diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 3c6172c..6b4f770 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -9,21 +9,21 @@ input methods requiring no external tools. **Product name:** Scriptty **Site URL:** stultus.in/scriptty (GitHub Pages, subdirectory of the lead developer's personal site) -**File extension:** .screenplay +**File extension:** .screenplay --- ## Tech Stack -| Layer | Technology | -|---|---| -| Desktop framework | Tauri 2 | -| Frontend | Svelte 5 + TypeScript + Vite | -| Editor | ProseMirror | -| PDF generation | Typst (Rust crate) | -| Mozhi engine | Custom (greedy transliteration from SMC Mozhi spec) | -| Backend language | Rust 1.93+ | -| Node.js | v22 / npm | +| Layer | Technology | +| ----------------- | --------------------------------------------------- | +| Desktop framework | Tauri 2 | +| Frontend | Svelte 5 + TypeScript + Vite | +| Editor | ProseMirror | +| PDF generation | Typst (Rust crate) | +| Mozhi engine | Custom (greedy transliteration from SMC Mozhi spec) | +| Backend language | Rust 1.93+ | +| Node.js | v22 / npm | --- @@ -144,22 +144,22 @@ These are final. Do not suggest alternatives unless explicitly asked. ### Element Navigation (Tab/Enter/Shortcut behavior) -| Current element | Key | Next element | -|---|---|---| -| SceneHeading | Enter | Action | -| Action | Enter | Action (new paragraph) | -| Action | Tab | Character | -| Character | Enter | Dialogue | -| Dialogue | Enter | Character | -| Dialogue | Tab | Parenthetical | -| Parenthetical | Enter | Dialogue | -| Parenthetical | Tab | Character | -| Transition | Enter | Action (Shift+Enter for a new scene — mid-scene transitions / montage) | -| Any element | Shift+Enter | SceneHeading (new scene) | -| Any element | Cmd+Shift+T | Transition | -| Character/Dialogue | Shift+Tab | Action | -| Parenthetical | Shift+Tab | Dialogue | -| Action (cursor at pos 0) | Shift+Tab | SceneHeading | +| Current element | Key | Next element | +| ------------------------ | ----------- | ---------------------------------------------------------------------- | +| SceneHeading | Enter | Action | +| Action | Enter | Action (new paragraph) | +| Action | Tab | Character | +| Character | Enter | Dialogue | +| Dialogue | Enter | Character | +| Dialogue | Tab | Parenthetical | +| Parenthetical | Enter | Dialogue | +| Parenthetical | Tab | Character | +| Transition | Enter | Action (Shift+Enter for a new scene — mid-scene transitions / montage) | +| Any element | Shift+Enter | SceneHeading (new scene) | +| Any element | Cmd+Shift+T | Transition | +| Character/Dialogue | Shift+Tab | Action | +| Parenthetical | Shift+Tab | Dialogue | +| Action (cursor at pos 0) | Shift+Tab | SceneHeading | ### Malayalam Language Support @@ -212,35 +212,35 @@ top-level fields are placeholders. ```json { - "type": "film", - "content": {}, - "meta": { - "title": "", - "author": "", - "director": "", - "tagline": "", - "registration_number": "", - "footnote": "", - "contact": "", - "draft_number": 1, - "draft_date": "", - "created_at": "", - "updated_at": "" - }, - "settings": { - "font": "manjari", - "default_language": "malayalam", - "input_scheme": "mozhi", - "scene_number_start": 1, - "show_characters_below_header": false - }, - "story": { - "idea": "", - "synopsis": "", - "treatment": "", - "narrative": "" - }, - "scene_cards": [] + "type": "film", + "content": {}, + "meta": { + "title": "", + "author": "", + "director": "", + "tagline": "", + "registration_number": "", + "footnote": "", + "contact": "", + "draft_number": 1, + "draft_date": "", + "created_at": "", + "updated_at": "" + }, + "settings": { + "font": "manjari", + "default_language": "malayalam", + "input_scheme": "mozhi", + "scene_number_start": 1, + "show_characters_below_header": false + }, + "story": { + "idea": "", + "synopsis": "", + "treatment": "", + "narrative": "" + }, + "scene_cards": [] } ``` @@ -268,6 +268,7 @@ Series file shape: ``` Notes: + - `type` defaults to `"film"` when missing — every legacy file loads unchanged. - Every meta/settings field is `#[serde(default)]`, so slim or hand-authored files (and series episodes that omit timestamps) deserialize without error. @@ -376,7 +377,7 @@ Full format spec: see `SCREENPLAY_FORMAT.md` at project root. Welcome screen / File menu) - Series have a top-level title plus an ordered list of episodes; each episode is a complete screenplay (own meta/settings/story/content/scene_cards) -- `documentStore` exposes active-* accessors (`activeContent`, `activeMeta`, +- `documentStore` exposes active-\* accessors (`activeContent`, `activeMeta`, `activeSettings`, `activeStory`, `activeSceneCards`, `activeEpisode`, `activeEpisodeIndex`) that multiplex film vs. series data — UI components read these instead of branching on project type @@ -465,10 +466,10 @@ Full format spec: see `SCREENPLAY_FORMAT.md` at project root. Two distinct modal idioms — pick the one that matches the surface: -| Idiom | Examples | When to use | -|---|---|---| -| **Centered card** | Metadata, Export, Statistics, About, Help, SeriesTitleDialog | Forms, references, anything with multiple inputs or its own focus context. Backdrop dims the page. | -| **Popover** | Settings (anchored to status-bar gear), Command Palette dropdown | UI anchored to a trigger; settings or quick lookups that should not block the page. Invisible click-catcher backdrop. | +| Idiom | Examples | When to use | +| ----------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| **Centered card** | Metadata, Export, Statistics, About, Help, SeriesTitleDialog | Forms, references, anything with multiple inputs or its own focus context. Backdrop dims the page. | +| **Popover** | Settings (anchored to status-bar gear), Command Palette dropdown | UI anchored to a trigger; settings or quick lookups that should not block the page. Invisible click-catcher backdrop. | All centered modals share tokens defined in `src/routes/+layout.svelte`: @@ -559,6 +560,7 @@ compiled Rust binary. The two communicate via **commands** — the frontend call function using `invoke()`, Rust executes it and returns a result. This means: + - UI logic, editor, input methods → Svelte/TypeScript - File I/O, PDF generation, OS integration → Rust - No server, no network, fully offline @@ -576,6 +578,7 @@ This means: ## Remaining Work ### Medium Term + - **Revision mode** — track changes per draft, asterisk marks in margin, Hollywood color cycle - **Draft history** — save snapshots on each save, restore from history, max 50 per file diff --git a/.claude/PROGRESS.md b/.claude/PROGRESS.md index 0bcbc11..2124dfd 100644 --- a/.claude/PROGRESS.md +++ b/.claude/PROGRESS.md @@ -3,6 +3,7 @@ ## Status: v0.10.0 shipped — Fountain & Final Draft interop, consolidated import wizard Highlights since v0.8.0: + - **Final Draft (`.fdx`) import** (v0.10.0). Hand-rolled XML parser using `quick-xml`. The six native paragraph types map directly; `Shot` folds to scene heading, `General` / `Lyrics` / `Outline N` fold to action. Inline `` runs become ProseMirror marks. `` collapses to sequential pairs. ``, ``, revisions, locked numbers, headers/footers, page layout drop with summary counts. Title-page text always lands in `meta.extra["fdx_title_page"]`; a Beat-style heuristic best-effort fills the standard meta fields. The FDX `Version` attribute lands in `meta.extra["fdx_source_version"]`. - **Round-trip-safe Fountain import + export** (v0.9.0 + v0.10.0). Full Fountain spec parser. Synopses absorb into scene-card descriptions; sections attach to the next scene's `shoot_notes` with a `[[#section depth=N]]` marker; inline `[[ ]]` notes attach to the containing scene. The export side applies forcing rules (`@` for non-all-caps-Latin character cues, `.` for non-slug scene headings, `>` for non-`TO:` transitions, `!` for action that would auto-detect as anything else) so a co-writer can edit a Scriptty-touched file and round-trip it back without silent corruption. Non-standard title-page keys round-trip via the new `meta.extra: BTreeMap` schema field. - **Single Import Screenplay wizard** (v0.10.0). One File-menu entry replaces four format-and-destination items. Centered-card modal with editorial-vocabulary header, format radio cards (Fountain / Final Draft) and destination radio cards (new film / episode of active series — disabled with explanatory sub-line when no series open). The standard `Cmd+O` Open dialog also accepts `.fountain` and `.fdx` directly. @@ -10,6 +11,7 @@ Highlights since v0.8.0: - **CI gating** (v0.9.0). New `.github/workflows/ci.yml` runs `cargo clippy --lib --tests -- -D warnings`, `cargo test --lib`, and `npm run check -- --fail-on-warnings` on push/PR to main. Caught and repaired 32 pre-existing stale tests in `pdf.rs` from earlier struct refactors. Update-Download-Links workflow hardened against the duplicate-trigger race that surfaced spurious failures on every release (concurrency group + rebase-retry). Highlights from v0.8.0 (still relevant): + - **Production planning end-to-end.** Scene cards carry a location group, shoot date, and extras list. Daily Shoot List PDF groups scenes by day → location with industry-standard page-eighths totals. Statistics panel gains Schedule and Episodes views, sortable columns, and CSV export across Characters / Locations / Schedule. - **Editorial-grade PDF redesign.** Title page, prose covers, scene-card cover, and shoot-list cover share one masthead vocabulary. Per-section page numbering. Transition widow control. Courier Prime now bundled into PDFs alongside the body font for accent typography. - **Episode Breakout view.** Series projects get a top-level card per episode with a scene preview list. IDE-style episode explorer in the sidebar with per-episode status (Outline / Draft / Revision / Final). @@ -21,6 +23,7 @@ Highlights from v0.8.0 (still relevant): ## Phase 1 — Completed ### Infrastructure + - [x] Tauri 2 + SvelteKit scaffold — desktop window - [x] Claude Code config — CLAUDE.md, 3 sub-agents, hooks - [x] Project structure scaffolded @@ -31,6 +34,7 @@ Highlights from v0.8.0 (still relevant): - [x] adapter-static for Tauri build ### Editor + - [x] ProseMirror schema — 8 node types - [x] Tab/Enter navigation keymap — full Hollywood element flow - [x] Shift+Enter — new scene heading from anywhere @@ -42,6 +46,7 @@ Highlights from v0.8.0 (still relevant): - [x] Font rendering via :global() CSS ### Input Methods + - [x] InputModeManager — Ctrl+Space toggle English/Malayalam - [x] Inscript 1 — static keymap - [x] Inscript 2 — static keymap @@ -50,6 +55,7 @@ Highlights from v0.8.0 (still relevant): - [x] Default scheme: Mozhi ### File I/O + - [x] .screenplay file format — JSON with content, meta, settings - [x] save_screenplay, open_screenplay, new_screenplay Tauri commands - [x] saveWithDialog() — native save dialog, Cmd+S shortcut @@ -59,22 +65,26 @@ Highlights from v0.8.0 (still relevant): - [x] Dirty state tracking — amber dot indicator ### Scene Navigator + - [x] Collapsible left panel — Ctrl+B toggle - [x] Auto-numbered scene list - [x] Click-to-jump - [x] Reactive updates on every keystroke ### Metadata + - [x] MetadataModal — title, author, contact, draft number, draft date - [x] Meta button in TitleBar - [x] Metadata persisted in .screenplay file ### Font Selection + - [x] Font selector UI — segmented control (Noto | Manjari) - [x] Live font switching in editor - [x] Font persisted in document settings ### PDF Export + - [x] Typst compiler integration — ScreenplayWorld trait, in-memory compilation - [x] Hollywood single-column PDF — A4, all element types, page break rules - [x] Indian two-column PDF — 50/50 grid, character/dialogue alignment, page break rules @@ -84,6 +94,7 @@ Highlights from v0.8.0 (still relevant): - [x] 17 unit tests passing ### UI / Design System + - [x] Full UI revamp — CSS custom properties, warm Kerala-rooted palette - [x] Dark/light theme toggle — themeStore with localStorage persistence - [x] TitleBar — ghost buttons, segmented font selector, teal primary Save @@ -98,6 +109,7 @@ Highlights from v0.8.0 (still relevant): ## Phase 2 — Completed ### 1. Help/About Menu + - [x] Help submenu in macOS native menu bar - [x] "About Scriptty" menu item → emits `menu-about` event → AboutModal - [x] AboutModal.svelte — ഋ logo, version 0.2.0, developer info, credits @@ -105,6 +117,7 @@ Highlights from v0.8.0 (still relevant): - [x] "View on GitHub" → opens repo in browser ### 2. Story Panel + - [x] `story` field added to ScreenplayDocument (Rust + TypeScript) with `#[serde(default)]` - [x] StoryPanel.svelte — three collapsible sections (Idea, Synopsis, Treatment) - [x] LeftPanel.svelte — tab switcher (Scenes | Story), widens to 420px on Story tab @@ -112,6 +125,7 @@ Highlights from v0.8.0 (still relevant): - [x] Data persisted in .screenplay JSON ### 3. Export Modal + - [x] ExportModal.svelte — replaces separate Hollywood/Indian buttons - [x] Checkbox sections: Title Page, Synopsis, Treatment, Screenplay, Scene Cards - [x] Format radio: Hollywood / Indian @@ -121,6 +135,7 @@ Highlights from v0.8.0 (still relevant): - [x] Single "Export" button in TitleBar opens modal ### 4. Scene Cards + - [x] `scene_cards` field added to ScreenplayDocument (Rust + TypeScript) with `#[serde(default)]` - [x] Scene heading parser — extracts location, time from INT./EXT. headings - [x] Character extractor — collects Character elements per scene @@ -131,6 +146,7 @@ Highlights from v0.8.0 (still relevant): - [x] Cmd+Shift+K shortcut to toggle view ### 5. Dirty-State Guard + - [x] Save confirmation dialog (Save / Don't Save / Cancel) via native `message` dialog - [x] Guards on: New, Open (TitleBar buttons + menu events + keyboard shortcuts) - [x] Window close interception via `onCloseRequested` @@ -142,12 +158,14 @@ Highlights from v0.8.0 (still relevant): ## Phase 3 — Completed ### 6. Character Autocomplete + - [x] ProseMirror plugin triggers after 2 chars typed in Character element - [x] Collects character names from document, filters by prefix (case-insensitive, Unicode-aware) - [x] Dropdown positioned below cursor, keyboard navigation (arrows/Enter/Tab/Escape) - [x] Accepts suggestion and creates Dialogue element below ### 7. Fountain Export + - [x] `fountain.rs` — ProseMirror JSON → Fountain plain text (.fountain) - [x] Title page block, auto-detected scene headings, Malayalam character `@` prefix - [x] Parentheticals wrapped, transitions auto-detected or forced with `>` @@ -155,6 +173,7 @@ Highlights from v0.8.0 (still relevant): - [x] 9 unit tests passing ### 8. Find and Replace + - [x] ProseMirror plugin with DecorationSet for search highlighting - [x] FindReplaceBar.svelte — find/replace modes, case sensitivity toggle - [x] Match navigation (next/prev), replace current, replace all (single undo step) @@ -162,6 +181,7 @@ Highlights from v0.8.0 (still relevant): - [x] Menu items in Edit menu ### 9. Script Statistics + - [x] StatisticsModal.svelte — computes from ProseMirror JSON on modal open - [x] Page count, scene count, word count, dialogue blocks, screen time estimate - [x] INT/EXT/Day/Night scene breakdown @@ -169,6 +189,7 @@ Highlights from v0.8.0 (still relevant): - [x] Refresh button, Cmd+Shift+I shortcut, View menu item ### 10. Plain Text Export + - [x] `plaintext.rs` — ProseMirror JSON → formatted plain text (.txt) - [x] Character names at col 40, dialogue at col 25 (35-char wrap), parentheticals at col 35 - [x] Transitions right-aligned, scene headings uppercase, metadata header block @@ -177,6 +198,7 @@ Highlights from v0.8.0 (still relevant): - [x] 9 unit tests passing ### 11. UI Consistency Fixes + - [x] All modals standardized to 480px width and 24px padding - [x] Hardcoded `#999` scene number color → `var(--text-muted)` - [x] FindReplaceBar border-radius standardized to 6px @@ -184,6 +206,7 @@ Highlights from v0.8.0 (still relevant): - [x] Window close/quit permission fix (`core:window:allow-close`) ### 12. Drag-and-Drop Scene Reordering + - [x] Scene Navigator: drag handle (⠿) appears on hover, custom mouse-event drag (WebKit-compatible) - [x] Scene Cards: scene number badge as drag handle, teal border highlight on drop target - [x] Reorder is a single ProseMirror transaction — undoable with Cmd+Z @@ -195,6 +218,7 @@ Highlights from v0.8.0 (still relevant): ## Phase 4 — Completed ### 13. Story Mode + - [x] StoryModeView.svelte — full-screen narrative writing view - [x] Page-card styling matching screenplay editor (white page, box shadow, centered) - [x] Malayalam input via InputModeManager singleton (Ctrl+Space, scheme selector) @@ -204,6 +228,7 @@ Highlights from v0.8.0 (still relevant): - [x] `narrative` field added to ScreenplayStory (Rust + TypeScript) with `#[serde(default)]` ### 14. Director Credits & PDF Export Improvements + - [x] `director` field added to ScreenplayMeta (Rust + TypeScript) with `#[serde(default)]` - [x] MetadataModal updated — "Written by" / "Directed by" labels, director input field - [x] Smart credit formatting: combined "Written and Directed by" when same person @@ -216,6 +241,7 @@ Highlights from v0.8.0 (still relevant): - [x] Format selector shown only when Screenplay is checked ### 15. Parenthetical Element Support + - [x] Tab from Dialogue creates Parenthetical (was Dialogue → Tab → Character) - [x] Tab from Parenthetical → Character, Shift+Tab from Parenthetical → Dialogue - [x] Auto-parentheses via CSS `::before`/`::after` — parens are visual only, not stored in content @@ -225,10 +251,12 @@ Highlights from v0.8.0 (still relevant): - [x] HelpModal updated with parenthetical navigation ### 16. Transition Shortcut + - [x] Cmd+Shift+T converts any element to Transition - [x] HelpModal updated with shortcut ### 17. File Format Specification + - [x] SCREENPLAY_FORMAT.md — complete spec of .screenplay JSON format - [x] All element types, meta fields, settings, story, scene cards documented - [x] Sequencing rules, examples, and LLM generation notes included @@ -238,22 +266,26 @@ Highlights from v0.8.0 (still relevant): ## Phase 5 — Completed ### 18. Continuous Page View (PR #2) + - [x] Editor uses infinite scroll — single continuous page, no page breaks - [x] ProseMirror min-height for seamless scrolling experience - [x] Simplified Editor.svelte — removed paginated rendering logic ### 19. Menu Bar Cleanup (PR #3) + - [x] TitleBar simplified — left-pane toggle button added - [x] Font selector, theme toggle, language controls removed from TitleBar - [x] Controls consolidated into Settings modal ### 20. Integrated Settings Modal (PR #4) + - [x] SettingsModal.svelte — consolidated language, keyboard scheme, font, theme - [x] Opens from gear icon in editor status bar (bottom-left popup) - [x] Keyboard scheme dropdown shown only when Malayalam mode is active - [x] Segmented controls for font and theme selection ### 21. Window & CI Improvements + - [x] Window launches maximized instead of fullscreen (fixes Windows taskbar issue) - [x] Rust dependency caching in GitHub Actions release workflow - [x] Hiran Venugopalan added as developer in About modal @@ -263,17 +295,20 @@ Highlights from v0.8.0 (still relevant): ## Phase 6 — Completed (v0.6.x → v0.7.0) ### 22. In-app Updates + - [x] `Help → Check for Updates` menu item with non-intrusive `UpdateToast` - [x] `updateStore.svelte.ts` performs the version check on demand - [x] Toast z-index lowered below modals (#56) ### 23. Theme & Typography + - [x] Kerala palette — teal accent, amber dirty-indicator, oxblood error tones (#69) - [x] Courier Prime + new typography hierarchy (#66, #70) — UI font, not embedded in PDFs - [x] Subtle fractal grain on the screenplay page (#68) - [x] Cool find-match highlight, raised page depth, SVG drag handle (#62, #64, #65) ### 24. Editor Polish + - [x] Floating B/I/U bubble above selection (`FormatBubble.svelte`, #71) - [x] Visual signals in Scene Navigator — INT/EXT, DAY/NIGHT, notes (#72) - [x] Signature scene-number gutter (#67) @@ -284,16 +319,19 @@ Highlights from v0.8.0 (still relevant): - [x] Document Properties moved from View → File menu (#77) ### 25. Command Palette & Status Bar + - [x] ⌘K Command Palette with fuzzy search (#76) - [x] Quieter status bar (#76) — view-switcher shortcuts on hover (#74) - [x] "Saved N min ago" indicator (#73) - [x] Symmetric view-switcher tabs ### 26. Performance + - [x] Consolidated gutter RAF chain + resize observer (#63) - [x] Event-driven input mode (replaced 200ms polling, #60) ### 27. Web Series Support + - [x] Series data model + `ProjectType::Film | Series` enum - [x] Active-episode accessors on `documentStore` — `activeContent`, `activeMeta`, `activeSettings`, `activeStory`, `activeSceneCards`, `activeEpisode`, @@ -307,6 +345,7 @@ Highlights from v0.8.0 (still relevant): - [x] Scene-card character extras keyed by flat `scene_index` across episodes ### 28. Issue-review batch (#78–#97) + - [x] Series export in backend commands (#78) - [x] StatisticsModal / OutlinePeek read activeContent (#79, #80) - [x] Scene-card extras keying in series PDF (#81) @@ -328,12 +367,14 @@ Highlights from v0.8.0 (still relevant): - [x] Single `DEFAULT_FONT` const (#97) ### 29. Release engineering + - [x] All four platforms ship signed/notarized installers (macOS arm64, macOS x64, Windows, Linux deb/AppImage/rpm) via tauri-action matrix build - [x] `update-downloads.yml` workflow auto-refreshes `docs/downloads.json` on release - [x] `cargo clippy` + `npx svelte-check` at zero warnings (gate) ### 30. Fountain import + round-trip (v0.9.0, #184) + - [x] `meta.extra: BTreeMap` schema field for non-standard title-page keys (#185) - [x] Hand-rolled Fountain parser in `src-tauri/src/screenplay/fountain_import.rs` @@ -358,6 +399,7 @@ Highlights from v0.8.0 (still relevant): round-trip fixed-point tests ### 31. Final Draft (FDX) import + import wizard (v0.10.0, #190) + - [x] `quick-xml` (MIT, pure-Rust) added as a dependency; FDX parser in `src-tauri/src/screenplay/fdx_import.rs` (#191) - [x] Six native paragraph types map directly; `Shot` folds to scene heading; @@ -387,9 +429,10 @@ Highlights from v0.8.0 (still relevant): UTF-8 BOM stripping, Malayalam pass-through ### 32. CI hardening + - [x] New `.github/workflows/ci.yml` — gates `cargo clippy --lib --tests - -- -D warnings`, `cargo test --lib`, `npm run check -- - --fail-on-warnings` on push/PR to main (#189) +-- -D warnings`, `cargo test --lib`, `npm run check -- +--fail-on-warnings` on push/PR to main (#189) - [x] Repaired 32 pre-existing stale tests in `pdf.rs` (struct refactors from #103 had drifted past the test code) - [x] `update-downloads.yml` race fix — `release` event was firing the @@ -402,6 +445,7 @@ Highlights from v0.8.0 (still relevant): ## Remaining Work ### Medium Term + - [ ] Revision mode — track changes per draft, asterisk marks in margin, Hollywood color cycle - [ ] Draft history — save snapshots on each save, restore from history, max 50 per file - [ ] FDX (Final Draft XML) **export** — currently we import FDX but don't export diff --git a/.claude/agents/pdf-export.md b/.claude/agents/pdf-export.md index a8d211b..7a4c84b 100644 --- a/.claude/agents/pdf-export.md +++ b/.claude/agents/pdf-export.md @@ -1,12 +1,14 @@ # PDF Export Agent ## Role + You are the PDF export specialist for Scriptty. You work across both the Rust backend (`src-tauri/src/commands/export.rs`, `src-tauri/src/screenplay/pdf.rs`) and the frontend export UI (`src/lib/components/ExportDialog.svelte`). Your sole focus is producing correct, well-formatted PDF output from a Scriptty document. ## Your Responsibilities + - Converting ProseMirror JSON document to Typst markup string - Typst template for Hollywood single-column format - Typst template for Indian two-column format @@ -40,6 +42,7 @@ Frontend (Svelte) No temp files are written at any point. Everything stays in memory. ## Typst Fundamentals + Typst is a document markup language and compiler. A Typst document looks like: ```typst @@ -71,29 +74,33 @@ Typst is generated programmatically as a String in Rust, then compiled by the Ty library. ## Hollywood Format Measurements + Match these exactly — they define the screenplay standard: -| Element | Left indent | Right indent | Case | -|---|---|---|---| -| Scene Heading | 0 | 0 | UPPERCASE | -| Action | 0 | 0 | Mixed case | -| Character name | 2.2in | 0 | UPPERCASE | -| Parenthetical | 1.6in | 2in | Mixed case | -| Dialogue | 1in | 1.5in | Mixed case | -| Transition | flush right | 0 | UPPERCASE | +| Element | Left indent | Right indent | Case | +| -------------- | ----------- | ------------ | ---------- | +| Scene Heading | 0 | 0 | UPPERCASE | +| Action | 0 | 0 | Mixed case | +| Character name | 2.2in | 0 | UPPERCASE | +| Parenthetical | 1.6in | 2in | Mixed case | +| Dialogue | 1in | 1.5in | Mixed case | +| Transition | flush right | 0 | UPPERCASE | Page: A4, margins left 1.5in, right 1in, top 1in, bottom 1in. Font size: 12pt. Line spacing: single, with one blank line between elements. ## Indian Two-Column Format + Two columns separated by a vertical line: + - Left column (45% width): scene description, action, direction - Right column (55% width): character name + dialogue - Scene headings span full width - Column ratio: 45/55 ## Font Embedding + Fonts are loaded from `src-tauri/fonts/` at compile time using Rust's `include_bytes!` macro. They are passed to Typst's font system so they are embedded in every PDF — no system font dependency. @@ -107,6 +114,7 @@ static MANJARI_REGULAR: &[u8] = include_bytes!("../fonts/Manjari-Regular.ttf"); ``` ## Title Page + Generated as the first page of every PDF export. Fields come from document `meta`: ```typst @@ -126,6 +134,7 @@ Generated as the first page of every PDF export. Fields come from document `meta ``` ## Coding Standards + - Typst markup is generated as a `String` — use Rust's `format!` and `write!` macros - Never write Typst to a file — always keep it in memory as a String - PDF bytes returned as `Vec` from all generation functions diff --git a/.claude/agents/rust-backend.md b/.claude/agents/rust-backend.md index 161fb11..8d29085 100644 --- a/.claude/agents/rust-backend.md +++ b/.claude/agents/rust-backend.md @@ -1,10 +1,12 @@ # Rust Backend Agent ## Role + You are the Rust backend specialist for Scriptty. You work exclusively inside `src-tauri/`. You never touch files in `src/` (the Svelte frontend). ## Your Responsibilities + - Tauri command handlers in `src-tauri/src/commands/` - File I/O — saving and opening `.screenplay` JSON files - PDF generation via the Typst Rust crate @@ -15,6 +17,7 @@ You are the Rust backend specialist for Scriptty. You work exclusively inside - `tauri.conf.json` configuration ## Developer Context + The lead developer is not a Rust expert. Follow these rules strictly: - Add inline comments explaining ownership and borrowing when they appear @@ -26,6 +29,7 @@ The lead developer is not a Rust expert. Follow these rules strictly: - All Tauri commands return `Result` so errors surface cleanly to the frontend ## Tauri Command Pattern + Every function exposed to the frontend follows this pattern: ```rust @@ -44,19 +48,23 @@ pub fn command_name(argument: Type) -> Result { ``` ## Key Libraries + - `tauri` — desktop app framework - `serde` / `serde_json` — JSON serialization (reading/writing .screenplay files) - `typst` — PDF compiler (do not use printpdf or any other PDF crate) - `std::fs` — file system operations ## File Ownership + Only modify files under `src-tauri/`. Never modify: + - `src/` — frontend territory - `package.json` — frontend config - `vite.config.ts` — frontend config - `svelte.config.js` — frontend config ## Coding Standards + - `snake_case` for all functions and variables - `PascalCase` for all structs, enums, and types - Doc comments (`///`) on all public functions diff --git a/.claude/agents/svelte-frontend.md b/.claude/agents/svelte-frontend.md index 5949238..85f115f 100644 --- a/.claude/agents/svelte-frontend.md +++ b/.claude/agents/svelte-frontend.md @@ -1,10 +1,12 @@ # Svelte Frontend Agent ## Role + You are the Svelte frontend specialist for Scriptty. You work exclusively inside `src/` and `static/`. You never touch files in `src-tauri/` (the Rust backend). ## Your Responsibilities + - ProseMirror editor setup and schema (`src/lib/editor/`) - Malayalam input engine (`src/lib/editor/input/`) - Svelte components (`src/lib/components/`) @@ -15,6 +17,7 @@ and `static/`. You never touch files in `src-tauri/` (the Rust backend). - CSS and styling ## Svelte Version + This project uses **Svelte 5**. Always use runes syntax: ```typescript @@ -26,22 +29,24 @@ let doubled = $derived(count * 2); // Side effects $effect(() => { - console.log(count); + console.log(count); }); // Props in components let { title, onSave } = $props<{ - title: string; - onSave: () => void; + title: string; + onSave: () => void; }>(); ``` Never use legacy Svelte 4 syntax: -- No `export let` for props — use `$props()` + +- No `export let` for props — use `$props()` - No reactive `$:` statements — use `$derived` or `$effect` - `onMount` is acceptable for DOM interactions ## ProseMirror Context + The screenplay editor uses ProseMirror. Key concepts: - **Schema** — defines the allowed element types (SceneHeading, Action, Character, etc.) @@ -54,6 +59,7 @@ All screenplay element types are defined in `src/lib/editor/schema.ts`. Do not a element types without updating the schema. ## Malayalam Input Architecture + The input system has four files: - `InputModeManager.ts` — tracks current mode (English/Malayalam) and active scheme. @@ -68,6 +74,7 @@ The input layer intercepts ProseMirror keydown events directly — do not use br composition events (compositionstart/compositionend) for this system. ## Tauri Integration + To call a Rust backend function from Svelte: ```typescript @@ -75,19 +82,20 @@ import { invoke } from '@tauri-apps/api/core'; // Calling a Rust command const result = await invoke('command_name', { - argumentName: value + argumentName: value }); ``` Always wrap `invoke` calls in try/catch — Rust errors surface as thrown exceptions. ## Font Usage + Fonts are in `static/fonts/`. Apply via CSS: ```css @font-face { - font-family: 'Noto Sans Malayalam'; - src: url('/fonts/NotoSansMalayalam-Regular.ttf') format('truetype'); + font-family: 'Noto Sans Malayalam'; + src: url('/fonts/NotoSansMalayalam-Regular.ttf') format('truetype'); } ``` @@ -95,11 +103,14 @@ The same font applies to all text — Malayalam and English. Do not use separate for different scripts. ## File Ownership + Only modify files under `src/` and `static/`. Never modify: + - `src-tauri/` — Rust backend territory - `src-tauri/Cargo.toml` — Rust dependencies ## Coding Standards + - TypeScript strict mode — no `any` types - All `invoke` calls must have explicit error handling - Component files use PascalCase: `SceneNavigator.svelte` diff --git a/.claude/settings.json b/.claude/settings.json index d4404da..6b75ef7 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,26 +1,26 @@ { - "hooks": { - "PostToolUse": [ - { - "matcher": "Write|Edit|MultiEdit", - "hooks": [ - { - "type": "command", - "command": "if echo '$CLAUDE_TOOL_INPUT' | jq -e '.path // .file_path' | grep -q 'src-tauri.*\\.rs$'; then cd src-tauri && cargo check 2>&1 | head -50; fi" - } - ] - } - ], - "PreToolUse": [ - { - "matcher": "Write|Edit|MultiEdit", - "hooks": [ - { - "type": "command", - "command": "if echo '$CLAUDE_TOOL_INPUT' | jq -e '.path // .file_path' | grep -q '\\.ts$\\|\\.svelte$'; then npx tsc --noEmit 2>&1 | head -30; fi" - } - ] - } - ] - } + "hooks": { + "PostToolUse": [ + { + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "if echo '$CLAUDE_TOOL_INPUT' | jq -e '.path // .file_path' | grep -q 'src-tauri.*\\.rs$'; then cd src-tauri && cargo check 2>&1 | head -50; fi" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "if echo '$CLAUDE_TOOL_INPUT' | jq -e '.path // .file_path' | grep -q '\\.ts$\\|\\.svelte$'; then npx tsc --noEmit 2>&1 | head -30; fi" + } + ] + } + ] + } } diff --git a/.claude/skills/ui-design/SKILL.md b/.claude/skills/ui-design/SKILL.md index 8d238f8..5074def 100644 --- a/.claude/skills/ui-design/SKILL.md +++ b/.claude/skills/ui-design/SKILL.md @@ -8,6 +8,7 @@ description: UI/UX design skill for Scriptty — offline Malayalam/English scree ## App Identity Scriptty is an offline desktop screenwriting app for Malayalam and English writers. The identity is: + - **Literary, not corporate** — this is a writer's tool, not a productivity SaaS - **Kerala-rooted** — the primary user is a Malayalam filmmaker or writer; the aesthetic can draw from Kerala's visual culture: deep greens, warm terracotta, ink blacks, aged paper whites - **Distraction-free, text-first** — the screenplay on screen is the hero; chrome exists only to serve it @@ -20,12 +21,14 @@ The app icon is ഋ inside a clapperboard, teal and dark. All UI decisions shoul ## Design Philosophy ### Core Principles (in priority order) + 1. **The screenplay is the UI** — the page/document area must always feel like the primary element. Everything else is peripheral. 2. **Reduce cognitive load** — writers shouldn't think about the interface while writing. Hide complexity; surface only what's needed. 3. **Every pixel earns its place** — no decorative elements. If a button exists, it must be obviously useful. If a label exists, it must say something. 4. **Dark and light modes are both first-class** — not a palette swap. Each mode has its own character. ### Anti-patterns to avoid (learned from research) + - Final Draft: dated icons, unpleasant colors, cluttered toolbar, too many visible options at once - Generic SaaS dark mode: purple gradients, neon accents, glassy cards — wrong context entirely - Pure black (#000000) backgrounds — causes glare and haloing on most screens @@ -37,62 +40,64 @@ The app icon is ഋ inside a clapperboard, teal and dark. All UI decisions shoul ## Color System ### Dark Mode (default) + ```css /* Surfaces — layered, never flat black */ ---surface-base: #1a1a1a; /* Main window background */ ---surface-elevated: #222222; /* TitleBar, panels */ ---surface-float: #2a2a2a; /* Modals, dropdowns */ ---surface-hover: #303030; /* Button hover states */ ---surface-active: #383838; /* Button pressed */ +--surface-base: #1a1a1a; /* Main window background */ +--surface-elevated: #222222; /* TitleBar, panels */ +--surface-float: #2a2a2a; /* Modals, dropdowns */ +--surface-hover: #303030; /* Button hover states */ +--surface-active: #383838; /* Button pressed */ /* The screenplay page — warm, paper-like, not pure white */ ---page-bg: #f5f0e8; /* Warm cream — like aged paper */ ---page-shadow: rgba(0,0,0,0.5); /* Drop shadow under page */ +--page-bg: #f5f0e8; /* Warm cream — like aged paper */ +--page-shadow: rgba(0, 0, 0, 0.5); /* Drop shadow under page */ /* Text — off-white hierarchy, never pure white */ ---text-primary: #e8e6e1; /* Main UI text — warm off-white */ ---text-secondary: #9e9a94; /* Labels, secondary info */ ---text-muted: #5e5a55; /* Disabled, placeholder */ ---text-on-page: #1a1a1a; /* Text inside the screenplay page */ +--text-primary: #e8e6e1; /* Main UI text — warm off-white */ +--text-secondary: #9e9a94; /* Labels, secondary info */ +--text-muted: #5e5a55; /* Disabled, placeholder */ +--text-on-page: #1a1a1a; /* Text inside the screenplay page */ /* Accent — teal, consistent with app icon */ ---accent: #2d9b8a; /* Primary accent — teal */ ---accent-hover: #35b5a2; /* Teal hover */ ---accent-muted: rgba(45,155,138,0.15); /* Teal background wash */ +--accent: #2d9b8a; /* Primary accent — teal */ +--accent-hover: #35b5a2; /* Teal hover */ +--accent-muted: rgba(45, 155, 138, 0.15); /* Teal background wash */ /* State colors */ ---dirty: #e8a04a; /* Unsaved changes indicator — warm amber */ ---error: #c0574a; /* Error state */ ---success: #4a9e6e; /* Success */ +--dirty: #e8a04a; /* Unsaved changes indicator — warm amber */ +--error: #c0574a; /* Error state */ +--success: #4a9e6e; /* Success */ /* Borders */ ---border-subtle: rgba(255,255,255,0.07); ---border-medium: rgba(255,255,255,0.12); +--border-subtle: rgba(255, 255, 255, 0.07); +--border-medium: rgba(255, 255, 255, 0.12); ``` ### Light Mode + ```css /* Surfaces */ ---surface-base: #f0ede8; /* Warm off-white base — not clinical white */ ---surface-elevated: #e8e4de; /* TitleBar, panels — slightly darker */ ---surface-float: #faf8f5; /* Modals — lightest surface */ ---surface-hover: #dedad4; /* Hover */ ---surface-active: #d2cdc7; /* Pressed */ +--surface-base: #f0ede8; /* Warm off-white base — not clinical white */ +--surface-elevated: #e8e4de; /* TitleBar, panels — slightly darker */ +--surface-float: #faf8f5; /* Modals — lightest surface */ +--surface-hover: #dedad4; /* Hover */ +--surface-active: #d2cdc7; /* Pressed */ /* The screenplay page in light mode */ ---page-bg: #ffffff; /* Pure white page in light mode */ ---page-shadow: rgba(0,0,0,0.12); +--page-bg: #ffffff; /* Pure white page in light mode */ +--page-shadow: rgba(0, 0, 0, 0.12); /* Text */ ---text-primary: #1a1916; /* Near-black, warm undertone */ ---text-secondary: #5c5852; /* Secondary */ ---text-muted: #9c9891; /* Disabled */ +--text-primary: #1a1916; /* Near-black, warm undertone */ +--text-secondary: #5c5852; /* Secondary */ +--text-muted: #9c9891; /* Disabled */ --text-on-page: #1a1a1a; /* Accent — same teal, slightly darker for light bg contrast */ --accent: #1e8070; --accent-hover: #237a6a; ---accent-muted: rgba(30,128,112,0.1); +--accent-muted: rgba(30, 128, 112, 0.1); /* State */ --dirty: #c47f28; @@ -100,11 +105,12 @@ The app icon is ഋ inside a clapperboard, teal and dark. All UI decisions shoul --success: #2e7d52; /* Borders */ ---border-subtle: rgba(0,0,0,0.08); ---border-medium: rgba(0,0,0,0.14); +--border-subtle: rgba(0, 0, 0, 0.08); +--border-medium: rgba(0, 0, 0, 0.14); ``` ### Theme Toggle Implementation + - Store in `documentStore` or a separate `themeStore` using `$state` - Apply as a `data-theme="dark"` or `data-theme="light"` attribute on `` or `` - CSS variables scoped via `[data-theme="dark"]` and `[data-theme="light"]` @@ -117,6 +123,7 @@ The app icon is ഋ inside a clapperboard, teal and dark. All UI decisions shoul ## Typography ### UI Typography (TitleBar, status bar, panels) + - **Font**: `system-ui, -apple-system` — native macOS San Francisco for all UI chrome - Never use web fonts for UI labels — they slow rendering and look wrong on macOS - Size scale: @@ -126,12 +133,14 @@ The app icon is ഋ inside a clapperboard, teal and dark. All UI decisions shoul - `15px` — modal headings, section titles ### Screenplay Page Typography + - Malayalam content: Noto Sans Malayalam or Manjari (user-selectable, already implemented) - English content in screenplay: `'Courier Prime', 'Courier New', monospace` — standard screenplay font - Page text sizes follow Hollywood spec (12pt Courier = ~16px at 96dpi) - Line height on page: `1.6` minimum — critical for Malayalam rendering ### Typography Rules for Dark Mode + - Body text: `--text-primary` (#e8e6e1) — warm off-white, NOT pure white - Secondary text: `--text-secondary` — use for labels, not body - Avoid italic emphasis in dark UI — use `font-weight: 500` instead @@ -162,6 +171,7 @@ The app icon is ഋ inside a clapperboard, teal and dark. All UI decisions shoul ``` ### TitleBar + - Height: `40px` - Background: `--surface-elevated` - Bottom border: `1px solid --border-subtle` @@ -173,6 +183,7 @@ The app icon is ഋ inside a clapperboard, teal and dark. All UI decisions shoul - Separator between button groups: `1px solid --border-subtle`, `16px` vertical margin ### Scene Navigator + - Width: `220px`, fixed - Background: `--surface-base` in dark, slightly lighter than main in light - Right border: `1px solid --border-subtle` @@ -182,6 +193,7 @@ The app icon is ഋ inside a clapperboard, teal and dark. All UI decisions shoul - Scene text: `--text-secondary`, `12px`, truncated with ellipsis ### Editor Area + - Background: `--surface-base` - Page centered, max-width `680px`, margin `auto` - Page padding: `60px 72px` (standard screenplay margins) @@ -191,6 +203,7 @@ The app icon is ഋ inside a clapperboard, teal and dark. All UI decisions shoul - Scroll behavior: smooth, scrollbar styled (thin, muted color) ### Status Bar + - Height: `28px` - Background: `--surface-elevated` - Top border: `1px solid --border-subtle` @@ -206,77 +219,86 @@ The app icon is ഋ inside a clapperboard, teal and dark. All UI decisions shoul ### Buttons #### Ghost Button (default for most toolbar actions) + ```css .btn-ghost { - height: 28px; - padding: 0 10px; - border-radius: 6px; - border: none; - background: transparent; - color: var(--text-secondary); - font-size: 12px; - cursor: pointer; - transition: background 120ms ease, color 120ms ease; + height: 28px; + padding: 0 10px; + border-radius: 6px; + border: none; + background: transparent; + color: var(--text-secondary); + font-size: 12px; + cursor: pointer; + transition: + background 120ms ease, + color 120ms ease; } .btn-ghost:hover { - background: var(--surface-hover); - color: var(--text-primary); + background: var(--surface-hover); + color: var(--text-primary); } .btn-ghost:active { - background: var(--surface-active); + background: var(--surface-active); } ``` #### Primary Button (Save) + ```css .btn-primary { - background: var(--accent); - color: white; - /* same sizing as ghost */ + background: var(--accent); + color: white; + /* same sizing as ghost */ } .btn-primary:hover { - background: var(--accent-hover); + background: var(--accent-hover); } ``` #### Icon Button (for theme toggle, collapse panel) + ```css .btn-icon { - width: 28px; - height: 28px; - padding: 0; - display: flex; - align-items: center; - justify-content: center; - border-radius: 6px; - /* same hover/active as ghost */ + width: 28px; + height: 28px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + /* same hover/active as ghost */ } ``` #### Segmented Control (font selector, scheme selector) + ```css .segmented { - display: flex; - background: var(--surface-base); - border-radius: 6px; - padding: 2px; - gap: 1px; + display: flex; + background: var(--surface-base); + border-radius: 6px; + padding: 2px; + gap: 1px; } .segmented-item { - padding: 3px 8px; - border-radius: 4px; - font-size: 11px; - color: var(--text-muted); - cursor: pointer; - transition: background 100ms, color 100ms; + padding: 3px 8px; + border-radius: 4px; + font-size: 11px; + color: var(--text-muted); + cursor: pointer; + transition: + background 100ms, + color 100ms; } .segmented-item.active { - background: var(--surface-elevated); - color: var(--text-primary); + background: var(--surface-elevated); + color: var(--text-primary); } ``` ### Modal + - Backdrop: `rgba(0,0,0,0.6)` with `backdrop-filter: blur(4px)` - Modal card: `--surface-float`, `12px` border-radius, `1px solid --border-medium` - Width: `480px`, never full-screen on desktop @@ -288,6 +310,7 @@ The app icon is ഋ inside a clapperboard, teal and dark. All UI decisions shoul - Transition: fade + scale(0.97 → 1.0), 150ms ease-out ### Dirty Indicator + - Small dot `6px` diameter in the title zone - Color: `--dirty` (#e8a04a) when unsaved - Transparent when saved @@ -298,22 +321,26 @@ The app icon is ഋ inside a clapperboard, teal and dark. All UI decisions shoul ## Interaction Patterns ### Theme Toggle + - Place in TitleBar right group, leftmost icon before font selector - Sun icon (☀) in dark mode, moon icon (🌙) in light mode — or use text "Light"/"Dark" - Instant CSS variable swap, no flash — use `transition: background 200ms, color 200ms` on `body` - Do NOT animate the screenplay page background color — it causes jarring page flicker ### Navigator Collapse + - Ctrl+B (already implemented) — keep shortcut - Collapse button (‹/›) at top of navigator - Collapsed state: navigator is `0px` width with `overflow: hidden`, editor expands to full width - Transition: `width 200ms cubic-bezier(0.4, 0, 0.2, 1)` ### Focus Mode (optional, Phase 2) + - Hide TitleBar and status bar, expand editor full window - Triggered by Cmd+Shift+F or a button in TitleBar ### Hover States + - All interactive elements must have visible hover state - Transition duration: `120ms` — fast enough to feel snappy, not jarring - Never use opacity-only hover — change background instead @@ -323,85 +350,93 @@ The app icon is ഋ inside a clapperboard, teal and dark. All UI decisions shoul ## Svelte Implementation Notes ### Theme Store Pattern + ```typescript // src/lib/stores/themeStore.svelte.ts -const STORAGE_KEY = 'scriptty-theme' -type Theme = 'dark' | 'light' +const STORAGE_KEY = 'scriptty-theme'; +type Theme = 'dark' | 'light'; function getInitialTheme(): Theme { - const stored = localStorage.getItem(STORAGE_KEY) - if (stored === 'dark' || stored === 'light') return stored - return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' + const stored = localStorage.getItem(STORAGE_KEY); + if (stored === 'dark' || stored === 'light') return stored; + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } class ThemeStore { - current = $state('dark') - - init() { - this.current = getInitialTheme() - this.apply() - } - - toggle() { - this.current = this.current === 'dark' ? 'light' : 'dark' - localStorage.setItem(STORAGE_KEY, this.current) - this.apply() - } - - apply() { - document.documentElement.setAttribute('data-theme', this.current) - } + current = $state('dark'); + + init() { + this.current = getInitialTheme(); + this.apply(); + } + + toggle() { + this.current = this.current === 'dark' ? 'light' : 'dark'; + localStorage.setItem(STORAGE_KEY, this.current); + this.apply(); + } + + apply() { + document.documentElement.setAttribute('data-theme', this.current); + } } -export const themeStore = new ThemeStore() +export const themeStore = new ThemeStore(); ``` ### CSS Variable Scoping + ```css /* In app.css or +layout.svelte - - - - - - -
- - -
-
-

Scriptty.

-

- A free, offline screenwriting app for Malayalam & English writers. Hollywood single-column & Indian two-column formats, built-in Malayalam input, production-ready PDFs. -

- -
-

Loading installers…

-
- -

- v0.8.0 - Mac · Windows · Linux - MIT · Source on GitHub - All releases → -

-
-
- - -
-
-

Malayalam screenwriters have always had to choose between two compromises — write in a tool built for Latin scripts and fight every mixed-script line, or use a generic word processor and lose the formatting that makes a screenplay readable on set.

-

Scriptty is the third option. The editor speaks screenplay, not "rich text" — every element has its own behaviour, its own keyboard shortcut, its own typography. Malayalam input is built in, so you toggle scripts mid-sentence with Ctrl+Space and never leave the page.

-
-
- - -
-
- Why Scriptty -

Built for how screenwriters actually work.

-
- -
- -
- № 01 · Language -

Malayalam & English, mixed freely

-

Three built-in input schemes — Mozhi (phonetic), Inscript 1, and Inscript 2. No OS keyboard setup needed. Mix Malayalam and English on the same line.

-
- -
- № 02 · Format -

Hollywood or Indian two-column

-

Write in standard Hollywood single-column. Export as Indian two-column for production teams who want dialogue and translation side by side.

-
- -
- № 03 · Privacy -

Fully offline. Yours.

-

Zero network calls at runtime. Your screenplays live on your machine as .screenplay files. No cloud, no account, no telemetry.

-
- -
- № 04 · Three views -

One file, three lenses

-

Writing view with scene annotations in the margin. Cards view as a typeset shooting-script tear sheet. Story view for Idea, Synopsis, Treatment, and Narrative.

-
- -
- № 05 · Web series -

Multi-episode, one file

-

An IDE-style episode explorer with per-episode status, drag-to-reorder, and an Episode Breakout view that drills from a series overview into each episode's scenes.

-
- -
- № 06 · Production -

Plan the shoot, not just the script

-

Tag each scene with a location group and a shoot date. Export a Daily Shoot List PDF — scenes grouped by day & location with industry-standard page-eighths totals.

-
- -
- № 07 · PDF -

An editorial-grade printed page

-

Title page, prose covers, and scene cards share one masthead vocabulary. Per-section page numbering, transition widow control, full-width production cards, Compact card-view export.

-
- -
- № 08 · Editor -

A smart, restrained editor

-

Auto-uppercase scene headings & characters. Malayalam-aware autocomplete. Smart curly quotes. Adjustable editor font size. Paste a plain-text draft and watch it become a properly formatted screenplay.

-
- -
- № 09 · Recovery -

Autosave that survives a crash

-

Background autosave to a hidden recovery file. If the app or your machine crashes, the next launch offers to restore your unsaved work — even an unsaved new document survives a power loss.

-
- -
- № 10 · Interop -

Imports Fountain and Final Draft

-

Co-writers' .fountain and .fdx hand-offs flow in through one Import Screenplay… wizard. Round-trip-aware Fountain export sends them back without silent data loss. Per-episode Fountain export for series.

-
- -
-
- - -
-
- Quick reference -

A pocket guide.

-

Three columns covering the editor, the production prep, and the file/output side. Full manual lives inside the app under Help.

-
- -
- -
-

The editor

- -

Element navigation

-

The editor cycles through six element types via Tab and Enter.

-
    -
  • Scene Heading → Enter → Action
  • -
  • Action → Tab → Character
  • -
  • Character → Enter → Dialogue
  • -
  • Anywhere → Shift+Enter → new Scene Heading
  • -
  • Anywhere → Cmd+Shift+T → Transition
  • -
- -

Malayalam input

-

Ctrl+Space toggles English ↔ Malayalam mid-sentence. Three schemes — Mozhi, Inscript 2, Inscript 1.

- -

Smart features

-

Smart curly quotes. Character autocomplete after 2 characters (Malayalam-aware). Auto-uppercase for scene headings and character cues.

-
- -
-

Production

- -

Scene cards

-

Each card carries description, shoot notes, extras list, location group, and shoot date. Cmd+Shift+K opens the Cards view.

- -

Daily Shoot List

-

From the Export modal, pick Daily Shoot List for a per-day production report — scenes grouped by date & location with page-eighths totals.

- -

Statistics

-

Cmd+Shift+I opens five views — Overview, Characters, Locations, Schedule, Episodes — with sortable columns and CSV export.

- -

Web series

-

Cmd+Shift+N creates a new series file. Each episode has its own metadata, scene cards, and story sections.

-
- -
-

Files & output

- -

The .screenplay format

-

Plain JSON. Human-readable. Open it in any text editor if you ever need to.

- -

Import

-
    -
  • Fountain (.fountain) — Highland, Slugline, Beat, WriterDuet hand-offs. Round-trip aware: synopses, sections, inline notes, and non-standard title-page keys re-emit on Fountain export.
  • -
  • Final Draft (.fdx) — XML format from FD 8 onwards. Six native paragraph types map directly; bold / italic / underline runs preserve as marks; dual dialogue collapses to sequential pairs; revisions and locked numbers drop with a summary toast.
  • -
  • Both flows pick film-or-episode destination via a single File → Import Screenplay… wizard.
  • -
- -

Export

-
    -
  • PDF — Hollywood or Indian, with optional Title page, Synopsis, Treatment, Narrative, Scene Cards, Daily Shoot List sections
  • -
  • Fountain — plain text format compatible with Highland, Slugline, Beat, WriterDuet, and Fade In. Series projects can export one file per episode.
  • -
  • Plain text — formatted .txt with proper indentation
  • -
- -

Autosave & recovery

-

A hidden recovery file is updated as you type. Survives crashes and power loss.

-
- -
-
- - -
-
- v0.10.0 -

What's new.

-
- -
- -
- № 01 -
-

Final Draft (.fdx) import

-

Co-writers handing off Final Draft files now flow into Scriptty as a film or as a new episode of an open series. Six paragraph types map directly; bold / italic / underline runs preserve as inline marks; dual-dialogue blocks collapse to sequential pairs; revisions, locked numbers, script notes, and tagging drop with a summary toast that tells you exactly what was transformed or dropped.

-
-
- -
- № 02 -
-

Round-trip-safe Fountain import

-

The earlier release added Fountain import; this one closes the loop. Synopses absorb into scene-card descriptions, sections attach to the next scene's shoot notes with a depth marker, inline notes attach to the containing scene, and non-standard title-page keys round-trip via a hidden extra map. A co-writer can edit your Fountain in Scriptty and send it back without silent data loss.

-
-
- -
- № 03 -
-

One designed import surface

-

Both formats live behind a single File → Import Screenplay… wizard — pick the format and where it lands (new film or new episode of an active series). The standard Cmd+O Open dialog also accepts .fountain and .fdx directly.

-
-
- -
- № 04 -
-

Production planning, end to end

-

Scene cards carry a location group, shoot date, and extras. Daily Shoot List PDF groups scenes by day & location with industry-standard page-eighths. Statistics adds Schedule and Episodes views with sortable columns and CSV export.

-
-
- -
- № 05 -
-

Editorial-grade PDF redesign

-

Title page, prose covers, and scene-card / shoot-list openers share one masthead vocabulary. Per-section page numbering. Transition widow control. Courier Prime now bundled into every PDF.

-
-
- -
- № 06 -
-

Episode Breakout view

-

For series projects: a top-level card per episode with a scene preview list. Click an episode to drill into its scenes. IDE-style explorer with per-episode status, drag-to-reorder, hover-rename.

-
-
- -
- № 07 -
-

Paste to Screenplay

-

Drop plain text into the editor and Scriptty converts it into a properly formatted screenplay — Hollywood-style detection plus a Malayalam-aware character-cue path.

-
-
- -
- № 08 -
-

Autosave & crash recovery

-

Background autosave to a hidden recovery file. Survives crashes and power loss. A normal save clears it.

-
-
- -
-
- - -
-
- Behind Scriptty -

Credits & thanks.

-
- -
- -
-

Built by

- -
- -
-

Inputs & feedback

-
    -
  • - Aashiq Abu - Filmmaker -
  • -
  • - Sijith Vijayakumar -
  • -
-
- -
-

Contribute

-

Scriptty is open source under the MIT license. Bugs, feature ideas, and pull requests are welcome on GitHub.

-
- -
-
- -
- - - - - - - - + .section-head { + margin-bottom: 32px; + } + .section-head h2 { + font-size: 30px; + } + .section-head .lead { + font-size: 16px; + margin-top: 14px; + } + + /* Credits collapse to single column with tighter row spacing. */ + .credits-grid { + gap: 28px; + } + .credits-group h3 { + margin-bottom: 12px; + padding-bottom: 10px; + } + + /* Footer rows stack more compactly. */ + footer { + padding: 44px 0 32px; + } + .foot { + gap: 28px; + } + } + + + + + + +
+ +
+
+

Scriptty.

+

+ A free, offline screenwriting app for Malayalam & English writers. + Hollywood single-column & Indian two-column + formats, built-in Malayalam input, production-ready PDFs. +

+ +
+

+ Loading installers… +

+
+ +

+ v0.8.0 + Mac · Windows · Linux + MIT · Source on GitHub + All releases → +

+
+
+ + +
+
+

+ Malayalam screenwriters have always had to choose between two compromises — write in a + tool built for Latin scripts and fight every mixed-script line, or use a generic word + processor and lose the formatting that makes a screenplay readable on set. +

+

+ Scriptty is the third option. The editor speaks screenplay, not "rich text" — + every element has its own behaviour, its own keyboard shortcut, its own typography. + Malayalam input is built in, so you toggle scripts mid-sentence with + Ctrl+Space and never leave the page. +

+
+
+ + +
+
+ Why Scriptty +

Built for how screenwriters actually work.

+
+ +
+
+ № 01 · Language +

Malayalam & English, mixed freely

+

+ Three built-in input schemes — Mozhi (phonetic), + Inscript 1, and Inscript 2. No OS keyboard setup + needed. Mix Malayalam and English on the same line. +

+
+ +
+ № 02 · Format +

Hollywood or Indian two-column

+

+ Write in standard Hollywood single-column. Export as Indian two-column for production + teams who want dialogue and translation side by side. +

+
+ +
+ № 03 · Privacy +

Fully offline. Yours.

+

+ Zero network calls at runtime. Your screenplays live on your machine as + .screenplay files. No cloud, no account, no telemetry. +

+
+ +
+ № 04 · Three views +

One file, three lenses

+

+ Writing view with scene annotations in the margin. Cards view as a + typeset shooting-script tear sheet. Story view for Idea, Synopsis, Treatment, and + Narrative. +

+
+ +
+ № 05 · Web series +

Multi-episode, one file

+

+ An IDE-style episode explorer with per-episode status, drag-to-reorder, and an Episode + Breakout view that drills from a series overview into each episode's scenes. +

+
+ +
+ № 06 · Production +

Plan the shoot, not just the script

+

+ Tag each scene with a location group and a shoot date. Export a + Daily Shoot List PDF — scenes grouped by day & location with + industry-standard page-eighths totals. +

+
+ +
+ № 07 · PDF +

An editorial-grade printed page

+

+ Title page, prose covers, and scene cards share one masthead vocabulary. Per-section + page numbering, transition widow control, full-width production cards, Compact + card-view export. +

+
+ +
+ № 08 · Editor +

A smart, restrained editor

+

+ Auto-uppercase scene headings & characters. Malayalam-aware autocomplete. Smart + curly quotes. Adjustable editor font size. + Paste a plain-text draft and watch it become a properly formatted + screenplay. +

+
+ +
+ № 09 · Recovery +

Autosave that survives a crash

+

+ Background autosave to a hidden recovery file. If the app or your machine crashes, the + next launch offers to restore your unsaved work — even an unsaved new document + survives a power loss. +

+
+ +
+ № 10 · Interop +

Imports Fountain and Final Draft

+

+ Co-writers' .fountain and .fdx hand-offs flow in through one + Import Screenplay… wizard. Round-trip-aware Fountain export sends + them back without silent data loss. Per-episode Fountain export for series. +

+
+
+
+ + +
+
+ Quick reference +

A pocket guide.

+

+ Three columns covering the editor, the production prep, and the file/output side. Full + manual lives inside the app under Help. +

+
+ +
+
+

The editor

+ +

Element navigation

+

+ The editor cycles through six element types via Tab and Enter. +

+
    +
  • Scene Heading → Enter → Action
  • +
  • Action → Tab → Character
  • +
  • Character → Enter → Dialogue
  • +
  • Anywhere → Shift+Enter → new Scene Heading
  • +
  • Anywhere → Cmd+Shift+T → Transition
  • +
+ +

Malayalam input

+

+ Ctrl+Space toggles English ↔ Malayalam mid-sentence. Three schemes — + Mozhi, Inscript 2, Inscript 1. +

+ +

Smart features

+

+ Smart curly quotes. Character autocomplete after 2 characters (Malayalam-aware). + Auto-uppercase for scene headings and character cues. +

+
+ +
+

Production

+ +

Scene cards

+

+ Each card carries description, shoot notes, extras list, location group, and shoot + date. Cmd+Shift+K opens the Cards view. +

+ +

Daily Shoot List

+

+ From the Export modal, pick Daily Shoot List for a per-day production + report — scenes grouped by date & location with page-eighths totals. +

+ +

Statistics

+

+ Cmd+Shift+I opens five views — Overview, Characters, Locations, Schedule, + Episodes — with sortable columns and CSV export. +

+ +

Web series

+

+ Cmd+Shift+N creates a new series file. Each episode has its own metadata, + scene cards, and story sections. +

+
+ +
+

Files & output

+ +

The .screenplay format

+

Plain JSON. Human-readable. Open it in any text editor if you ever need to.

+ +

Import

+
    +
  • + Fountain (.fountain) — Highland, Slugline, Beat, + WriterDuet hand-offs. Round-trip aware: synopses, sections, inline notes, and + non-standard title-page keys re-emit on Fountain export. +
  • +
  • + Final Draft (.fdx) — XML format from FD 8 onwards. Six + native paragraph types map directly; bold / italic / underline runs preserve as + marks; dual dialogue collapses to sequential pairs; revisions and locked numbers + drop with a summary toast. +
  • +
  • + Both flows pick film-or-episode destination via a single + File → Import Screenplay… wizard. +
  • +
+ +

Export

+
    +
  • + PDF — Hollywood or Indian, with optional Title page, Synopsis, + Treatment, Narrative, Scene Cards, Daily Shoot List sections +
  • +
  • + Fountain — plain text format compatible with Highland, Slugline, + Beat, WriterDuet, and Fade In. Series projects can export one file per episode. +
  • +
  • Plain text — formatted .txt with proper indentation
  • +
+ +

Autosave & recovery

+

A hidden recovery file is updated as you type. Survives crashes and power loss.

+
+
+
+ + +
+
+ v0.10.0 +

What's new.

+
+ +
+
+ № 01 +
+

Final Draft (.fdx) import

+

+ Co-writers handing off Final Draft files now flow into Scriptty as a film or as a + new episode of an open series. Six paragraph types map directly; bold / italic / + underline runs preserve as inline marks; dual-dialogue blocks collapse to sequential + pairs; revisions, locked numbers, script notes, and tagging drop with a summary + toast that tells you exactly what was transformed or dropped. +

+
+
+ +
+ № 02 +
+

Round-trip-safe Fountain import

+

+ The earlier release added Fountain import; this one closes the loop. Synopses absorb + into scene-card descriptions, sections attach to the next scene's shoot notes with a + depth marker, inline notes attach to the containing scene, and non-standard + title-page keys round-trip via a hidden extra map. A co-writer can edit + your Fountain in Scriptty and send it back without silent data loss. +

+
+
+ +
+ № 03 +
+

One designed import surface

+

+ Both formats live behind a single File → Import Screenplay… wizard + — pick the format and where it lands (new film or new episode of an active series). + The standard Cmd+O Open dialog also accepts .fountain and + .fdx directly. +

+
+
+ +
+ № 04 +
+

Production planning, end to end

+

+ Scene cards carry a location group, shoot date, and extras. Daily Shoot List PDF + groups scenes by day & location with industry-standard page-eighths. Statistics + adds Schedule and Episodes views with sortable columns and CSV export. +

+
+
+ +
+ № 05 +
+

Editorial-grade PDF redesign

+

+ Title page, prose covers, and scene-card / shoot-list openers share one masthead + vocabulary. Per-section page numbering. Transition widow control. Courier Prime now + bundled into every PDF. +

+
+
+ +
+ № 06 +
+

Episode Breakout view

+

+ For series projects: a top-level card per episode with a scene preview list. Click + an episode to drill into its scenes. IDE-style explorer with per-episode status, + drag-to-reorder, hover-rename. +

+
+
+ +
+ № 07 +
+

Paste to Screenplay

+

+ Drop plain text into the editor and Scriptty converts it into a properly formatted + screenplay — Hollywood-style detection plus a Malayalam-aware character-cue path. +

+
+
+ +
+ № 08 +
+

Autosave & crash recovery

+

+ Background autosave to a hidden recovery file. Survives crashes and power loss. A + normal save clears it. +

+
+
+
+
+ + +
+
+ Behind Scriptty +

Credits & thanks.

+
+ +
+
+

Built by

+ +
+ +
+

Inputs & feedback

+
    +
  • + Aashiq Abu + Filmmaker +
  • +
  • + Sijith Vijayakumar +
  • +
+
+ +
+

Contribute

+

+ Scriptty is open source under the MIT license. Bugs, feature ideas, and pull requests + are welcome on GitHub. +

+
+
+
+
+ + + + + + + diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 795b9b7..d860e1e 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,3 @@ fn main() { - tauri_build::build() + tauri_build::build() } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index d3fdaa6..80aef9e 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -1,32 +1,30 @@ { - "$schema": "../gen/schemas/desktop-schema.json", - "identifier": "default", - "description": "enables the default permissions", - "windows": [ - "main" - ], - "permissions": [ - "core:default", - "core:window:allow-close", - "core:window:allow-destroy", - "dialog:allow-save", - "dialog:allow-open", - "dialog:allow-message", - "dialog:allow-confirm", - "fs:allow-write-file", - "fs:allow-write-text-file", - { - "identifier": "opener:allow-open-url", - "allow": [ - { "url": "https://stultus.in" }, - { "url": "https://stultus.in/*" }, - { "url": "https://hiran.in" }, - { "url": "https://hiran.in/*" }, - { "url": "https://github.com/stultus/scriptty" }, - { "url": "https://github.com/stultus/scriptty/*" }, - { "url": "mailto:*" } - ] - }, - "deep-link:default" - ] + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "enables the default permissions", + "windows": ["main"], + "permissions": [ + "core:default", + "core:window:allow-close", + "core:window:allow-destroy", + "dialog:allow-save", + "dialog:allow-open", + "dialog:allow-message", + "dialog:allow-confirm", + "fs:allow-write-file", + "fs:allow-write-text-file", + { + "identifier": "opener:allow-open-url", + "allow": [ + { "url": "https://stultus.in" }, + { "url": "https://stultus.in/*" }, + { "url": "https://hiran.in" }, + { "url": "https://hiran.in/*" }, + { "url": "https://github.com/stultus/scriptty" }, + { "url": "https://github.com/stultus/scriptty/*" }, + { "url": "mailto:*" } + ] + }, + "deep-link:default" + ] } diff --git a/src-tauri/src/commands/export.rs b/src-tauri/src/commands/export.rs index f71c01f..055e08d 100644 --- a/src-tauri/src/commands/export.rs +++ b/src-tauri/src/commands/export.rs @@ -16,8 +16,8 @@ use crate::fonts; use crate::screenplay::document::ScreenplayDocument; use crate::screenplay::fountain; -use crate::screenplay::plaintext; use crate::screenplay::pdf; +use crate::screenplay::plaintext; use serde::Deserialize; // `Emitter` is a Tauri trait that adds the `.emit(event, payload)` method // onto AppHandle — it lets Rust push a named event to the frontend webview @@ -100,7 +100,16 @@ pub fn export_typst_markup( // We don't need to take ownership — we just need to read the JSON. // `&document.meta` passes a reference to the metadata so the markup generator // can include a title page if the screenplay has a title set. - Ok(pdf::generate_typst_markup(&document.content, font_name, &document.meta, false, document.settings.scene_number_start, false, &document.scene_cards, false)) + Ok(pdf::generate_typst_markup( + &document.content, + font_name, + &document.meta, + false, + document.settings.scene_number_start, + false, + &document.scene_cards, + false, + )) } /// Exports a screenplay document as PDF bytes. @@ -118,10 +127,7 @@ pub fn export_typst_markup( /// * `Ok(Vec)` — The raw PDF file bytes ready to write to disk. /// * `Err(String)` — An error message if PDF generation fails. #[tauri::command] -pub fn export_pdf( - document: ScreenplayDocument, - app: tauri::AppHandle, -) -> Result, String> { +pub fn export_pdf(document: ScreenplayDocument, app: tauri::AppHandle) -> Result, String> { // `bundled_fonts()` returns a Vec — all fonts compiled into the binary. let bundled = fonts::bundled_fonts(); @@ -312,9 +318,27 @@ pub fn export_combined_pdf( }; markup = if options.format == "indian" { - pdf::generate_indian_markup(&document.content, font_name, &meta_for_export, options.page_break_after_scene, document.settings.scene_number_start, options.characters_below_heading, &document.scene_cards, options.include_page_numbers) + pdf::generate_indian_markup( + &document.content, + font_name, + &meta_for_export, + options.page_break_after_scene, + document.settings.scene_number_start, + options.characters_below_heading, + &document.scene_cards, + options.include_page_numbers, + ) } else { - pdf::generate_typst_markup(&document.content, font_name, &meta_for_export, options.page_break_after_scene, document.settings.scene_number_start, options.characters_below_heading, &document.scene_cards, options.include_page_numbers) + pdf::generate_typst_markup( + &document.content, + font_name, + &meta_for_export, + options.page_break_after_scene, + document.settings.scene_number_start, + options.characters_below_heading, + &document.scene_cards, + options.include_page_numbers, + ) }; has_content = true; } else { @@ -337,7 +361,10 @@ pub fn export_combined_pdf( // If title page is requested without screenplay, generate a standalone title page if options.include_title_page && !document.meta.title.is_empty() { - markup.push_str(&pdf::generate_title_page_markup(&document.meta, options.include_page_numbers)); + markup.push_str(&pdf::generate_title_page_markup( + &document.meta, + options.include_page_numbers, + )); has_content = true; } } @@ -426,7 +453,10 @@ pub fn export_combined_pdf( /// * `Err(String)` — An error message if conversion fails. #[tauri::command] pub fn export_plaintext(document: ScreenplayDocument) -> Result { - Ok(plaintext::generate_plaintext(&document.content, &document.meta)) + Ok(plaintext::generate_plaintext( + &document.content, + &document.meta, + )) } /// Exports a screenplay document as a Fountain plain-text string. diff --git a/src-tauri/src/commands/file.rs b/src-tauri/src/commands/file.rs index 3e530ed..a4daf0e 100644 --- a/src-tauri/src/commands/file.rs +++ b/src-tauri/src/commands/file.rs @@ -24,10 +24,10 @@ pub fn new_screenplay() -> ScreenplayDocument { { "type": "scene_heading" } ] }), - meta: Default::default(), // Uses the Default impl we defined + meta: Default::default(), // Uses the Default impl we defined settings: Default::default(), - story: Default::default(), // Empty story sections - scene_cards: Vec::new(), // No scene cards initially + story: Default::default(), // Empty story sections + scene_cards: Vec::new(), // No scene cards initially } } @@ -45,8 +45,7 @@ pub fn save_screenplay(path: String, document: ScreenplayDocument) -> Result<(), let json = serde_json::to_string_pretty(&document) .map_err(|e| format!("Failed to serialize document: {}", e))?; - std::fs::write(&path, json) - .map_err(|e| format!("Failed to write file '{}': {}", path, e))?; + std::fs::write(&path, json).map_err(|e| format!("Failed to write file '{}': {}", path, e))?; Ok(()) } @@ -166,8 +165,8 @@ pub fn load_autosave(path: String) -> Result, String> { // If the original is newer (or equal), the autosave is stale — discard // it and report "nothing to recover" so the user isn't prompted to // restore something older than the file they just opened. - let original_meta = std::fs::metadata(&path) - .map_err(|e| format!("Failed to stat '{}': {}", path, e))?; + let original_meta = + std::fs::metadata(&path).map_err(|e| format!("Failed to stat '{}': {}", path, e))?; let autosave_time = autosave_meta .modified() diff --git a/src-tauri/src/commands/import.rs b/src-tauri/src/commands/import.rs index de65c11..17db83c 100644 --- a/src-tauri/src/commands/import.rs +++ b/src-tauri/src/commands/import.rs @@ -138,7 +138,9 @@ pub fn import_fdx_as_episode( mut current_document: ScreenplayDocument, ) -> Result { if current_document.project_type != ProjectType::Series { - return Err("Cannot import a Final Draft file as an episode unless a Series is open.".into()); + return Err( + "Cannot import a Final Draft file as an episode unless a Series is open.".into(), + ); } let text = std::fs::read_to_string(&path) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 52749d7..6fea51b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10,266 +10,438 @@ use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu}; // Emitter trait lets us send events from Rust to the frontend webview. use tauri::Emitter; - #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - tauri::Builder::default() - // Register the dialog plugin so the frontend can open native file dialogs. - .plugin(tauri_plugin_dialog::init()) - // Register the FS plugin so the frontend can write files (e.g. PDF export). - .plugin(tauri_plugin_fs::init()) - // Register the opener plugin so we can open URLs in the default browser. - .plugin(tauri_plugin_opener::init()) - // Register the deep-link plugin for file association handling. - // This buffers file URLs on cold launch so the frontend can retrieve - // them after mounting via getCurrent(). - .plugin(tauri_plugin_deep_link::init()) - .setup(|app| { - if cfg!(debug_assertions) { - app.handle().plugin( - tauri_plugin_log::Builder::default() - .level(log::LevelFilter::Info) - .build(), - )?; - } + tauri::Builder::default() + // Register the dialog plugin so the frontend can open native file dialogs. + .plugin(tauri_plugin_dialog::init()) + // Register the FS plugin so the frontend can write files (e.g. PDF export). + .plugin(tauri_plugin_fs::init()) + // Register the opener plugin so we can open URLs in the default browser. + .plugin(tauri_plugin_opener::init()) + // Register the deep-link plugin for file association handling. + // This buffers file URLs on cold launch so the frontend can retrieve + // them after mounting via getCurrent(). + .plugin(tauri_plugin_deep_link::init()) + .setup(|app| { + if cfg!(debug_assertions) { + app.handle().plugin( + tauri_plugin_log::Builder::default() + .level(log::LevelFilter::Info) + .build(), + )?; + } - // --- Native App Menu --- - // Build a standard File + Edit + Help menu bar. Custom items (New, Open, Save, - // Save As, About, etc.) emit events to the frontend. Predefined items (Undo, Redo, - // Cut, Copy, Paste, Select All, Quit) are handled automatically by the OS. + // --- Native App Menu --- + // Build a standard File + Edit + Help menu bar. Custom items (New, Open, Save, + // Save As, About, etc.) emit events to the frontend. Predefined items (Undo, Redo, + // Cut, Copy, Paste, Select All, Quit) are handled automatically by the OS. - // File menu with custom items for document operations (#166). - // Order: file ops · save group · export · doc props · close · quit. - let file_menu = Submenu::with_items( - app, - "File", - true, // enabled - &[ - // MenuItem::with_id creates a menu item with a custom ID we can match on later. - // Args: app handle, id, display text, enabled, optional keyboard accelerator. - &MenuItem::with_id(app, "new-film", "New Film", true, Some("CmdOrCtrl+N"))?, - &MenuItem::with_id(app, "new-series", "New Series", true, Some("CmdOrCtrl+Shift+N"))?, - &MenuItem::with_id(app, "open", "Open...", true, Some("CmdOrCtrl+O"))?, - &PredefinedMenuItem::separator(app)?, - // Opens the in-app import wizard (format + destination picker). - &MenuItem::with_id(app, "import-screenplay", "Import Screenplay...", true, None::<&str>)?, - &PredefinedMenuItem::separator(app)?, - &MenuItem::with_id(app, "save", "Save", true, Some("CmdOrCtrl+S"))?, - &MenuItem::with_id(app, "save-as", "Save As...", true, Some("CmdOrCtrl+Shift+S"))?, - &PredefinedMenuItem::separator(app)?, - // Export — discoverability fix (#166). macOS users reach for - // File → Export by reflex; the title-bar button alone wasn't - // enough. - &MenuItem::with_id(app, "export", "Export...", true, Some("CmdOrCtrl+Shift+E"))?, - &PredefinedMenuItem::separator(app)?, - // Renamed from "Metadata..." — "Document Properties..." is - // the macOS-native phrasing every word-processor uses. - &MenuItem::with_id(app, "edit-meta", "Document Properties...", true, None::<&str>)?, - &PredefinedMenuItem::separator(app)?, - // Close Window — macOS convention is ⌘W. Routes through the - // frontend's dirty-state guard before actually closing. - &MenuItem::with_id(app, "close-window", "Close Window", true, Some("CmdOrCtrl+W"))?, - &PredefinedMenuItem::separator(app)?, - // Custom quit item instead of PredefinedMenuItem::quit so the frontend - // can intercept it and prompt for unsaved changes before quitting. - &MenuItem::with_id(app, "quit", "Quit Scriptty", true, Some("CmdOrCtrl+Q"))?, - ], - )?; + // File menu with custom items for document operations (#166). + // Order: file ops · save group · export · doc props · close · quit. + let file_menu = Submenu::with_items( + app, + "File", + true, // enabled + &[ + // MenuItem::with_id creates a menu item with a custom ID we can match on later. + // Args: app handle, id, display text, enabled, optional keyboard accelerator. + &MenuItem::with_id(app, "new-film", "New Film", true, Some("CmdOrCtrl+N"))?, + &MenuItem::with_id( + app, + "new-series", + "New Series", + true, + Some("CmdOrCtrl+Shift+N"), + )?, + &MenuItem::with_id(app, "open", "Open...", true, Some("CmdOrCtrl+O"))?, + &PredefinedMenuItem::separator(app)?, + // Opens the in-app import wizard (format + destination picker). + &MenuItem::with_id( + app, + "import-screenplay", + "Import Screenplay...", + true, + None::<&str>, + )?, + &PredefinedMenuItem::separator(app)?, + &MenuItem::with_id(app, "save", "Save", true, Some("CmdOrCtrl+S"))?, + &MenuItem::with_id( + app, + "save-as", + "Save As...", + true, + Some("CmdOrCtrl+Shift+S"), + )?, + &PredefinedMenuItem::separator(app)?, + // Export — discoverability fix (#166). macOS users reach for + // File → Export by reflex; the title-bar button alone wasn't + // enough. + &MenuItem::with_id( + app, + "export", + "Export...", + true, + Some("CmdOrCtrl+Shift+E"), + )?, + &PredefinedMenuItem::separator(app)?, + // Renamed from "Metadata..." — "Document Properties..." is + // the macOS-native phrasing every word-processor uses. + &MenuItem::with_id( + app, + "edit-meta", + "Document Properties...", + true, + None::<&str>, + )?, + &PredefinedMenuItem::separator(app)?, + // Close Window — macOS convention is ⌘W. Routes through the + // frontend's dirty-state guard before actually closing. + &MenuItem::with_id( + app, + "close-window", + "Close Window", + true, + Some("CmdOrCtrl+W"), + )?, + &PredefinedMenuItem::separator(app)?, + // Custom quit item instead of PredefinedMenuItem::quit so the frontend + // can intercept it and prompt for unsaved changes before quitting. + &MenuItem::with_id(app, "quit", "Quit Scriptty", true, Some("CmdOrCtrl+Q"))?, + ], + )?; - // Edit menu with standard OS-handled items (no custom event handling needed) - let edit_menu = Submenu::with_items( - app, - "Edit", - true, - &[ - &PredefinedMenuItem::undo(app, None)?, - &PredefinedMenuItem::redo(app, None)?, - &PredefinedMenuItem::separator(app)?, - &PredefinedMenuItem::cut(app, None)?, - &PredefinedMenuItem::copy(app, None)?, - &PredefinedMenuItem::paste(app, None)?, - &PredefinedMenuItem::select_all(app, None)?, - &PredefinedMenuItem::separator(app)?, - &MenuItem::with_id(app, "find", "Find", true, Some("CmdOrCtrl+F"))?, - &MenuItem::with_id(app, "find-replace", "Find and Replace", true, Some("CmdOrCtrl+Shift+H"))?, - ], - )?; + // Edit menu with standard OS-handled items (no custom event handling needed) + let edit_menu = Submenu::with_items( + app, + "Edit", + true, + &[ + &PredefinedMenuItem::undo(app, None)?, + &PredefinedMenuItem::redo(app, None)?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::cut(app, None)?, + &PredefinedMenuItem::copy(app, None)?, + &PredefinedMenuItem::paste(app, None)?, + &PredefinedMenuItem::select_all(app, None)?, + &PredefinedMenuItem::separator(app)?, + &MenuItem::with_id(app, "find", "Find", true, Some("CmdOrCtrl+F"))?, + &MenuItem::with_id( + app, + "find-replace", + "Find and Replace", + true, + Some("CmdOrCtrl+Shift+H"), + )?, + ], + )?; - // Element Type submenu (#167) — exposes the screenplay vocabulary - // that was previously only reachable via Tab/Enter. Each item - // emits a frontend event that converts the current paragraph to - // the chosen element type via a ProseMirror command. - let element_type_menu = Submenu::with_items( - app, - "Element Type", - true, - &[ - &MenuItem::with_id(app, "elem-scene-heading", "Scene Heading", true, Some("CmdOrCtrl+Shift+H"))?, - &MenuItem::with_id(app, "elem-action", "Action", true, Some("CmdOrCtrl+Alt+A"))?, - &MenuItem::with_id(app, "elem-character", "Character", true, Some("CmdOrCtrl+Alt+C"))?, - &MenuItem::with_id(app, "elem-parenthetical", "Parenthetical", true, Some("CmdOrCtrl+Alt+P"))?, - &MenuItem::with_id(app, "elem-dialogue", "Dialogue", true, Some("CmdOrCtrl+Alt+D"))?, - &MenuItem::with_id(app, "elem-transition", "Transition", true, Some("CmdOrCtrl+Shift+T"))?, - ], - )?; + // Element Type submenu (#167) — exposes the screenplay vocabulary + // that was previously only reachable via Tab/Enter. Each item + // emits a frontend event that converts the current paragraph to + // the chosen element type via a ProseMirror command. + let element_type_menu = Submenu::with_items( + app, + "Element Type", + true, + &[ + &MenuItem::with_id( + app, + "elem-scene-heading", + "Scene Heading", + true, + Some("CmdOrCtrl+Shift+H"), + )?, + &MenuItem::with_id( + app, + "elem-action", + "Action", + true, + Some("CmdOrCtrl+Alt+A"), + )?, + &MenuItem::with_id( + app, + "elem-character", + "Character", + true, + Some("CmdOrCtrl+Alt+C"), + )?, + &MenuItem::with_id( + app, + "elem-parenthetical", + "Parenthetical", + true, + Some("CmdOrCtrl+Alt+P"), + )?, + &MenuItem::with_id( + app, + "elem-dialogue", + "Dialogue", + true, + Some("CmdOrCtrl+Alt+D"), + )?, + &MenuItem::with_id( + app, + "elem-transition", + "Transition", + true, + Some("CmdOrCtrl+Shift+T"), + )?, + ], + )?; - // Format menu — text marks + element-type vocabulary submenu. - let format_menu = Submenu::with_items( - app, - "Format", - true, - &[ - &MenuItem::with_id(app, "bold", "Bold", true, Some("CmdOrCtrl+B"))?, - &MenuItem::with_id(app, "italic", "Italic", true, Some("CmdOrCtrl+I"))?, - &MenuItem::with_id(app, "underline", "Underline", true, Some("CmdOrCtrl+U"))?, - &PredefinedMenuItem::separator(app)?, - &element_type_menu, - ], - )?; + // Format menu — text marks + element-type vocabulary submenu. + let format_menu = Submenu::with_items( + app, + "Format", + true, + &[ + &MenuItem::with_id(app, "bold", "Bold", true, Some("CmdOrCtrl+B"))?, + &MenuItem::with_id(app, "italic", "Italic", true, Some("CmdOrCtrl+I"))?, + &MenuItem::with_id(app, "underline", "Underline", true, Some("CmdOrCtrl+U"))?, + &PredefinedMenuItem::separator(app)?, + &element_type_menu, + ], + )?; - // Theme submenu (#168) — Light / Dark / System. Selection routes - // through themeStore. The active theme isn't shown with a check - // mark here because Tauri's CheckMenuItem has to be rebuilt to - // change state; for now the Settings popover is the canonical - // visual indicator. - let theme_menu = Submenu::with_items( - app, - "Theme", - true, - &[ - &MenuItem::with_id(app, "theme-light", "Light", true, None::<&str>)?, - &MenuItem::with_id(app, "theme-dark", "Dark", true, None::<&str>)?, - &PredefinedMenuItem::separator(app)?, - &MenuItem::with_id(app, "theme-system", "Match System", true, None::<&str>)?, - ], - )?; + // Theme submenu (#168) — Light / Dark / System. Selection routes + // through themeStore. The active theme isn't shown with a check + // mark here because Tauri's CheckMenuItem has to be rebuilt to + // change state; for now the Settings popover is the canonical + // visual indicator. + let theme_menu = Submenu::with_items( + app, + "Theme", + true, + &[ + &MenuItem::with_id(app, "theme-light", "Light", true, None::<&str>)?, + &MenuItem::with_id(app, "theme-dark", "Dark", true, None::<&str>)?, + &PredefinedMenuItem::separator(app)?, + &MenuItem::with_id(app, "theme-system", "Match System", true, None::<&str>)?, + ], + )?; - // View menu (#168) — view switchers get ⌘1/⌘2/⌘3 number - // shortcuts so the keyboard path matches macOS reading apps. - let view_menu = Submenu::with_items( - app, - "View", - true, - &[ - &MenuItem::with_id(app, "view-writing", "Writing", true, Some("CmdOrCtrl+1"))?, - &MenuItem::with_id(app, "scene-cards", "Cards", true, Some("CmdOrCtrl+2"))?, - &MenuItem::with_id(app, "story-mode", "Story", true, Some("CmdOrCtrl+3"))?, - &PredefinedMenuItem::separator(app)?, - &MenuItem::with_id(app, "statistics", "Statistics", true, Some("CmdOrCtrl+Shift+I"))?, - &MenuItem::with_id(app, "toggle-sidebar", "Toggle Sidebar", true, Some("Ctrl+CmdOrCtrl+B"))?, - &PredefinedMenuItem::separator(app)?, - &theme_menu, - ], - )?; + // View menu (#168) — view switchers get ⌘1/⌘2/⌘3 number + // shortcuts so the keyboard path matches macOS reading apps. + let view_menu = Submenu::with_items( + app, + "View", + true, + &[ + &MenuItem::with_id(app, "view-writing", "Writing", true, Some("CmdOrCtrl+1"))?, + &MenuItem::with_id(app, "scene-cards", "Cards", true, Some("CmdOrCtrl+2"))?, + &MenuItem::with_id(app, "story-mode", "Story", true, Some("CmdOrCtrl+3"))?, + &PredefinedMenuItem::separator(app)?, + &MenuItem::with_id( + app, + "statistics", + "Statistics", + true, + Some("CmdOrCtrl+Shift+I"), + )?, + &MenuItem::with_id( + app, + "toggle-sidebar", + "Toggle Sidebar", + true, + Some("Ctrl+CmdOrCtrl+B"), + )?, + &PredefinedMenuItem::separator(app)?, + &theme_menu, + ], + )?; - // Help menu with About dialog and external links - let help_menu = Submenu::with_items( - app, - "Help", - true, - &[ - &MenuItem::with_id(app, "about", "About Scriptty", true, None::<&str>)?, - &MenuItem::with_id(app, "help-guide", "How to Use Scriptty", true, None::<&str>)?, - &MenuItem::with_id(app, "check-updates", "Check for Updates…", true, None::<&str>)?, - &PredefinedMenuItem::separator(app)?, - &MenuItem::with_id(app, "report-issue", "Report an Issue", true, None::<&str>)?, - &MenuItem::with_id(app, "view-github", "View on GitHub", true, None::<&str>)?, - ], - )?; + // Help menu with About dialog and external links + let help_menu = Submenu::with_items( + app, + "Help", + true, + &[ + &MenuItem::with_id(app, "about", "About Scriptty", true, None::<&str>)?, + &MenuItem::with_id( + app, + "help-guide", + "How to Use Scriptty", + true, + None::<&str>, + )?, + &MenuItem::with_id( + app, + "check-updates", + "Check for Updates…", + true, + None::<&str>, + )?, + &PredefinedMenuItem::separator(app)?, + &MenuItem::with_id(app, "report-issue", "Report an Issue", true, None::<&str>)?, + &MenuItem::with_id(app, "view-github", "View on GitHub", true, None::<&str>)?, + ], + )?; - // Assemble the menu bar from the submenus and apply it to the app - let menu = Menu::with_items(app, &[&file_menu, &edit_menu, &format_menu, &view_menu, &help_menu])?; - app.set_menu(menu)?; + // Assemble the menu bar from the submenus and apply it to the app + let menu = Menu::with_items( + app, + &[&file_menu, &edit_menu, &format_menu, &view_menu, &help_menu], + )?; + app.set_menu(menu)?; - // Handle clicks on our custom menu items by emitting events to the frontend. - // The `move` keyword transfers ownership of captured variables into the closure — - // needed because this closure outlives the setup function. - // `event.id().as_ref()` gives us the string ID we set in MenuItem::with_id above. - app.on_menu_event(move |app, event| { - // Match the menu item's ID string and emit the corresponding event. - // `let _ = ...` discards the Result — if emit fails, we silently ignore it - // (there's no meaningful recovery for a failed emit). - match event.id().as_ref() { - "new-film" => { let _ = app.emit("menu-new-film", ()); } - "new-series" => { let _ = app.emit("menu-new-series", ()); } - "open" => { let _ = app.emit("menu-open", ()); } - "import-screenplay" => { let _ = app.emit("menu-import-screenplay", ()); } - "save" => { let _ = app.emit("menu-save", ()); } - "save-as" => { let _ = app.emit("menu-save-as", ()); } - "about" => { let _ = app.emit("menu-about", ()); } - "help-guide" => { let _ = app.emit("menu-help-guide", ()); } - "check-updates" => { let _ = app.emit("menu-check-updates", ()); } - "statistics" => { let _ = app.emit("menu-statistics", ()); } - "scene-cards" => { let _ = app.emit("menu-scene-cards", ()); } - "story-mode" => { let _ = app.emit("menu-story-mode", ()); } - "view-writing" => { let _ = app.emit("menu-view-writing", ()); } - "toggle-sidebar" => { let _ = app.emit("menu-toggle-sidebar", ()); } - "edit-meta" => { let _ = app.emit("menu-edit-meta", ()); } - "export" => { let _ = app.emit("menu-export", ()); } - "close-window" => { let _ = app.emit("menu-close-window", ()); } - "bold" => { let _ = app.emit("menu-bold", ()); } - "italic" => { let _ = app.emit("menu-italic", ()); } - "underline" => { let _ = app.emit("menu-underline", ()); } - // Element type submenu (#167) — each item emits a single - // event with the element name as payload, frontend dispatches - // the corresponding ProseMirror command. - "elem-scene-heading" => { let _ = app.emit("menu-element-type", "scene_heading"); } - "elem-action" => { let _ = app.emit("menu-element-type", "action"); } - "elem-character" => { let _ = app.emit("menu-element-type", "character"); } - "elem-parenthetical" => { let _ = app.emit("menu-element-type", "parenthetical"); } - "elem-dialogue" => { let _ = app.emit("menu-element-type", "dialogue"); } - "elem-transition" => { let _ = app.emit("menu-element-type", "transition"); } - // Theme submenu (#168) - "theme-light" => { let _ = app.emit("menu-theme", "light"); } - "theme-dark" => { let _ = app.emit("menu-theme", "dark"); } - "theme-system" => { let _ = app.emit("menu-theme", "system"); } - "find" => { let _ = app.emit("menu-find", ()); } - "find-replace" => { let _ = app.emit("menu-find-replace", ()); } - "quit" => { let _ = app.emit("menu-quit", ()); } - // External links — open in the default browser using the opener plugin. - // `tauri_plugin_opener::OpenerExt` provides the `.opener()` method on AppHandle. - "report-issue" => { - use tauri_plugin_opener::OpenerExt; - let _ = app.opener().open_url("https://github.com/stultus/scriptty/issues", None::<&str>); - } - "view-github" => { - use tauri_plugin_opener::OpenerExt; - let _ = app.opener().open_url("https://github.com/stultus/scriptty", None::<&str>); - } - _ => {} // Ignore predefined items — the OS handles those - } - }); + // Handle clicks on our custom menu items by emitting events to the frontend. + // The `move` keyword transfers ownership of captured variables into the closure — + // needed because this closure outlives the setup function. + // `event.id().as_ref()` gives us the string ID we set in MenuItem::with_id above. + app.on_menu_event(move |app, event| { + // Match the menu item's ID string and emit the corresponding event. + // `let _ = ...` discards the Result — if emit fails, we silently ignore it + // (there's no meaningful recovery for a failed emit). + match event.id().as_ref() { + "new-film" => { + let _ = app.emit("menu-new-film", ()); + } + "new-series" => { + let _ = app.emit("menu-new-series", ()); + } + "open" => { + let _ = app.emit("menu-open", ()); + } + "import-screenplay" => { + let _ = app.emit("menu-import-screenplay", ()); + } + "save" => { + let _ = app.emit("menu-save", ()); + } + "save-as" => { + let _ = app.emit("menu-save-as", ()); + } + "about" => { + let _ = app.emit("menu-about", ()); + } + "help-guide" => { + let _ = app.emit("menu-help-guide", ()); + } + "check-updates" => { + let _ = app.emit("menu-check-updates", ()); + } + "statistics" => { + let _ = app.emit("menu-statistics", ()); + } + "scene-cards" => { + let _ = app.emit("menu-scene-cards", ()); + } + "story-mode" => { + let _ = app.emit("menu-story-mode", ()); + } + "view-writing" => { + let _ = app.emit("menu-view-writing", ()); + } + "toggle-sidebar" => { + let _ = app.emit("menu-toggle-sidebar", ()); + } + "edit-meta" => { + let _ = app.emit("menu-edit-meta", ()); + } + "export" => { + let _ = app.emit("menu-export", ()); + } + "close-window" => { + let _ = app.emit("menu-close-window", ()); + } + "bold" => { + let _ = app.emit("menu-bold", ()); + } + "italic" => { + let _ = app.emit("menu-italic", ()); + } + "underline" => { + let _ = app.emit("menu-underline", ()); + } + // Element type submenu (#167) — each item emits a single + // event with the element name as payload, frontend dispatches + // the corresponding ProseMirror command. + "elem-scene-heading" => { + let _ = app.emit("menu-element-type", "scene_heading"); + } + "elem-action" => { + let _ = app.emit("menu-element-type", "action"); + } + "elem-character" => { + let _ = app.emit("menu-element-type", "character"); + } + "elem-parenthetical" => { + let _ = app.emit("menu-element-type", "parenthetical"); + } + "elem-dialogue" => { + let _ = app.emit("menu-element-type", "dialogue"); + } + "elem-transition" => { + let _ = app.emit("menu-element-type", "transition"); + } + // Theme submenu (#168) + "theme-light" => { + let _ = app.emit("menu-theme", "light"); + } + "theme-dark" => { + let _ = app.emit("menu-theme", "dark"); + } + "theme-system" => { + let _ = app.emit("menu-theme", "system"); + } + "find" => { + let _ = app.emit("menu-find", ()); + } + "find-replace" => { + let _ = app.emit("menu-find-replace", ()); + } + "quit" => { + let _ = app.emit("menu-quit", ()); + } + // External links — open in the default browser using the opener plugin. + // `tauri_plugin_opener::OpenerExt` provides the `.opener()` method on AppHandle. + "report-issue" => { + use tauri_plugin_opener::OpenerExt; + let _ = app + .opener() + .open_url("https://github.com/stultus/scriptty/issues", None::<&str>); + } + "view-github" => { + use tauri_plugin_opener::OpenerExt; + let _ = app + .opener() + .open_url("https://github.com/stultus/scriptty", None::<&str>); + } + _ => {} // Ignore predefined items — the OS handles those + } + }); - // Check if the app was launched by double-clicking a .screenplay file. - // The OS passes the file path as a command-line argument. - // We store it in Tauri's managed state so the frontend can retrieve it - // after mounting — emitting an event here would be too early (the - // frontend's `listen()` hasn't been registered yet). - Ok(()) - }) - .invoke_handler(tauri::generate_handler![ - commands::file::new_screenplay, - commands::file::save_screenplay, - commands::file::open_screenplay, - commands::export::export_typst_markup, - commands::export::export_pdf, - commands::export::export_pdf_indian, - commands::export::export_combined_pdf, - commands::export::export_plaintext, - commands::export::export_fountain, - commands::file::open_external_url, - commands::file::autosave_screenplay, - commands::file::discard_autosave, - commands::file::load_autosave, - commands::import::import_fountain_as_film, - commands::import::import_fountain_as_episode, - commands::import::import_fdx_as_film, - commands::import::import_fdx_as_episode, - ]) - .build(tauri::generate_context!()) - .expect("error while building tauri application") - .run(|_app, _event| { - // The deep-link plugin handles both cold launch and warm launch - // file open events automatically. - }); + // Check if the app was launched by double-clicking a .screenplay file. + // The OS passes the file path as a command-line argument. + // We store it in Tauri's managed state so the frontend can retrieve it + // after mounting — emitting an event here would be too early (the + // frontend's `listen()` hasn't been registered yet). + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + commands::file::new_screenplay, + commands::file::save_screenplay, + commands::file::open_screenplay, + commands::export::export_typst_markup, + commands::export::export_pdf, + commands::export::export_pdf_indian, + commands::export::export_combined_pdf, + commands::export::export_plaintext, + commands::export::export_fountain, + commands::file::open_external_url, + commands::file::autosave_screenplay, + commands::file::discard_autosave, + commands::file::load_autosave, + commands::import::import_fountain_as_film, + commands::import::import_fountain_as_episode, + commands::import::import_fdx_as_film, + commands::import::import_fdx_as_episode, + ]) + .build(tauri::generate_context!()) + .expect("error while building tauri application") + .run(|_app, _event| { + // The deep-link plugin handles both cold launch and warm launch + // file open events automatically. + }); } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index ff65298..2d7dc14 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2,5 +2,5 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { - scriptty_lib::run(); + scriptty_lib::run(); } diff --git a/src-tauri/src/screenplay/document.rs b/src-tauri/src/screenplay/document.rs index f045d6d..7800592 100644 --- a/src-tauri/src/screenplay/document.rs +++ b/src-tauri/src/screenplay/document.rs @@ -127,7 +127,10 @@ pub struct ScreenplaySettings { /// A custom `deserialize_with` clamps the stored value to 1..=9999 so a /// hand-edited 0, negative cast, or absurdly large value can't make the /// PDF pipeline's `saturating_sub` silently drop scene character lists. - #[serde(default = "default_scene_number_start", deserialize_with = "deserialize_scene_number_start")] + #[serde( + default = "default_scene_number_start", + deserialize_with = "deserialize_scene_number_start" + )] pub scene_number_start: u32, /// When true, the editor shows an auto-generated "characters: …" line /// below each scene heading listing every character who speaks in that @@ -141,7 +144,10 @@ pub struct ScreenplaySettings { /// (oversize text would push the gutter / annotations off-screen). /// Defaults to 14 — the historical baseline before this setting /// existed (#123). - #[serde(default = "default_editor_font_size", deserialize_with = "deserialize_editor_font_size")] + #[serde( + default = "default_editor_font_size", + deserialize_with = "deserialize_editor_font_size" + )] pub editor_font_size: u32, } diff --git a/src-tauri/src/screenplay/fdx_import.rs b/src-tauri/src/screenplay/fdx_import.rs index 5812b3f..89024b5 100644 --- a/src-tauri/src/screenplay/fdx_import.rs +++ b/src-tauri/src/screenplay/fdx_import.rs @@ -80,10 +80,13 @@ pub fn parse_fdx(input: &str) -> Result<(ScreenplayDocument, FdxImportSummary), let mut buf: Vec = Vec::new(); loop { - match reader - .read_event_into(&mut buf) - .map_err(|e| format!("XML parse error at byte {}: {}", reader.buffer_position(), e))? - { + match reader.read_event_into(&mut buf).map_err(|e| { + format!( + "XML parse error at byte {}: {}", + reader.buffer_position(), + e + ) + })? { Event::Start(e) => handle_start(&e, &mut state, &reader)?, Event::Empty(e) => { // Self-closing: handle as Start+End back-to-back. Empty @@ -545,10 +548,7 @@ fn map_type(fdx_type: &str, summary: &mut FdxImportSummary) -> &'static str { /// produced if the user typed them locally. fn build_inline_children(runs: &[TextRun], node_kind: &str) -> Vec { let mut out: Vec = Vec::new(); - let upper_kind = matches!( - node_kind, - "scene_heading" | "character" | "transition" - ); + let upper_kind = matches!(node_kind, "scene_heading" | "character" | "transition"); for run in runs { if run.text.is_empty() { continue; @@ -605,8 +605,7 @@ fn build_meta( .collect::>() .join("\n"); if !full_text.trim().is_empty() { - meta.extra - .insert("fdx_title_page".into(), full_text); + meta.extra.insert("fdx_title_page".into(), full_text); } // Beat-style heuristic for the standard fields. This is a best- @@ -641,7 +640,8 @@ fn build_meta( /// `meta.extra["fdx_title_page"]` always carries the full text either /// way. fn apply_title_page_heuristic(lines: &[TitlePageLine], meta: &mut ScreenplayMeta) { - let non_empty: Vec<&TitlePageLine> = lines.iter().filter(|l| !l.text.trim().is_empty()).collect(); + let non_empty: Vec<&TitlePageLine> = + lines.iter().filter(|l| !l.text.trim().is_empty()).collect(); if non_empty.is_empty() { return; } @@ -940,7 +940,10 @@ mod tests { "#; let (doc, _) = parse_fdx(input).unwrap(); assert_eq!(node_text(&doc.content, 0), "Plain bold italic all three."); - assert!(node_marks(&doc.content, 0, 0).is_empty(), "plain run no marks"); + assert!( + node_marks(&doc.content, 0, 0).is_empty(), + "plain run no marks" + ); assert_eq!(node_marks(&doc.content, 0, 1), vec!["bold"]); assert_eq!(node_marks(&doc.content, 0, 3), vec!["italic"]); let combined = node_marks(&doc.content, 0, 5); @@ -1087,7 +1090,12 @@ mod tests { "#; let (doc, _) = parse_fdx(input).unwrap(); - let dump = doc.meta.extra.get("fdx_title_page").cloned().unwrap_or_default(); + let dump = doc + .meta + .extra + .get("fdx_title_page") + .cloned() + .unwrap_or_default(); assert!(dump.contains("THE GREAT SCRIPT")); assert!(dump.contains("Hrishikesh")); } diff --git a/src-tauri/src/screenplay/fountain.rs b/src-tauri/src/screenplay/fountain.rs index 242ddb6..1881a70 100644 --- a/src-tauri/src/screenplay/fountain.rs +++ b/src-tauri/src/screenplay/fountain.rs @@ -121,10 +121,7 @@ fn emit_title_page(output: &mut String, meta: &ScreenplayMeta) { // round-trip is symmetric. Writers who set this manually for // WGA/registration purposes get it labelled as Copyright in the // Fountain output, which is the closest standard key. - output.push_str(&format!( - "Copyright: {}\n", - meta.registration_number - )); + output.push_str(&format!("Copyright: {}\n", meta.registration_number)); } if !meta.footnote.is_empty() { // `meta.footnote` is the cover-bottom dedication / confidentiality @@ -343,11 +340,7 @@ fn emit_body(output: &mut String, nodes: &[Value], buckets: &HashMap, - scene_idx: usize, -) { +fn flush_scene_notes(output: &mut String, buckets: &HashMap, scene_idx: usize) { if let Some(bucket) = buckets.get(&scene_idx) { for note in &bucket.notes { output.push_str(&format!("\n[[{}]]\n", note)); @@ -552,7 +545,8 @@ mod tests { ..Default::default() }; meta.extra.insert("Zeta".into(), "z".into()); - meta.extra.insert("Source".into(), "Based on a true story".into()); + meta.extra + .insert("Source".into(), "Based on a true story".into()); meta.extra.insert("Beta".into(), "b".into()); let result = generate_fountain(&content, &meta, &[]); let beta = result.find("Beta:").expect("Beta present"); @@ -630,7 +624,10 @@ mod tests { ], }); let result = generate_fountain(&content, &ScreenplayMeta::default(), &[]); - assert!(result.contains("!BANG!"), "expected forced action, got: {result}"); + assert!( + result.contains("!BANG!"), + "expected forced action, got: {result}" + ); } #[test] @@ -890,7 +887,8 @@ mod tests { ..Default::default() }; meta.extra.insert("Source".into(), "An old letter".into()); - meta.extra.insert("Custom Key".into(), "carries over".into()); + meta.extra + .insert("Custom Key".into(), "carries over".into()); let content = scene("INT. HOUSE - DAY"); let exported = generate_fountain(&content, &meta, &[]); diff --git a/src-tauri/src/screenplay/fountain_import.rs b/src-tauri/src/screenplay/fountain_import.rs index e47265a..41df9be 100644 --- a/src-tauri/src/screenplay/fountain_import.rs +++ b/src-tauri/src/screenplay/fountain_import.rs @@ -25,8 +25,7 @@ // - boneyard preservation (silently dropped, counted) use crate::screenplay::document::{ - ProjectType, ScreenplayDocument, ScreenplayMeta, ScreenplaySettings, ScreenplayStory, - SceneCard, + ProjectType, SceneCard, ScreenplayDocument, ScreenplayMeta, ScreenplaySettings, ScreenplayStory, }; use serde_json::{json, Value}; @@ -85,7 +84,9 @@ pub fn parse_fountain(input: &str) -> Result<(ScreenplayDocument, ImportSummary) // Malayalam without @-forced cues is a recoverable but noisy case — // surface a warning so the writer sees why their characters all // imported as action. - if has_substantial_malayalam(body_text) && !body_text.contains("\n@") && !body_text.starts_with('@') + if has_substantial_malayalam(body_text) + && !body_text.contains("\n@") + && !body_text.starts_with('@') { summary.warnings.push( "Fountain file appears to contain Malayalam but no @-prefixed character cues — \ @@ -455,7 +456,10 @@ enum Token { Dialogue(String), Transition(String), Synopsis(String), - Section { depth: u32, text: String }, + Section { + depth: u32, + text: String, + }, /// References a `Note` by index. Inserted at the position the original /// `[[ ... ]]` appeared (between body lines) so the folder can attach /// it to the right scene. @@ -515,15 +519,14 @@ fn tokenize_body(input: &str, summary: &mut ImportSummary) -> Vec { // ─── 1. Forced prefixes (Win over auto-detection). ───────────── // `..` is NOT a forced scene heading per spec — must be a single // dot not followed by another dot. - if trimmed.starts_with('.') - && !trimmed.starts_with("..") - && trimmed.len() > 1 - { + if trimmed.starts_with('.') && !trimmed.starts_with("..") && trimmed.len() > 1 { let heading = drop_scene_number(trimmed[1..].trim()); if heading.1 { summary.scene_numbers_dropped += 1; } - tokens.push(Token::SceneHeading(strip_emphasis_count(heading.0, summary))); + tokens.push(Token::SceneHeading(strip_emphasis_count( + heading.0, summary, + ))); prev = PrevKind::Other; at_top_of_body = false; i += 1; @@ -600,10 +603,7 @@ fn tokenize_body(input: &str, summary: &mut ImportSummary) -> Vec { } // Centred text → action for v1 (no centred node in our schema). if trimmed.starts_with('>') && trimmed.ends_with('<') { - let inner = trimmed - .trim_start_matches('>') - .trim_end_matches('<') - .trim(); + let inner = trimmed.trim_start_matches('>').trim_end_matches('<').trim(); tokens.push(Token::Action(strip_emphasis_count(inner, summary))); prev = PrevKind::Other; at_top_of_body = false; @@ -643,10 +643,7 @@ fn tokenize_body(input: &str, summary: &mut ImportSummary) -> Vec { } // ─── 4. Transition auto-detect (incl. `FADE IN:` at top). ────── - if prev_blank - && next_blank - && is_uppercase_latin_line(trimmed) - && trimmed.ends_with("TO:") + if prev_blank && next_blank && is_uppercase_latin_line(trimmed) && trimmed.ends_with("TO:") { tokens.push(Token::Transition(strip_emphasis_count(trimmed, summary))); prev = PrevKind::Other; @@ -689,7 +686,10 @@ fn tokenize_body(input: &str, summary: &mut ImportSummary) -> Vec { } // ─── 7. Dialogue continuation. ───────────────────────────────── - if matches!(prev, PrevKind::Character | PrevKind::Parenthetical | PrevKind::Dialogue) { + if matches!( + prev, + PrevKind::Character | PrevKind::Parenthetical | PrevKind::Dialogue + ) { // Collect this line plus any consecutive lines that are still // dialogue (the two-space-trailing convention keeps blank- // looking lines alive). @@ -789,9 +789,19 @@ fn is_scene_heading_prefix(line: &str) -> bool { // Order longest-first so `INT./EXT.` beats `INT.` for a line that // genuinely starts with the dual-prefix form. const PREFIXES: &[&str] = &[ - "INT./EXT.", "INT./EXT ", "INT/EXT.", "INT/EXT ", "INT/EXT", - "I/E.", "I/E ", - "INT.", "INT ", "EXT.", "EXT ", "EST.", "EST ", + "INT./EXT.", + "INT./EXT ", + "INT/EXT.", + "INT/EXT ", + "INT/EXT", + "I/E.", + "I/E ", + "INT.", + "INT ", + "EXT.", + "EXT ", + "EST.", + "EST ", ]; PREFIXES.iter().any(|p| upper.starts_with(p)) } @@ -926,7 +936,11 @@ fn strip_emphasis(text: &str) -> String { } } if c == '*' || c == '_' { - let prev_ws = if i == 0 { true } else { chars[i - 1].is_whitespace() }; + let prev_ws = if i == 0 { + true + } else { + chars[i - 1].is_whitespace() + }; let next_ws = if i + 1 >= chars.len() { true } else { @@ -1258,10 +1272,7 @@ mod tests { \n\ INT. HOUSE - DAY\n"; let (doc, _) = parse_fountain(input).unwrap(); - assert_eq!( - doc.meta.contact, - "hello@example.com\n555-1234\nPO Box 99" - ); + assert_eq!(doc.meta.contact, "hello@example.com\n555-1234\nPO Box 99"); } #[test] @@ -1451,7 +1462,10 @@ mod tests { fn nested_sections_preserve_depth() { let input = "## Sequence A\n\nINT. HOUSE - DAY\n\nAction.\n"; let (doc, _) = parse_fountain(input).unwrap(); - assert_eq!(doc.scene_cards[0].shoot_notes, "[[#section depth=2]] Sequence A"); + assert_eq!( + doc.scene_cards[0].shoot_notes, + "[[#section depth=2]] Sequence A" + ); } #[test] diff --git a/src-tauri/src/screenplay/pdf.rs b/src-tauri/src/screenplay/pdf.rs index be0b893..d8c7a07 100644 --- a/src-tauri/src/screenplay/pdf.rs +++ b/src-tauri/src/screenplay/pdf.rs @@ -6,8 +6,8 @@ use crate::screenplay::document::{SceneCard, ScreenplayMeta}; use chrono::Datelike; -use std::collections::HashMap; use serde_json::Value; +use std::collections::HashMap; use typst::diag::FileResult; use typst::foundations::{Bytes, Datetime}; use typst::layout::PagedDocument; @@ -91,7 +91,10 @@ enum ScreenplayGroup { /// /// Uses a manual index loop so we can "consume" (skip) elements that get absorbed /// into a group, preventing them from being processed twice. -fn group_elements(mut elements: Vec, scene_number_start: u32) -> Vec { +fn group_elements( + mut elements: Vec, + scene_number_start: u32, +) -> Vec { let mut groups: Vec = Vec::new(); // Manual index so we can skip elements that get consumed into groups. // A for-each loop wouldn't let us advance past consumed elements. @@ -123,14 +126,13 @@ fn group_elements(mut elements: Vec, scene_number_start: u32) // Peek at the next element — if it's an action, consume it // into the SceneBlock so they stay on the same page. - let first_action_typst = if i + 1 < elements.len() - && elements[i + 1].element_type == "action" - { - i += 1; // Skip the next element since we're consuming it - Some(std::mem::take(&mut elements[i].typst_inline)) - } else { - None - }; + let first_action_typst = + if i + 1 < elements.len() && elements[i + 1].element_type == "action" { + i += 1; // Skip the next element since we're consuming it + Some(std::mem::take(&mut elements[i].typst_inline)) + } else { + None + }; groups.push(ScreenplayGroup::SceneBlock { heading_text, @@ -348,7 +350,11 @@ fn extract_elements(content: &Value) -> Vec { // and dialogue where inline bold formatting should appear in the PDF. let typst_inline = extract_inline_typst(node); - elements.push(ScreenplayElement { element_type, text, typst_inline }); + elements.push(ScreenplayElement { + element_type, + text, + typst_inline, + }); } elements @@ -413,11 +419,17 @@ fn dialogue_quote_wrap(text: &str) -> (&'static str, &'static str) { // Check for any opening quote at the start let first = trimmed.chars().next().unwrap(); - let has_open = matches!(first, '"' | '\u{201C}' | '\u{201D}' | '\'' | '\u{2018}' | '\u{2019}'); + let has_open = matches!( + first, + '"' | '\u{201C}' | '\u{201D}' | '\'' | '\u{2018}' | '\u{2019}' + ); // Check for any closing quote at the end let last = trimmed.chars().last().unwrap(); - let has_close = matches!(last, '"' | '\u{201C}' | '\u{201D}' | '\'' | '\u{2018}' | '\u{2019}'); + let has_close = matches!( + last, + '"' | '\u{201C}' | '\u{201D}' | '\'' | '\u{2018}' | '\u{2019}' + ); let prefix = if has_open { "" } else { "\u{201C}" }; let suffix = if has_close { "" } else { "\u{201D}" }; @@ -453,7 +465,11 @@ fn normalize_quotes(text: &str) -> String { // — apostrophes inside words like "don't" should stay as apostrophe) else if c == '\u{2018}' || c == '\u{2019}' { let prev = if i > 0 { Some(chars[i - 1]) } else { None }; - let next = if i + 1 < chars.len() { Some(chars[i + 1]) } else { None }; + let next = if i + 1 < chars.len() { + Some(chars[i + 1]) + } else { + None + }; let is_opening = match prev { None => true, Some(p) if p.is_whitespace() => true, @@ -609,7 +625,9 @@ pub fn generate_title_page_markup(meta: &ScreenplayMeta, page_numbers: bool) -> // When the body has numbering on, explicitly override with `numbering: none` // so the title page prints without a page number (Hollywood convention). if page_numbers { - page.push_str("#page(margin: (top: 3cm, bottom: 3cm, left: 3cm, right: 2.5cm), numbering: none)[\n"); + page.push_str( + "#page(margin: (top: 3cm, bottom: 3cm, left: 3cm, right: 2.5cm), numbering: none)[\n", + ); } else { page.push_str("#page(margin: (top: 3cm, bottom: 3cm, left: 3cm, right: 2.5cm))[\n"); } @@ -730,19 +748,13 @@ pub fn generate_title_page_markup(meta: &ScreenplayMeta, page_numbers: bool) -> // it reads as a deliberate footer rather than text floating // disconnected at the bottom-left. 35% width keeps it short // and discreet, matching the eyebrow rule weight (0.5pt). - page.push_str( - " #line(length: 35%, stroke: 0.5pt + luma(180))\n #v(0.35cm)\n", - ); + page.push_str(" #line(length: 35%, stroke: 0.5pt + luma(180))\n #v(0.35cm)\n"); if has_contact { // Split multi-line contact info by newlines and join with Typst line breaks. // `\` at the end of a line in Typst creates a line break (like
in HTML). - let contact_lines: Vec = meta - .contact - .trim() - .lines() - .map(escape_typst) - .collect(); + let contact_lines: Vec = + meta.contact.trim().lines().map(escape_typst).collect(); page.push_str(&format!( " #text(size: 10pt)[{}]\n", contact_lines.join("\\\n") @@ -997,10 +1009,7 @@ pub fn generate_typst_markup( }; // Parenthetical: centered on the text-area // centerline, italic. - block.push_str(&format!( - " #align(center)[#emph[{}]]\n", - display - )); + block.push_str(&format!(" #align(center)[#emph[{}]]\n", display)); } DialogueLine::Dialogue(text, typst_inline) => { // Auto-wrap dialogue in quotes if missing @@ -1034,10 +1043,7 @@ pub fn generate_typst_markup( // on the same page — prevents an orphaned // "CUT TO:" cue at the top of the next page. if next_is_transition { - format!( - "#block(sticky: true)[#par[{}]]\n\n", - element.typst_inline - ) + format!("#block(sticky: true)[#par[{}]]\n\n", element.typst_inline) } else { format!("#par[{}]\n\n", element.typst_inline) } @@ -1045,10 +1051,7 @@ pub fn generate_typst_markup( "transition" => { // Transitions: right-aligned, uppercase (e.g., "CUT TO:") // Bold is not meaningful here since transitions are always uppercase styled - format!( - "#v(1em)\n#align(right)[{}]\n", - escaped.to_uppercase() - ) + format!("#v(1em)\n#align(right)[{}]\n", escaped.to_uppercase()) } "episode_boundary" => { // Series export: weak pagebreak (no-op if already on a @@ -1287,7 +1290,11 @@ impl World for ScreenplayWorld { // `Datetime::from_ymd` creates a Typst date from year, month, day. // `Datelike` trait (imported from chrono) provides `.year()`, `.month()`, `.day()`. - Datetime::from_ymd(now.year(), now.month().try_into().ok()?, now.day().try_into().ok()?) + Datetime::from_ymd( + now.year(), + now.month().try_into().ok()?, + now.day().try_into().ok()?, + ) } } @@ -1646,10 +1653,7 @@ pub fn generate_indian_markup( // Transition: right-aligned, full width (e.g., "CUT TO:") let escaped = escape_typst(&element.text); - markup.push_str(&format!( - "#align(right)[{}]\n\n", - escaped.to_uppercase() - )); + markup.push_str(&format!("#align(right)[{}]\n\n", escaped.to_uppercase())); } "episode_boundary" => { // Series export boundary: flush any in-flight character @@ -1750,21 +1754,15 @@ pub fn generate_pdf_indian( let document = typst::compile::(&world) .output .map_err(|diagnostics| { - let messages: Vec = diagnostics - .iter() - .map(|d| format!("{:?}", d)) - .collect(); + let messages: Vec = diagnostics.iter().map(|d| format!("{:?}", d)).collect(); format!("Typst compilation errors: {}", messages.join("; ")) })?; // Render the compiled document to PDF bytes in memory. // No temp files are written — everything stays in memory. - let pdf_bytes = typst_pdf::pdf(&document, &typst_pdf::PdfOptions::default()) - .map_err(|diagnostics| { - let messages: Vec = diagnostics - .iter() - .map(|d| format!("{:?}", d)) - .collect(); + let pdf_bytes = + typst_pdf::pdf(&document, &typst_pdf::PdfOptions::default()).map_err(|diagnostics| { + let messages: Vec = diagnostics.iter().map(|d| format!("{:?}", d)).collect(); format!("PDF rendering errors: {}", messages.join("; ")) })?; @@ -1826,22 +1824,16 @@ pub fn generate_pdf( .output .map_err(|diagnostics| { // `diagnostics` is a Vec of errors — format them all into one string - let messages: Vec = diagnostics - .iter() - .map(|d| format!("{:?}", d)) - .collect(); + let messages: Vec = diagnostics.iter().map(|d| format!("{:?}", d)).collect(); format!("Typst compilation errors: {}", messages.join("; ")) })?; // Render the compiled document to PDF bytes in memory. // `PdfOptions::default()` uses standard PDF settings. // No temp files are written — everything stays in memory. - let pdf_bytes = typst_pdf::pdf(&document, &typst_pdf::PdfOptions::default()) - .map_err(|diagnostics| { - let messages: Vec = diagnostics - .iter() - .map(|d| format!("{:?}", d)) - .collect(); + let pdf_bytes = + typst_pdf::pdf(&document, &typst_pdf::PdfOptions::default()).map_err(|diagnostics| { + let messages: Vec = diagnostics.iter().map(|d| format!("{:?}", d)).collect(); format!("PDF rendering errors: {}", messages.join("; ")) })?; @@ -1865,7 +1857,14 @@ pub fn generate_pdf( /// * `director` — Director name /// * `needs_pagebreak` — whether to emit a `#pagebreak()` before the section #[allow(clippy::too_many_arguments)] -pub fn generate_prose_section_markup(section_name: &str, body: &str, font_name: &str, meta: &ScreenplayMeta, needs_pagebreak: bool, page_numbers: bool) -> String { +pub fn generate_prose_section_markup( + section_name: &str, + body: &str, + font_name: &str, + meta: &ScreenplayMeta, + needs_pagebreak: bool, + page_numbers: bool, +) -> String { let escaped_section = escape_typst(section_name); let escaped_body = escape_typst(body); let escaped_title = escape_typst(&meta.title); @@ -2050,7 +2049,14 @@ pub fn generate_prose_section_markup(section_name: &str, body: &str, font_name: /// location group, or empty-state). Used by the export's "Compact /// card view" toggle. #[allow(clippy::too_many_arguments)] -pub fn generate_scene_cards_markup(cards_data: &Value, font_name: &str, meta: &ScreenplayMeta, needs_pagebreak: bool, page_numbers: bool, compact: bool) -> String { +pub fn generate_scene_cards_markup( + cards_data: &Value, + font_name: &str, + meta: &ScreenplayMeta, + needs_pagebreak: bool, + page_numbers: bool, + compact: bool, +) -> String { let mut markup = String::new(); // Only emit a page break if there's preceding content. When page @@ -2204,13 +2210,33 @@ pub fn generate_scene_cards_markup(cards_data: &Value, font_name: &str, meta: &S /// The caller controls inter-card spacing; this function emits /// only the card itself. fn emit_scene_card(markup: &mut String, card: &Value, compact: bool) { - let scene_num = card.get("scene_number").and_then(|v| v.as_u64()).unwrap_or(0); + let scene_num = card + .get("scene_number") + .and_then(|v| v.as_u64()) + .unwrap_or(0); let heading = card.get("heading").and_then(|v| v.as_str()).unwrap_or(""); - let characters = card.get("characters").and_then(|v| v.as_str()).unwrap_or(""); - let description = card.get("description").and_then(|v| v.as_str()).unwrap_or(""); - let shoot_notes = card.get("shoot_notes").and_then(|v| v.as_str()).unwrap_or(""); - let scheduled_date = card.get("scheduled_date").and_then(|v| v.as_str()).unwrap_or("").trim(); - let location_group = card.get("location_group").and_then(|v| v.as_str()).unwrap_or("").trim(); + let characters = card + .get("characters") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let description = card + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let shoot_notes = card + .get("shoot_notes") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let scheduled_date = card + .get("scheduled_date") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim(); + let location_group = card + .get("location_group") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim(); // Parse setting + time from the heading so the eyebrow renders // "INTERIOR · DAY" / "EXTERIOR · NIGHT" the same way the @@ -2313,9 +2339,7 @@ fn emit_scene_card(markup: &mut String, card: &Value, compact: bool) { // either body field, drop a muted em-dash so the card // reads as "intentionally pending" rather than a layout gap. if description.is_empty() && shoot_notes.is_empty() { - markup.push_str( - " #v(6pt)\n #text(size: 9.5pt, fill: luma(180))[—]\n", - ); + markup.push_str(" #v(6pt)\n #text(size: 9.5pt, fill: luma(180))[—]\n"); } } @@ -2521,7 +2545,9 @@ pub fn generate_shoot_list_markup( let mut day_first_emit = true; let flush_day_total = |markup: &mut String, total: u64, first: bool| { - if first || total == 0 { return; } + if first || total == 0 { + return; + } let pages = (total as f64) / 8.0; markup.push_str(&format!( "#v(6pt)\n#align(right)[#text(size: 10pt, fill: luma(110))[Day total: {} eighths (~{:.1} pages)]]\n\n", @@ -2530,13 +2556,27 @@ pub fn generate_shoot_list_markup( }; for row in arr { - let date = row.get("scheduled_date").and_then(|v| v.as_str()).unwrap_or("").to_string(); - let group = row.get("location_group").and_then(|v| v.as_str()).unwrap_or("").to_string(); - let scene_num = row.get("scene_number").and_then(|v| v.as_u64()).unwrap_or(0); + let date = row + .get("scheduled_date") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let group = row + .get("location_group") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let scene_num = row + .get("scene_number") + .and_then(|v| v.as_u64()) + .unwrap_or(0); let heading = row.get("heading").and_then(|v| v.as_str()).unwrap_or(""); let location = row.get("location").and_then(|v| v.as_str()).unwrap_or(""); let time = row.get("time").and_then(|v| v.as_str()).unwrap_or(""); - let char_count = row.get("character_count").and_then(|v| v.as_u64()).unwrap_or(0); + let char_count = row + .get("character_count") + .and_then(|v| v.as_u64()) + .unwrap_or(0); let eighths = row.get("eighths").and_then(|v| v.as_u64()).unwrap_or(1); // Day boundary — flush prior day's total + pagebreak before the next. @@ -2549,7 +2589,11 @@ pub fn generate_shoot_list_markup( day_first_emit = false; // Day header — date prominent, day-of-week-style block. - let date_label = if date.is_empty() { "Unscheduled".to_string() } else { date.clone() }; + let date_label = if date.is_empty() { + "Unscheduled".to_string() + } else { + date.clone() + }; markup.push_str(&format!( "#text(size: 14pt, weight: \"bold\")[{}]\n#v(2pt)\n#line(length: 100%, stroke: 0.5pt + luma(180))\n#v(8pt)\n\n", escape_typst(&date_label) @@ -2560,7 +2604,11 @@ pub fn generate_shoot_list_markup( // Group boundary within a day. if Some(&group) != current_group.as_ref() { - let group_label = if group.is_empty() { "(no group)".to_string() } else { group.clone() }; + let group_label = if group.is_empty() { + "(no group)".to_string() + } else { + group.clone() + }; markup.push_str(&format!( "#v(4pt)\n#text(size: 11pt, weight: \"semibold\", fill: luma(80))[{}]\n#v(4pt)\n\n", escape_typst(&group_label) @@ -2618,19 +2666,13 @@ pub fn compile_markup_to_pdf(markup: &str, font_data: &FontData) -> Result(&world) .output .map_err(|diagnostics| { - let messages: Vec = diagnostics - .iter() - .map(|d| format!("{:?}", d)) - .collect(); + let messages: Vec = diagnostics.iter().map(|d| format!("{:?}", d)).collect(); format!("Typst compilation errors: {}", messages.join("; ")) })?; - let pdf_bytes = typst_pdf::pdf(&document, &typst_pdf::PdfOptions::default()) - .map_err(|diagnostics| { - let messages: Vec = diagnostics - .iter() - .map(|d| format!("{:?}", d)) - .collect(); + let pdf_bytes = + typst_pdf::pdf(&document, &typst_pdf::PdfOptions::default()).map_err(|diagnostics| { + let messages: Vec = diagnostics.iter().map(|d| format!("{:?}", d)).collect(); format!("PDF rendering errors: {}", messages.join("; ")) })?; @@ -2748,7 +2790,16 @@ mod tests { ] }); - let markup = generate_typst_markup(&doc, "Noto Sans Malayalam", &empty_meta(), false, 1, false, &[], false); + let markup = generate_typst_markup( + &doc, + "Noto Sans Malayalam", + &empty_meta(), + false, + 1, + false, + &[], + false, + ); // Should contain the font setting assert!(markup.contains("Noto Sans Malayalam")); // Scene heading text should be uppercased @@ -2786,7 +2837,8 @@ mod tests { ] }); - let markup = generate_typst_markup(&doc, "Manjari", &empty_meta(), false, 1, false, &[], false); + let markup = + generate_typst_markup(&doc, "Manjari", &empty_meta(), false, 1, false, &[], false); // Character cue is uppercased and centred (the renderer switched // from `pad(left: 5.19cm)` to `#align(center)` when character + // parenthetical + dialogue were unified onto a shared centerline). @@ -2813,7 +2865,16 @@ mod tests { ] }); - let markup = generate_typst_markup(&doc, "Noto Sans Malayalam", &empty_meta(), false, 1, false, &[], false); + let markup = generate_typst_markup( + &doc, + "Noto Sans Malayalam", + &empty_meta(), + false, + 1, + false, + &[], + false, + ); // Malayalam text should pass through unmodified (no special chars to escape) assert!(markup.contains("രമേഷ് Flat ലേക്ക് നടന്നു")); } @@ -3057,7 +3118,16 @@ mod tests { ] }); - let markup = generate_typst_markup(&doc, "Noto Sans Malayalam", &empty_meta(), false, 1, false, &[], false); + let markup = generate_typst_markup( + &doc, + "Noto Sans Malayalam", + &empty_meta(), + false, + 1, + false, + &[], + false, + ); // The scene heading and first action should be inside a single // unbreakable block. (Block takes additional args, so match prefix.) assert!(markup.contains("block(breakable: false,")); @@ -3093,7 +3163,9 @@ mod tests { assert_eq!(groups.len(), 2); match &groups[0] { - ScreenplayGroup::SceneBlock { first_action_typst, .. } => { + ScreenplayGroup::SceneBlock { + first_action_typst, .. + } => { // typst markup; equality holds because "First action." has // no characters that `escape_typst` modifies. assert_eq!(first_action_typst.as_deref(), Some("First action.")); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 4d0b7a2..f2cf218 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,45 +1,45 @@ { - "$schema": "https://schema.tauri.app/config/2", - "productName": "Scriptty", - "version": "0.10.0", - "identifier": "app.scriptty.screenplay", - "build": { - "frontendDist": "../build", - "devUrl": "http://localhost:5173", - "beforeDevCommand": "npm run dev", - "beforeBuildCommand": "npm run build" - }, - "app": { - "windows": [ - { - "title": "Scriptty", - "width": 800, - "height": 600, - "resizable": true, - "maximized": true - } - ], - "security": { - "csp": "default-src 'self' ipc: http://ipc.localhost; img-src 'self' asset: http://asset.localhost data:; font-src 'self' asset: http://asset.localhost; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' ipc: http://ipc.localhost ws://localhost:* https://api.github.com" - } - }, - "bundle": { - "active": true, - "targets": "all", - "icon": [ - "icons/32x32.png", - "icons/128x128.png", - "icons/128x128@2x.png", - "icons/icon.icns", - "icons/icon.ico" - ], - "fileAssociations": [ - { - "ext": ["screenplay"], - "mimeType": "application/x-screenplay", - "description": "Scriptty Screenplay", - "role": "Editor" - } - ] - } + "$schema": "https://schema.tauri.app/config/2", + "productName": "Scriptty", + "version": "0.10.0", + "identifier": "app.scriptty.screenplay", + "build": { + "frontendDist": "../build", + "devUrl": "http://localhost:5173", + "beforeDevCommand": "npm run dev", + "beforeBuildCommand": "npm run build" + }, + "app": { + "windows": [ + { + "title": "Scriptty", + "width": 800, + "height": 600, + "resizable": true, + "maximized": true + } + ], + "security": { + "csp": "default-src 'self' ipc: http://ipc.localhost; img-src 'self' asset: http://asset.localhost data:; font-src 'self' asset: http://asset.localhost; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' ipc: http://ipc.localhost ws://localhost:* https://api.github.com" + } + }, + "bundle": { + "active": true, + "targets": "all", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], + "fileAssociations": [ + { + "ext": ["screenplay"], + "mimeType": "application/x-screenplay", + "description": "Scriptty Screenplay", + "role": "Editor" + } + ] + } } diff --git a/src/lib/components/AboutModal.svelte b/src/lib/components/AboutModal.svelte index 47dd061..fde29bb 100644 --- a/src/lib/components/AboutModal.svelte +++ b/src/lib/components/AboutModal.svelte @@ -1,346 +1,406 @@ {#if open} - - {/if} diff --git a/src/lib/components/CommandPalette.svelte b/src/lib/components/CommandPalette.svelte index e610ce9..2bd3b10 100644 --- a/src/lib/components/CommandPalette.svelte +++ b/src/lib/components/CommandPalette.svelte @@ -1,311 +1,314 @@ {#if open} - - + + {/if} diff --git a/src/lib/components/DatePicker.svelte b/src/lib/components/DatePicker.svelte index 757b4e1..7b4acae 100644 --- a/src/lib/components/DatePicker.svelte +++ b/src/lib/components/DatePicker.svelte @@ -1,721 +1,787 @@
- - - {#if open} - - {/if} + { + e.stopPropagation(); + clear(); + }} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.stopPropagation(); + clear(); + } + }}>× + {/if} + + + {#if open} + + {/if}
diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index eaab225..9f87a58 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -1,984 +1,1054 @@
- {#if findReplaceOpen} - { findReplaceOpen = false; }} /> - {/if} -
-
-
- {#if showAnnotations} -
- {#each sceneSlots as slot (slot.sceneOrder)} - {@const showFields = slot.description || slot.shootNotes || editingSceneIndex === slot.sceneOrder} - {@const expanded = !collapsedSlots.has(slot.sceneOrder)} -
-
- {#if showFields} - -
- Description - -
-
- Notes - -
- {/if} -
-
- {/each} -
- {/if} -
-
- - + {#if findReplaceOpen} + { + findReplaceOpen = false; + }} + /> + {/if} +
+
+
+ {#if showAnnotations} +
+ {#each sceneSlots as slot (slot.sceneOrder)} + {@const showFields = + slot.description || slot.shootNotes || editingSceneIndex === slot.sceneOrder} + {@const expanded = !collapsedSlots.has(slot.sceneOrder)} +
+
+ {#if showFields} + +
+ Description + +
+
+ Notes + +
+ {/if} +
+
+ {/each} +
+ {/if} +
+
+ +
diff --git a/src/lib/components/EpisodeCardsView.svelte b/src/lib/components/EpisodeCardsView.svelte index 294ff07..27a7018 100644 --- a/src/lib/components/EpisodeCardsView.svelte +++ b/src/lib/components/EpisodeCardsView.svelte @@ -1,1091 +1,1152 @@
-
- -
- -
- {#each episodes as ep, index (ep.id)} - {@const sceneCount = sceneCountFor(ep)} - {@const pages = pageEstimateFor(ep)} - {@const idea = ep.story?.idea ?? ''} - {@const peekHeadings = sceneHeadingsFor(ep, 3)} - {@const hiddenCount = sceneCount - peekHeadings.length} - - - -
{ - // Click anywhere on the card body drills into scenes (#154) — - // editable controls (textarea, input, buttons) opt out via - // closest() so the writer doesn't drill while editing the - // logline or renaming the episode. - const t = e.target as HTMLElement; - if (t.closest('input, textarea, button, [role="button"]')) return; - openEpisode(index); - }} - > -
- startDrag(e, index)} - role="button" - tabindex="-1" - aria-label="Drag to reorder Episode {ep.number}" - title="Drag to reorder" - >{String(ep.number).padStart(2, '0')} - - {#if editingIndex === index} - - - {:else} - - {/if} - -
- - -
-
- -
- - - - + + +
{ + // Click anywhere on the card body drills into scenes (#154) — + // editable controls (textarea, input, buttons) opt out via + // closest() so the writer doesn't drill while editing the + // logline or renaming the episode. + const t = e.target as HTMLElement; + if (t.closest('input, textarea, button, [role="button"]')) return; + openEpisode(index); + }} + > +
+ startDrag(e, index)} + role="button" + tabindex="-1" + aria-label="Drag to reorder Episode {ep.number}" + title="Drag to reorder">{String(ep.number).padStart(2, '0')} + + {#if editingIndex === index} + + + {:else} + + {/if} + +
+ + +
+
+ +
+ + + + -
- Scenes - {#if peekHeadings.length === 0} -

No scenes yet — drill in to start outlining.

- {:else} -
    - {#each peekHeadings as heading, i} -
  1. - {String(i + 1).padStart(2, '0')} - {heading.toUpperCase()} -
  2. - {/each} -
- {#if hiddenCount > 0} -

- + {hiddenCount} more {hiddenCount === 1 ? 'scene' : 'scenes'} -

- {/if} - {/if} -
-
- -
-
- - - {sceneCount} - {sceneCount === 1 ? 'scene' : 'scenes'} - - - ~{pages} - pages - -
- -
-
- {/each} - - - -
+ +
diff --git a/src/lib/components/ExportModal.svelte b/src/lib/components/ExportModal.svelte index 163907e..fb4c9e0 100644 --- a/src/lib/components/ExportModal.svelte +++ b/src/lib/components/ExportModal.svelte @@ -1,1987 +1,2240 @@ {#if open} - - + {/if} diff --git a/src/lib/components/NewProjectDialog.svelte b/src/lib/components/NewProjectDialog.svelte index d5baf12..b339aaf 100644 --- a/src/lib/components/NewProjectDialog.svelte +++ b/src/lib/components/NewProjectDialog.svelte @@ -1,314 +1,331 @@ {#if open} - - {/if} diff --git a/src/lib/components/OutlinePeek.svelte b/src/lib/components/OutlinePeek.svelte index 140c2c6..d122c5d 100644 --- a/src/lib/components/OutlinePeek.svelte +++ b/src/lib/components/OutlinePeek.svelte @@ -1,219 +1,222 @@ {#if scenes.length > 0} -
- - {#if currentIndex >= 0} - Scene {scenes[currentIndex]?.number ?? currentIndex + 1} - of {scenes[scenes.length - 1].number} - {:else} - {scenes.length} scene{scenes.length === 1 ? '' : 's'} - {/if} - -
- {#each scenes as sc, i (sc.childIndex)} - - {/each} -
- -
+
+ + {#if currentIndex >= 0} + Scene {scenes[currentIndex]?.number ?? currentIndex + 1} + of {scenes[scenes.length - 1].number} + {:else} + {scenes.length} scene{scenes.length === 1 ? '' : 's'} + {/if} + +
+ {#each scenes as sc, i (sc.childIndex)} + + {/each} +
+ +
{/if} diff --git a/src/lib/components/PasteScriptDialog.svelte b/src/lib/components/PasteScriptDialog.svelte index 90a0e71..7bdb109 100644 --- a/src/lib/components/PasteScriptDialog.svelte +++ b/src/lib/components/PasteScriptDialog.svelte @@ -1,105 +1,112 @@ {#if open} - - + spellcheck="false" + > + +
+ {#if parsed && parsed.content.length > 0} +
    + {#each parsed.content as node, i (i)} +
  1. + {elementLabel(node.type)} + {elementText(node) || '(empty)'} +
  2. + {/each} +
+ {:else} +

Preview appears here once you paste.

+ {/if} +
+ + + + + {/if} diff --git a/src/lib/components/SceneCardsView.svelte b/src/lib/components/SceneCardsView.svelte index bdeb9df..c168fb1 100644 --- a/src/lib/components/SceneCardsView.svelte +++ b/src/lib/components/SceneCardsView.svelte @@ -1,1570 +1,1667 @@
- - -
-
-
- {#if heroIsSeriesScenes} - - - Episode {String(documentStore.activeEpisode?.number ?? 1).padStart(2, '0')} - {:else if heroIsEpisodesLevel} - - Series - - {:else} - - Film - - {/if} -
- -

- {#if heroIsEpisodesLevel}Episodes{:else}Scenes{/if} -

- -

- {#if heroIsEpisodesLevel} - {documentStore.document?.series?.title || 'Untitled Series'} - {#if seriesTotals} - - - {seriesTotals.scenes} {seriesTotals.scenes === 1 ? 'scene' : 'scenes'} - - ~{seriesTotals.pages} pages - - {/if} - {:else if heroIsSeriesScenes} - {documentStore.activeEpisode?.title?.trim() || 'Untitled episode'} - {:else} - {documentStore.activeMeta?.title?.trim() || 'Untitled screenplay'} - {/if} -

-
- -
-
- {heroCountValue} - {heroCountNoun} -
- - {#if cardLevel === 'scenes'} -
- - -
- {/if} -
-
- - {#if documentStore.isSeries && cardLevel === 'episodes'} - -
- -
- {:else} -
- {#each displayCards as card (card.key)} - {@const headingUpper = card.heading.toUpperCase()} - {@const cardSetting = headingUpper.startsWith('INT./EXT.') || headingUpper.startsWith('INT/EXT') - ? 'INT_EXT' - : headingUpper.startsWith('INT.') || headingUpper.startsWith('INT ') - ? 'INT' - : headingUpper.startsWith('EXT.') || headingUpper.startsWith('EXT ') - ? 'EXT' - : ''} - - {@const isSkeleton = !card.description.trim() - && !card.shootNotes.trim() - && !card.extraCharacters.trim() - && !card.scheduledDate.trim() - && !card.locationGroup.trim()} - {@const hasProduction = !!(card.extraCharacters.trim() || card.locationGroup.trim() || card.scheduledDate.trim())} - {@const settingWord = cardSetting === 'INT' ? 'INTERIOR' - : cardSetting === 'EXT' ? 'EXTERIOR' - : cardSetting === 'INT_EXT' ? 'INT · EXT' - : ''} - {@const timeWord = (card.time ?? '').toUpperCase()} -
- -
{ if (!groupByLocation) startDrag(e, card.sceneNumber); }} - role="button" - tabindex="-1" - aria-label={groupByLocation - ? `Scene ${card.sceneNumber} (drag disabled while grouped)` - : `Drag to reorder scene ${card.sceneNumber}`} - title={groupByLocation ? 'Switch off "Group by location" to drag-reorder' : 'Drag to reorder'} - > - {String(card.sceneNumber).padStart(2, '0')} -
- -
-
- -
- {#if settingWord}{settingWord}{/if} - {#if settingWord && timeWord}{/if} - {#if timeWord}{timeWord}{/if} - {#if !settingWord && !timeWord}No slug yet{/if} -
- -
- - -
-
- - - {#if editingHeadingFor === card.sceneNumber} - - commitHeadingEdit(card.sceneNumber)} - onkeydown={(e) => handleHeadingKeydown(e, card.sceneNumber)} - placeholder="INT. LOCATION — TIME" - autofocus - /> - {:else} - - {/if} - - {#if card.characters.length > 0} -

{card.characters.join(' · ')}

- {/if} - -
- - - + {#if editingHeadingFor === card.sceneNumber} + + commitHeadingEdit(card.sceneNumber)} + onkeydown={(e) => handleHeadingKeydown(e, card.sceneNumber)} + placeholder="INT. LOCATION — TIME" + autofocus + /> + {:else} + + {/if} + + {#if card.characters.length > 0} +

+ + {card.characters.join(' · ')} +

+ {/if} + +
+ + + -
- - - Production - {#if hasProduction} - - {#if card.locationGroup.trim()}{card.locationGroup}{/if} - {#if card.scheduledDate.trim()}· {card.scheduledDate}{/if} - {#if card.extraCharacters.trim()}· extras{/if} - - {:else} - cast extras · location group · shoot date - {/if} - -
-
- - updateExtraCharacters(card.sceneOrder, (e.target as HTMLInputElement).value)} - onkeydown={handleKeydown} - /> -
-
-
- - updateLocationGroup(card.sceneOrder, (e.target as HTMLInputElement).value)} - onkeydown={handleKeydown} - /> -
-
- Shoot date - updateScheduledDate(card.sceneOrder, v)} - placeholder="Pick a date" - compact={true} - /> -
-
-
-
- - -
- -
- {card.pageEstimate} -
-
-
- {/each} - - - -
- - + +
+ + - - {/if} + + {/if}
diff --git a/src/lib/components/SceneNavigator.svelte b/src/lib/components/SceneNavigator.svelte index 48be993..835f7be 100644 --- a/src/lib/components/SceneNavigator.svelte +++ b/src/lib/components/SceneNavigator.svelte @@ -1,948 +1,1027 @@ diff --git a/src/lib/components/SeriesEpisodeList.svelte b/src/lib/components/SeriesEpisodeList.svelte index 4a4b390..583f4d7 100644 --- a/src/lib/components/SeriesEpisodeList.svelte +++ b/src/lib/components/SeriesEpisodeList.svelte @@ -1,803 +1,841 @@
- -
-
- Series - {#if editingSeries} - - - {:else} - - {/if} - {episodes.length} {episodes.length === 1 ? 'episode' : 'episodes'} -
- -
- -
    - {#each episodes as ep, index (ep.id)} - {@const isActive = index === documentStore.activeEpisodeIndex} - {@const isOpen = expanded[ep.id] ?? false} -
  • index} - class:drop-below={dropTargetIndex === index && dragFromIndex !== null && dragFromIndex < index} - > -
    - startDrag(e, index)} - role="button" - tabindex="-1" - aria-label={`Drag to reorder Episode ${ep.number}`} - title="Drag to reorder" - > - - - - {#if editingIndex === index} - - - {:else} - {@const halves = splitBilingualTitle(ep.title)} - -
    - - -
    - {/if} -
    - - {#if isOpen} - + + {:else} + + {/if} + {episodes.length} {episodes.length === 1 ? 'episode' : 'episodes'} +
+ + + +
    + {#each episodes as ep, index (ep.id)} + {@const isActive = index === documentStore.activeEpisodeIndex} + {@const isOpen = expanded[ep.id] ?? false} +
  • index} + class:drop-below={dropTargetIndex === index && + dragFromIndex !== null && + dragFromIndex < index} + > +
    + startDrag(e, index)} + role="button" + tabindex="-1" + aria-label={`Drag to reorder Episode ${ep.number}`} + title="Drag to reorder" + > + + + + {#if editingIndex === index} + + + {:else} + {@const halves = splitBilingualTitle(ep.title)} + +
    + + +
    + {/if} +
    + + {#if isOpen} + -
    - -
    - {/if} -
  • - {/each} -
+
+ +
+ {/if} + + {/each} + diff --git a/src/lib/components/SettingsModal.svelte b/src/lib/components/SettingsModal.svelte index dafbbdf..3977e64 100644 --- a/src/lib/components/SettingsModal.svelte +++ b/src/lib/components/SettingsModal.svelte @@ -1,670 +1,745 @@ {#if open} - - + + {/if} diff --git a/src/lib/components/StatisticsModal.svelte b/src/lib/components/StatisticsModal.svelte index 0e9e1c2..6e4c912 100644 --- a/src/lib/components/StatisticsModal.svelte +++ b/src/lib/components/StatisticsModal.svelte @@ -1,2094 +1,2536 @@ {#if open} - - + {/if} diff --git a/src/lib/components/StatusBar.svelte b/src/lib/components/StatusBar.svelte index bffd724..0245175 100644 --- a/src/lib/components/StatusBar.svelte +++ b/src/lib/components/StatusBar.svelte @@ -1,301 +1,341 @@
-
- -
- - {#if onShowHelp} - - {/if} -
+
+ +
+ + {#if onShowHelp} + + {/if} +
- + - -
- -
+ +
+ +
- + - -
- -
-
-
- {#if saveLabel} - - - {saveLabel} - - {/if} - {#if rightContent} - {@render rightContent()} - {/if} -
+
+ +
+
+
+ {#if saveLabel} + + + {saveLabel} + + {/if} + {#if rightContent} + {@render rightContent()} + {/if} +
diff --git a/src/lib/components/StoryModeView.svelte b/src/lib/components/StoryModeView.svelte index c0db5a2..241befb 100644 --- a/src/lib/components/StoryModeView.svelte +++ b/src/lib/components/StoryModeView.svelte @@ -1,345 +1,392 @@
-
-
-
- -
- -

{tabs.find((t) => t.id === activeTab)?.label ?? 'Story'}

-
-
- {#each tabs as tab} - - {/each} -
- {#each tabs as tab (tab.id)} - {#if activeTab === tab.id} -
- -
- {/if} - {/each} -
-
-
+
+ +

+ {tabs.find((t) => t.id === activeTab)?.label ?? 'Story'} +

+
+
+ {#each tabs as tab} + + {/each} +
+ {#each tabs as tab (tab.id)} + {#if activeTab === tab.id} +
+ +
+ {/if} + {/each} +
+ + diff --git a/src/lib/components/TitleBar.svelte b/src/lib/components/TitleBar.svelte index 9af6860..85ba81c 100644 --- a/src/lib/components/TitleBar.svelte +++ b/src/lib/components/TitleBar.svelte @@ -1,818 +1,986 @@
-
- - - - -
- -
- - {#if documentStore.document} - - {/if} - {displayTitle} - {#if documentStore.document && !(documentStore.isSeries && documentStore.activeEpisode)} - - {/if} - {#if documentStore.isSeries && documentStore.activeEpisode} - - {/if} - {#if documentStore.isDirty} - - {/if} - {#if statusMessage} - - - {statusMessage} - - {/if} - - - {#if episodePopoverOpen && documentStore.isSeries && documentStore.document?.series} -
- {#each documentStore.document.series.episodes as ep, idx (ep.id)} - - {/each} - - -
- {/if} -
- -
- - - -
- -
- - - - -
+ + + + +
- diff --git a/src/lib/components/UpdateToast.svelte b/src/lib/components/UpdateToast.svelte index 78b70be..2b98004 100644 --- a/src/lib/components/UpdateToast.svelte +++ b/src/lib/components/UpdateToast.svelte @@ -1,188 +1,190 @@ {#if updateStore.available} - -
- - -

- Scriptty {updateStore.available.latestVersion}. -

- -

- Out now. - You're on {updateStore.available.currentVersion}. -

- -
- - -
-
+
+ + +

+ Scriptty {updateStore.available.latestVersion}. +

+ +

+ Out now. + You're on {updateStore.available.currentVersion}. +

+ +
+ + +
+
{/if} diff --git a/src/lib/components/WelcomeScreen.svelte b/src/lib/components/WelcomeScreen.svelte index 7045ee8..f2bd269 100644 --- a/src/lib/components/WelcomeScreen.svelte +++ b/src/lib/components/WelcomeScreen.svelte @@ -1,146 +1,175 @@
-
- - - - - -

Scriptty.

-

For Malayalam & English screenwriters

- - - -
- - - -
- -
- - -
- - {#if recent.length > 0} -
- -
    - {#each recent as item (item.path)} -
  • - -
  • - {/each} -
-
- {/if} -
+ + + + +

Scriptty.

+

For Malayalam & English screenwriters

+ + + +
+ + + +
+ +
+ + +
+ + {#if recent.length > 0} +
+ +
    + {#each recent as item (item.path)} +
  • + +
  • + {/each} +
+
+ {/if} +
@@ -148,365 +177,370 @@ diff --git a/src/lib/editor/autoUppercase.ts b/src/lib/editor/autoUppercase.ts index 2f6f106..e282dd7 100644 --- a/src/lib/editor/autoUppercase.ts +++ b/src/lib/editor/autoUppercase.ts @@ -17,39 +17,39 @@ const LOWERCASE_LATIN = /[a-z]/; * Uses appendTransaction so it works with typed input, paste, and IME. */ export const autoUppercasePlugin = new Plugin({ - appendTransaction(transactions: readonly Transaction[], _oldState, newState) { - // Only process if any transaction changed the document - if (!transactions.some((tr) => tr.docChanged)) return null; - - const { doc, selection } = newState; - const $from = selection.$from; - - // Check if the cursor is inside an auto-uppercase node type - const parentType = $from.parent.type.name; - if (!UPPERCASE_TYPES.has(parentType)) return null; - - // Scan the current node's text content for lowercase Latin letters - const parent = $from.parent; - let tr = newState.tr; - let changed = false; - - parent.forEach((child, offset) => { - if (!child.isText || !child.text) return; - - const text = child.text; - // Position of this text node within the document - // $from.start() gives the start position of the parent node's content - const basePos = $from.start() + offset; - - for (let i = 0; i < text.length; i++) { - if (LOWERCASE_LATIN.test(text[i])) { - const pos = basePos + i; - tr = tr.replaceWith(pos, pos + 1, newState.schema.text(text[i].toUpperCase())); - changed = true; - } - } - }); - - return changed ? tr : null; - }, + appendTransaction(transactions: readonly Transaction[], _oldState, newState) { + // Only process if any transaction changed the document + if (!transactions.some((tr) => tr.docChanged)) return null; + + const { doc, selection } = newState; + const $from = selection.$from; + + // Check if the cursor is inside an auto-uppercase node type + const parentType = $from.parent.type.name; + if (!UPPERCASE_TYPES.has(parentType)) return null; + + // Scan the current node's text content for lowercase Latin letters + const parent = $from.parent; + let tr = newState.tr; + let changed = false; + + parent.forEach((child, offset) => { + if (!child.isText || !child.text) return; + + const text = child.text; + // Position of this text node within the document + // $from.start() gives the start position of the parent node's content + const basePos = $from.start() + offset; + + for (let i = 0; i < text.length; i++) { + if (LOWERCASE_LATIN.test(text[i])) { + const pos = basePos + i; + tr = tr.replaceWith(pos, pos + 1, newState.schema.text(text[i].toUpperCase())); + changed = true; + } + } + }); + + return changed ? tr : null; + } }); diff --git a/src/lib/editor/characterAutocomplete.ts b/src/lib/editor/characterAutocomplete.ts index b97f76e..bd88492 100644 --- a/src/lib/editor/characterAutocomplete.ts +++ b/src/lib/editor/characterAutocomplete.ts @@ -13,25 +13,25 @@ import { screenplaySchema } from './schema'; export const autocompleteKey = new PluginKey('characterAutocomplete'); interface AutocompleteState { - /** Whether the dropdown is currently visible */ - active: boolean; - /** The text the user has typed so far in the character element */ - query: string; - /** Filtered + sorted suggestion list */ - suggestions: string[]; - /** Index of the currently highlighted suggestion (0-based) */ - selectedIndex: number; + /** Whether the dropdown is currently visible */ + active: boolean; + /** The text the user has typed so far in the character element */ + query: string; + /** Filtered + sorted suggestion list */ + suggestions: string[]; + /** Index of the currently highlighted suggestion (0-based) */ + selectedIndex: number; } /** Collect all unique character names from the document */ function collectCharacterNames(state: EditorState): string[] { - const names = new Set(); - state.doc.descendants((node) => { - if (node.type.name === 'character' && node.textContent.trim().length > 0) { - names.add(node.textContent.trim()); - } - }); - return Array.from(names); + const names = new Set(); + state.doc.descendants((node) => { + if (node.type.name === 'character' && node.textContent.trim().length > 0) { + names.add(node.textContent.trim()); + } + }); + return Array.from(names); } /** @@ -55,17 +55,17 @@ function collectCharacterNames(state: EditorState): string[] { * (e.g. `c` → "ക്\u200D" before the `h`) but never carry lexical weight. */ function skeleton(s: string): string { - return s - .normalize('NFC') - .replace(/[\u0D3E-\u0D4D\u0D57\u0D62\u0D63\u0D81-\u0D83\u200C\u200D]/g, '') - .toLowerCase(); + return s + .normalize('NFC') + .replace(/[\u0D3E-\u0D4D\u0D57\u0D62\u0D63\u0D81-\u0D83\u200C\u200D]/g, '') + .toLowerCase(); } interface MatchResult { - /** Whether the name is a candidate suggestion */ - match: boolean; - /** 0 = prefix match (strongest), 1 = substring match (weaker) */ - rank: number; + /** Whether the name is a candidate suggestion */ + match: boolean; + /** 0 = prefix match (strongest), 1 = substring match (weaker) */ + rank: number; } /** @@ -73,59 +73,59 @@ interface MatchResult { * using consonant-skeleton comparison. Prefix beats substring. */ function matchesQuery(name: string, query: string): MatchResult { - const nname = skeleton(name); - const nquery = skeleton(query); - if (nquery.length === 0) return { match: false, rank: 99 }; - // Don't suggest the exact same name the writer has already fully typed - if (nname === nquery) return { match: false, rank: 99 }; - if (nname.startsWith(nquery)) return { match: true, rank: 0 }; - if (nname.includes(nquery)) return { match: true, rank: 1 }; - return { match: false, rank: 99 }; + const nname = skeleton(name); + const nquery = skeleton(query); + if (nquery.length === 0) return { match: false, rank: 99 }; + // Don't suggest the exact same name the writer has already fully typed + if (nname === nquery) return { match: false, rank: 99 }; + if (nname.startsWith(nquery)) return { match: true, rank: 0 }; + if (nname.includes(nquery)) return { match: true, rank: 1 }; + return { match: false, rank: 99 }; } /** Compute the autocomplete state from the current editor state */ function computeState(editorState: EditorState): AutocompleteState { - const inactive: AutocompleteState = { - active: false, - query: '', - suggestions: [], - selectedIndex: 0, - }; - - const { selection } = editorState; - const $from = selection.$from; - const parentType = $from.parent.type.name; - - // Only activate inside character elements - if (parentType !== 'character') return inactive; - - const query = $from.parent.textContent; - - // Need at least 2 skeletal chars to trigger. A single Malayalam - // consonant ("ക്") skeletonizes to 1 char — too broad to suggest on. - const querySkeleton = skeleton(query); - if (querySkeleton.length < 2) return inactive; - - const allNames = collectCharacterNames(editorState); - const scored = allNames - .map((name) => ({ name, result: matchesQuery(name, query) })) - .filter((entry) => entry.result.match); - - // Sort: prefix matches first, then substring matches; alphabetically within each rank. - scored.sort((a, b) => { - if (a.result.rank !== b.result.rank) return a.result.rank - b.result.rank; - return a.name.localeCompare(b.name); - }); - - const suggestions = scored.map((entry) => entry.name); - if (suggestions.length === 0) return inactive; - - return { - active: true, - query, - suggestions, - selectedIndex: 0, - }; + const inactive: AutocompleteState = { + active: false, + query: '', + suggestions: [], + selectedIndex: 0 + }; + + const { selection } = editorState; + const $from = selection.$from; + const parentType = $from.parent.type.name; + + // Only activate inside character elements + if (parentType !== 'character') return inactive; + + const query = $from.parent.textContent; + + // Need at least 2 skeletal chars to trigger. A single Malayalam + // consonant ("ക്") skeletonizes to 1 char — too broad to suggest on. + const querySkeleton = skeleton(query); + if (querySkeleton.length < 2) return inactive; + + const allNames = collectCharacterNames(editorState); + const scored = allNames + .map((name) => ({ name, result: matchesQuery(name, query) })) + .filter((entry) => entry.result.match); + + // Sort: prefix matches first, then substring matches; alphabetically within each rank. + scored.sort((a, b) => { + if (a.result.rank !== b.result.rank) return a.result.rank - b.result.rank; + return a.name.localeCompare(b.name); + }); + + const suggestions = scored.map((entry) => entry.name); + if (suggestions.length === 0) return inactive; + + return { + active: true, + query, + suggestions, + selectedIndex: 0 + }; } /** @@ -134,44 +134,44 @@ function computeState(editorState: EditorState): AutocompleteState { * relative to the editor container. */ function createDropdown(): HTMLUListElement { - const ul = document.createElement('ul'); - ul.className = 'character-autocomplete'; - ul.style.display = 'none'; - return ul; + const ul = document.createElement('ul'); + ul.className = 'character-autocomplete'; + ul.style.display = 'none'; + return ul; } /** Render the suggestion list into the dropdown element */ function renderDropdown( - dropdown: HTMLUListElement, - state: AutocompleteState, - view: EditorView + dropdown: HTMLUListElement, + state: AutocompleteState, + view: EditorView ): void { - if (!state.active) { - dropdown.style.display = 'none'; - return; - } - - // Build list items - dropdown.innerHTML = ''; - state.suggestions.forEach((name, i) => { - const li = document.createElement('li'); - li.textContent = name; - li.className = 'autocomplete-item'; - if (i === state.selectedIndex) { - li.classList.add('selected'); - } - // Click to select - li.addEventListener('mousedown', (e) => { - // Prevent the editor from losing focus - e.preventDefault(); - acceptSuggestion(view, name); - }); - dropdown.appendChild(li); - }); - - // Position the dropdown below the current character element - positionDropdown(dropdown, view); - dropdown.style.display = 'block'; + if (!state.active) { + dropdown.style.display = 'none'; + return; + } + + // Build list items + dropdown.innerHTML = ''; + state.suggestions.forEach((name, i) => { + const li = document.createElement('li'); + li.textContent = name; + li.className = 'autocomplete-item'; + if (i === state.selectedIndex) { + li.classList.add('selected'); + } + // Click to select + li.addEventListener('mousedown', (e) => { + // Prevent the editor from losing focus + e.preventDefault(); + acceptSuggestion(view, name); + }); + dropdown.appendChild(li); + }); + + // Position the dropdown below the current character element + positionDropdown(dropdown, view); + dropdown.style.display = 'block'; } /** @@ -183,45 +183,45 @@ function renderDropdown( * scroll container's visible viewport — if not, flip it above the cursor. */ function positionDropdown(dropdown: HTMLUListElement, view: EditorView): void { - const { from } = view.state.selection; - const coords = view.coordsAtPos(from); + const { from } = view.state.selection; + const coords = view.coordsAtPos(from); - const editorRect = view.dom.getBoundingClientRect(); + const editorRect = view.dom.getBoundingClientRect(); - // The scroll container is `.editor-scroll` — the parent of `.editor-container`. - // It defines the visible viewport for the editor content. - const scrollContainer = view.dom.parentElement?.parentElement; - const scrollRect = scrollContainer?.getBoundingClientRect(); + // The scroll container is `.editor-scroll` — the parent of `.editor-container`. + // It defines the visible viewport for the editor content. + const scrollContainer = view.dom.parentElement?.parentElement; + const scrollRect = scrollContainer?.getBoundingClientRect(); - // Left position relative to the ProseMirror editor DOM - const left = coords.left - editorRect.left; + // Left position relative to the ProseMirror editor DOM + const left = coords.left - editorRect.left; - // Temporarily make dropdown visible but off-screen so we can measure its height - dropdown.style.visibility = 'hidden'; - dropdown.style.display = 'block'; - const dropdownHeight = dropdown.offsetHeight; - dropdown.style.visibility = ''; + // Temporarily make dropdown visible but off-screen so we can measure its height + dropdown.style.visibility = 'hidden'; + dropdown.style.display = 'block'; + const dropdownHeight = dropdown.offsetHeight; + dropdown.style.visibility = ''; - // Gap between the cursor line and the dropdown - const gap = 4; + // Gap between the cursor line and the dropdown + const gap = 4; - // Default: place below the cursor line - let top = coords.bottom - editorRect.top + gap; + // Default: place below the cursor line + let top = coords.bottom - editorRect.top + gap; - // Check if the dropdown would overflow the scroll container's visible area. - // If there isn't enough room below, flip it above the cursor. - if (scrollRect) { - const spaceBelow = scrollRect.bottom - coords.bottom; - const spaceAbove = coords.top - scrollRect.top; + // Check if the dropdown would overflow the scroll container's visible area. + // If there isn't enough room below, flip it above the cursor. + if (scrollRect) { + const spaceBelow = scrollRect.bottom - coords.bottom; + const spaceAbove = coords.top - scrollRect.top; - if (spaceBelow < dropdownHeight + gap && spaceAbove > dropdownHeight + gap) { - // Flip above: position the dropdown's bottom edge at the cursor's top - top = coords.top - editorRect.top - dropdownHeight - gap; - } - } + if (spaceBelow < dropdownHeight + gap && spaceAbove > dropdownHeight + gap) { + // Flip above: position the dropdown's bottom edge at the cursor's top + top = coords.top - editorRect.top - dropdownHeight - gap; + } + } - dropdown.style.left = `${left}px`; - dropdown.style.top = `${top}px`; + dropdown.style.left = `${left}px`; + dropdown.style.top = `${top}px`; } /** @@ -230,35 +230,31 @@ function positionDropdown(dropdown: HTMLUListElement, view: EditorView): void { * the cursor into it (same as pressing Enter on a Character element). */ function acceptSuggestion(view: EditorView, name: string): void { - const state = view.state; - const $from = state.selection.$from; - - // Replace the entire content of the character node with the selected name - const nodeStart = $from.start(); // start of the character node's content - const nodeEnd = $from.end(); // end of the character node's content - - let tr = state.tr.replaceWith( - nodeStart, - nodeEnd, - state.schema.text(name) - ); - - // Now create a Dialogue element below, mimicking Enter behavior on Character. - // After replacing text, the character node's end position has shifted. - // Recalculate: the character node's outer end is nodeStart - 1 (before) + node size. - // Simpler: resolve the new position after replacement and find .after(). - const newState = view.state.apply(tr); - const $newFrom = newState.selection.$from; - const afterCharacter = $newFrom.after(); // position right after the character node - - const dialogueNode = screenplaySchema.nodes.dialogue.create(); - tr = tr.insert(afterCharacter, dialogueNode); - // Move cursor inside the new dialogue node (afterCharacter + 1 for opening tag) - tr = tr.setSelection(TextSelection.create(tr.doc, afterCharacter + 1)); - tr.scrollIntoView(); - - view.dispatch(tr); - view.focus(); + const state = view.state; + const $from = state.selection.$from; + + // Replace the entire content of the character node with the selected name + const nodeStart = $from.start(); // start of the character node's content + const nodeEnd = $from.end(); // end of the character node's content + + let tr = state.tr.replaceWith(nodeStart, nodeEnd, state.schema.text(name)); + + // Now create a Dialogue element below, mimicking Enter behavior on Character. + // After replacing text, the character node's end position has shifted. + // Recalculate: the character node's outer end is nodeStart - 1 (before) + node size. + // Simpler: resolve the new position after replacement and find .after(). + const newState = view.state.apply(tr); + const $newFrom = newState.selection.$from; + const afterCharacter = $newFrom.after(); // position right after the character node + + const dialogueNode = screenplaySchema.nodes.dialogue.create(); + tr = tr.insert(afterCharacter, dialogueNode); + // Move cursor inside the new dialogue node (afterCharacter + 1 for opening tag) + tr = tr.setSelection(TextSelection.create(tr.doc, afterCharacter + 1)); + tr.scrollIntoView(); + + view.dispatch(tr); + view.focus(); } /** @@ -268,88 +264,87 @@ function acceptSuggestion(view: EditorView, name: string): void { * interception via handleKeyDown. */ export const characterAutocompletePlugin = new Plugin({ - key: autocompleteKey, - - state: { - init(_, state) { - return computeState(state); - }, - apply(_tr, _prevPluginState, _oldState, newState) { - return computeState(newState); - }, - }, - - props: { - // Intercept keys when the autocomplete dropdown is visible - handleKeyDown(view, event) { - const pluginState = autocompleteKey.getState(view.state); - if (!pluginState?.active) return false; - - switch (event.key) { - case 'ArrowDown': { - event.preventDefault(); - // Move selection down, wrapping around - const next = - (pluginState.selectedIndex + 1) % pluginState.suggestions.length; - updateSelectedIndex(view, next); - return true; - } - - case 'ArrowUp': { - event.preventDefault(); - // Move selection up, wrapping around - const prev = - (pluginState.selectedIndex - 1 + pluginState.suggestions.length) % - pluginState.suggestions.length; - updateSelectedIndex(view, prev); - return true; - } - - case 'Enter': - case 'Tab': { - event.preventDefault(); - const name = pluginState.suggestions[pluginState.selectedIndex]; - acceptSuggestion(view, name); - return true; - } - - case 'Escape': { - event.preventDefault(); - // Dismiss — the plugin state will recalculate on next update, - // but we force-hide the dropdown immediately via the view - const dropdown = findDropdown(view); - if (dropdown) dropdown.style.display = 'none'; - // Move cursor to end of current text to "commit" and stop matching - return true; - } - } - - return false; - }, - }, - - view(editorView) { - const dropdown = createDropdown(); - // Append to the ProseMirror editor DOM so positioning works relative to it - editorView.dom.parentElement?.appendChild(dropdown); - - return { - update(view) { - const pluginState = autocompleteKey.getState(view.state); - if (pluginState) { - renderDropdown(dropdown, pluginState, view); - } - }, - destroy() { - dropdown.remove(); - }, - }; - }, + key: autocompleteKey, + + state: { + init(_, state) { + return computeState(state); + }, + apply(_tr, _prevPluginState, _oldState, newState) { + return computeState(newState); + } + }, + + props: { + // Intercept keys when the autocomplete dropdown is visible + handleKeyDown(view, event) { + const pluginState = autocompleteKey.getState(view.state); + if (!pluginState?.active) return false; + + switch (event.key) { + case 'ArrowDown': { + event.preventDefault(); + // Move selection down, wrapping around + const next = (pluginState.selectedIndex + 1) % pluginState.suggestions.length; + updateSelectedIndex(view, next); + return true; + } + + case 'ArrowUp': { + event.preventDefault(); + // Move selection up, wrapping around + const prev = + (pluginState.selectedIndex - 1 + pluginState.suggestions.length) % + pluginState.suggestions.length; + updateSelectedIndex(view, prev); + return true; + } + + case 'Enter': + case 'Tab': { + event.preventDefault(); + const name = pluginState.suggestions[pluginState.selectedIndex]; + acceptSuggestion(view, name); + return true; + } + + case 'Escape': { + event.preventDefault(); + // Dismiss — the plugin state will recalculate on next update, + // but we force-hide the dropdown immediately via the view + const dropdown = findDropdown(view); + if (dropdown) dropdown.style.display = 'none'; + // Move cursor to end of current text to "commit" and stop matching + return true; + } + } + + return false; + } + }, + + view(editorView) { + const dropdown = createDropdown(); + // Append to the ProseMirror editor DOM so positioning works relative to it + editorView.dom.parentElement?.appendChild(dropdown); + + return { + update(view) { + const pluginState = autocompleteKey.getState(view.state); + if (pluginState) { + renderDropdown(dropdown, pluginState, view); + } + }, + destroy() { + dropdown.remove(); + } + }; + } }); /** Find the dropdown element for a given editor view */ function findDropdown(view: EditorView): HTMLUListElement | null { - return view.dom.parentElement?.querySelector('.character-autocomplete') ?? null; + return view.dom.parentElement?.querySelector('.character-autocomplete') ?? null; } /** @@ -358,20 +353,20 @@ function findDropdown(view: EditorView): HTMLUListElement | null { * because the selection index is a UI concern, not document state. */ function updateSelectedIndex(view: EditorView, newIndex: number): void { - const dropdown = findDropdown(view); - if (!dropdown) return; - - const pluginState = autocompleteKey.getState(view.state); - if (!pluginState) return; - - // Update the visual selection - const items = dropdown.querySelectorAll('.autocomplete-item'); - items.forEach((item, i) => { - item.classList.toggle('selected', i === newIndex); - }); - - // Store the new index — we mutate the plugin state object directly here. - // This is safe because the state will be fully recomputed on the next - // transaction anyway. - pluginState.selectedIndex = newIndex; + const dropdown = findDropdown(view); + if (!dropdown) return; + + const pluginState = autocompleteKey.getState(view.state); + if (!pluginState) return; + + // Update the visual selection + const items = dropdown.querySelectorAll('.autocomplete-item'); + items.forEach((item, i) => { + item.classList.toggle('selected', i === newIndex); + }); + + // Store the new index — we mutate the plugin state object directly here. + // This is safe because the state will be fully recomputed on the next + // transaction anyway. + pluginState.selectedIndex = newIndex; } diff --git a/src/lib/editor/findReplace.ts b/src/lib/editor/findReplace.ts index 84e264e..65e500d 100644 --- a/src/lib/editor/findReplace.ts +++ b/src/lib/editor/findReplace.ts @@ -10,347 +10,362 @@ import type { Node as ProseMirrorNode } from 'prosemirror-model'; /** A match position in the document */ export interface Match { - from: number; - to: number; + from: number; + to: number; } /** Plugin state for find/replace */ export interface FindReplaceState { - query: string; - caseSensitive: boolean; - matches: Match[]; - currentIndex: number; // -1 when no matches - decorations: DecorationSet; + query: string; + caseSensitive: boolean; + matches: Match[]; + currentIndex: number; // -1 when no matches + decorations: DecorationSet; } /** Actions dispatched via transaction metadata */ type FindReplaceAction = - | { type: 'search'; query: string; caseSensitive: boolean } - | { type: 'next' } - | { type: 'prev' } - | { type: 'setCurrent'; index: number } - | { type: 'clear' }; + | { type: 'search'; query: string; caseSensitive: boolean } + | { type: 'next' } + | { type: 'prev' } + | { type: 'setCurrent'; index: number } + | { type: 'clear' }; export const findReplaceKey = new PluginKey('findReplace'); /** Find all occurrences of `query` in the document */ function findMatches(doc: ProseMirrorNode, query: string, caseSensitive: boolean): Match[] { - if (!query) return []; + if (!query) return []; - const matches: Match[] = []; - const searchQuery = caseSensitive ? query : query.toLowerCase(); + const matches: Match[] = []; + const searchQuery = caseSensitive ? query : query.toLowerCase(); - // Walk every text node in the document - doc.descendants((node, pos) => { - if (!node.isText || !node.text) return; + // Walk every text node in the document + doc.descendants((node, pos) => { + if (!node.isText || !node.text) return; - const text = caseSensitive ? node.text : node.text.toLowerCase(); - let startIndex = 0; + const text = caseSensitive ? node.text : node.text.toLowerCase(); + let startIndex = 0; - // Find all occurrences within this text node - while (startIndex < text.length) { - const idx = text.indexOf(searchQuery, startIndex); - if (idx === -1) break; + // Find all occurrences within this text node + while (startIndex < text.length) { + const idx = text.indexOf(searchQuery, startIndex); + if (idx === -1) break; - matches.push({ - from: pos + idx, - to: pos + idx + query.length, - }); - // Advance by 1 to find overlapping matches - startIndex = idx + 1; - } - }); + matches.push({ + from: pos + idx, + to: pos + idx + query.length + }); + // Advance by 1 to find overlapping matches + startIndex = idx + 1; + } + }); - return matches; + return matches; } /** Build a DecorationSet from matches, highlighting the current one distinctly */ function buildDecorations( - doc: ProseMirrorNode, - matches: Match[], - currentIndex: number + doc: ProseMirrorNode, + matches: Match[], + currentIndex: number ): DecorationSet { - if (matches.length === 0) return DecorationSet.empty; + if (matches.length === 0) return DecorationSet.empty; - const decorations = matches.map((m, i) => { - const className = i === currentIndex ? 'find-match find-match-current' : 'find-match'; - return Decoration.inline(m.from, m.to, { class: className }); - }); + const decorations = matches.map((m, i) => { + const className = i === currentIndex ? 'find-match find-match-current' : 'find-match'; + return Decoration.inline(m.from, m.to, { class: className }); + }); - return DecorationSet.create(doc, decorations); + return DecorationSet.create(doc, decorations); } /** Empty plugin state */ function emptyState(doc: ProseMirrorNode): FindReplaceState { - return { - query: '', - caseSensitive: false, - matches: [], - currentIndex: -1, - decorations: DecorationSet.empty, - }; + return { + query: '', + caseSensitive: false, + matches: [], + currentIndex: -1, + decorations: DecorationSet.empty + }; } /** The find/replace ProseMirror plugin */ export const findReplacePlugin = new Plugin({ - key: findReplaceKey, - - state: { - init(_, state) { - return emptyState(state.doc); - }, - - apply(tr: Transaction, prev: FindReplaceState, _oldState: EditorState, newState: EditorState): FindReplaceState { - const action = tr.getMeta(findReplaceKey) as FindReplaceAction | undefined; - - if (action) { - switch (action.type) { - case 'search': { - const matches = findMatches(newState.doc, action.query, action.caseSensitive); - const currentIndex = matches.length > 0 ? 0 : -1; - return { - query: action.query, - caseSensitive: action.caseSensitive, - matches, - currentIndex, - decorations: buildDecorations(newState.doc, matches, currentIndex), - }; - } - - case 'next': { - if (prev.matches.length === 0) return prev; - const nextIndex = (prev.currentIndex + 1) % prev.matches.length; - return { - ...prev, - currentIndex: nextIndex, - decorations: buildDecorations(newState.doc, prev.matches, nextIndex), - }; - } - - case 'prev': { - if (prev.matches.length === 0) return prev; - const prevIndex = (prev.currentIndex - 1 + prev.matches.length) % prev.matches.length; - return { - ...prev, - currentIndex: prevIndex, - decorations: buildDecorations(newState.doc, prev.matches, prevIndex), - }; - } - - case 'setCurrent': { - if (action.index < 0 || action.index >= prev.matches.length) return prev; - return { - ...prev, - currentIndex: action.index, - decorations: buildDecorations(newState.doc, prev.matches, action.index), - }; - } - - case 'clear': { - return emptyState(newState.doc); - } - } - } - - // Document changed while a search is active. Map existing matches - // through the transaction (instant, O(matches)) instead of re-scanning - // the whole doc on every keystroke (was O(text-nodes) per keystroke, - // #101). The view-level debounced rescan below catches up with any - // newly typed text after ~200ms of idle so brand-new matches still - // appear — just not on the keystroke that added them. - if (tr.docChanged && prev.query) { - const remapped: Match[] = []; - for (const m of prev.matches) { - const from = tr.mapping.map(m.from, 1); - const to = tr.mapping.map(m.to, -1); - // Match was deleted or its range collapsed — drop it. - if (to <= from) continue; - // Length changed (text inserted into the match) — drop too, - // the rescan will recover it with the new shape. - if (to - from !== prev.query.length) continue; - remapped.push({ from, to }); - } - const currentIndex = remapped.length === 0 - ? -1 - : Math.min(Math.max(prev.currentIndex, 0), remapped.length - 1); - return { - ...prev, - matches: remapped, - currentIndex, - decorations: prev.decorations.map(tr.mapping, newState.doc), - }; - } - - return prev; - }, - }, - - props: { - decorations(state) { - const pluginState = findReplaceKey.getState(state); - return pluginState?.decorations ?? DecorationSet.empty; - }, - }, - - // Debounced re-scan after the user stops typing. Existing matches are - // already remapped synchronously in apply() above, so highlights stay - // visible during typing; this view plugin picks up newly typed-in - // matches once the doc settles (~200ms idle) (#101). - view() { - let rescanTimer: ReturnType | null = null; - return { - update(view, prevState) { - const state = findReplaceKey.getState(view.state); - if (!state || !state.query) { - if (rescanTimer) { - clearTimeout(rescanTimer); - rescanTimer = null; - } - return; - } - // Only debounce if the doc actually changed. - if (view.state.doc === prevState.doc) return; - if (rescanTimer) clearTimeout(rescanTimer); - rescanTimer = setTimeout(() => { - rescanTimer = null; - const cur = findReplaceKey.getState(view.state); - if (!cur || !cur.query) return; - // Re-trigger the search action so apply() recomputes from scratch. - // Preserve the user's current match index when possible. - const prevIndex = cur.currentIndex; - view.dispatch( - view.state.tr.setMeta(findReplaceKey, { - type: 'search', - query: cur.query, - caseSensitive: cur.caseSensitive, - } as FindReplaceAction) - ); - const after = findReplaceKey.getState(view.state); - if (after && after.matches.length > 0 && prevIndex >= 0 && prevIndex < after.matches.length) { - view.dispatch( - view.state.tr.setMeta(findReplaceKey, { - type: 'setCurrent', - index: prevIndex, - } as FindReplaceAction) - ); - } - }, 200); - }, - destroy() { - if (rescanTimer) clearTimeout(rescanTimer); - }, - }; - }, + key: findReplaceKey, + + state: { + init(_, state) { + return emptyState(state.doc); + }, + + apply( + tr: Transaction, + prev: FindReplaceState, + _oldState: EditorState, + newState: EditorState + ): FindReplaceState { + const action = tr.getMeta(findReplaceKey) as FindReplaceAction | undefined; + + if (action) { + switch (action.type) { + case 'search': { + const matches = findMatches(newState.doc, action.query, action.caseSensitive); + const currentIndex = matches.length > 0 ? 0 : -1; + return { + query: action.query, + caseSensitive: action.caseSensitive, + matches, + currentIndex, + decorations: buildDecorations(newState.doc, matches, currentIndex) + }; + } + + case 'next': { + if (prev.matches.length === 0) return prev; + const nextIndex = (prev.currentIndex + 1) % prev.matches.length; + return { + ...prev, + currentIndex: nextIndex, + decorations: buildDecorations(newState.doc, prev.matches, nextIndex) + }; + } + + case 'prev': { + if (prev.matches.length === 0) return prev; + const prevIndex = (prev.currentIndex - 1 + prev.matches.length) % prev.matches.length; + return { + ...prev, + currentIndex: prevIndex, + decorations: buildDecorations(newState.doc, prev.matches, prevIndex) + }; + } + + case 'setCurrent': { + if (action.index < 0 || action.index >= prev.matches.length) return prev; + return { + ...prev, + currentIndex: action.index, + decorations: buildDecorations(newState.doc, prev.matches, action.index) + }; + } + + case 'clear': { + return emptyState(newState.doc); + } + } + } + + // Document changed while a search is active. Map existing matches + // through the transaction (instant, O(matches)) instead of re-scanning + // the whole doc on every keystroke (was O(text-nodes) per keystroke, + // #101). The view-level debounced rescan below catches up with any + // newly typed text after ~200ms of idle so brand-new matches still + // appear — just not on the keystroke that added them. + if (tr.docChanged && prev.query) { + const remapped: Match[] = []; + for (const m of prev.matches) { + const from = tr.mapping.map(m.from, 1); + const to = tr.mapping.map(m.to, -1); + // Match was deleted or its range collapsed — drop it. + if (to <= from) continue; + // Length changed (text inserted into the match) — drop too, + // the rescan will recover it with the new shape. + if (to - from !== prev.query.length) continue; + remapped.push({ from, to }); + } + const currentIndex = + remapped.length === 0 + ? -1 + : Math.min(Math.max(prev.currentIndex, 0), remapped.length - 1); + return { + ...prev, + matches: remapped, + currentIndex, + decorations: prev.decorations.map(tr.mapping, newState.doc) + }; + } + + return prev; + } + }, + + props: { + decorations(state) { + const pluginState = findReplaceKey.getState(state); + return pluginState?.decorations ?? DecorationSet.empty; + } + }, + + // Debounced re-scan after the user stops typing. Existing matches are + // already remapped synchronously in apply() above, so highlights stay + // visible during typing; this view plugin picks up newly typed-in + // matches once the doc settles (~200ms idle) (#101). + view() { + let rescanTimer: ReturnType | null = null; + return { + update(view, prevState) { + const state = findReplaceKey.getState(view.state); + if (!state || !state.query) { + if (rescanTimer) { + clearTimeout(rescanTimer); + rescanTimer = null; + } + return; + } + // Only debounce if the doc actually changed. + if (view.state.doc === prevState.doc) return; + if (rescanTimer) clearTimeout(rescanTimer); + rescanTimer = setTimeout(() => { + rescanTimer = null; + const cur = findReplaceKey.getState(view.state); + if (!cur || !cur.query) return; + // Re-trigger the search action so apply() recomputes from scratch. + // Preserve the user's current match index when possible. + const prevIndex = cur.currentIndex; + view.dispatch( + view.state.tr.setMeta(findReplaceKey, { + type: 'search', + query: cur.query, + caseSensitive: cur.caseSensitive + } as FindReplaceAction) + ); + const after = findReplaceKey.getState(view.state); + if ( + after && + after.matches.length > 0 && + prevIndex >= 0 && + prevIndex < after.matches.length + ) { + view.dispatch( + view.state.tr.setMeta(findReplaceKey, { + type: 'setCurrent', + index: prevIndex + } as FindReplaceAction) + ); + } + }, 200); + }, + destroy() { + if (rescanTimer) clearTimeout(rescanTimer); + } + }; + } }); // --- Dispatch helpers called by the FindReplaceBar component --- /** Dispatch a search action and return the resulting plugin state */ export function dispatchSearch( - view: EditorView, - query: string, - caseSensitive: boolean + view: EditorView, + query: string, + caseSensitive: boolean ): FindReplaceState | undefined { - const tr = view.state.tr.setMeta(findReplaceKey, { - type: 'search', - query, - caseSensitive, - } as FindReplaceAction); - view.dispatch(tr); - return findReplaceKey.getState(view.state); + const tr = view.state.tr.setMeta(findReplaceKey, { + type: 'search', + query, + caseSensitive + } as FindReplaceAction); + view.dispatch(tr); + return findReplaceKey.getState(view.state); } /** Move to the next match (wraps around) */ export function dispatchNext(view: EditorView): FindReplaceState | undefined { - const tr = view.state.tr.setMeta(findReplaceKey, { type: 'next' } as FindReplaceAction); - view.dispatch(tr); - return findReplaceKey.getState(view.state); + const tr = view.state.tr.setMeta(findReplaceKey, { type: 'next' } as FindReplaceAction); + view.dispatch(tr); + return findReplaceKey.getState(view.state); } /** Move to the previous match (wraps around) */ export function dispatchPrev(view: EditorView): FindReplaceState | undefined { - const tr = view.state.tr.setMeta(findReplaceKey, { type: 'prev' } as FindReplaceAction); - view.dispatch(tr); - return findReplaceKey.getState(view.state); + const tr = view.state.tr.setMeta(findReplaceKey, { type: 'prev' } as FindReplaceAction); + view.dispatch(tr); + return findReplaceKey.getState(view.state); } /** Clear all highlights and reset state */ export function dispatchClear(view: EditorView): void { - const tr = view.state.tr.setMeta(findReplaceKey, { type: 'clear' } as FindReplaceAction); - view.dispatch(tr); + const tr = view.state.tr.setMeta(findReplaceKey, { type: 'clear' } as FindReplaceAction); + view.dispatch(tr); } /** Replace the current match with `replacement` text and re-search */ export function replaceCurrentMatch( - view: EditorView, - replacement: string + view: EditorView, + replacement: string ): FindReplaceState | undefined { - const pluginState = findReplaceKey.getState(view.state); - if (!pluginState || pluginState.currentIndex < 0) return pluginState; - - const match = pluginState.matches[pluginState.currentIndex]; - - // Replace the matched text - let tr = view.state.tr.insertText(replacement, match.from, match.to); - - // Re-search after replacing — set meta on the same transaction - tr = tr.setMeta(findReplaceKey, { - type: 'search', - query: pluginState.query, - caseSensitive: pluginState.caseSensitive, - } as FindReplaceAction); - - view.dispatch(tr); - - // After re-search, adjust currentIndex to stay at the same position - // (the match that was at currentIndex is now gone, so the next match - // naturally falls into the same index position) - const newState = findReplaceKey.getState(view.state); - if (newState && newState.matches.length > 0 && pluginState.currentIndex < newState.matches.length) { - // Set currentIndex to keep the user at the same position - const setCurrent = view.state.tr.setMeta(findReplaceKey, { - type: 'setCurrent', - index: Math.min(pluginState.currentIndex, newState.matches.length - 1), - } as FindReplaceAction); - view.dispatch(setCurrent); - } - - return findReplaceKey.getState(view.state); + const pluginState = findReplaceKey.getState(view.state); + if (!pluginState || pluginState.currentIndex < 0) return pluginState; + + const match = pluginState.matches[pluginState.currentIndex]; + + // Replace the matched text + let tr = view.state.tr.insertText(replacement, match.from, match.to); + + // Re-search after replacing — set meta on the same transaction + tr = tr.setMeta(findReplaceKey, { + type: 'search', + query: pluginState.query, + caseSensitive: pluginState.caseSensitive + } as FindReplaceAction); + + view.dispatch(tr); + + // After re-search, adjust currentIndex to stay at the same position + // (the match that was at currentIndex is now gone, so the next match + // naturally falls into the same index position) + const newState = findReplaceKey.getState(view.state); + if ( + newState && + newState.matches.length > 0 && + pluginState.currentIndex < newState.matches.length + ) { + // Set currentIndex to keep the user at the same position + const setCurrent = view.state.tr.setMeta(findReplaceKey, { + type: 'setCurrent', + index: Math.min(pluginState.currentIndex, newState.matches.length - 1) + } as FindReplaceAction); + view.dispatch(setCurrent); + } + + return findReplaceKey.getState(view.state); } /** Replace all matches in a single undo step */ export function replaceAllMatches( - view: EditorView, - replacement: string + view: EditorView, + replacement: string ): FindReplaceState | undefined { - const pluginState = findReplaceKey.getState(view.state); - if (!pluginState || pluginState.matches.length === 0) return pluginState; - - // Replace in reverse order to preserve earlier positions - let tr = view.state.tr; - for (let i = pluginState.matches.length - 1; i >= 0; i--) { - const match = pluginState.matches[i]; - tr = tr.insertText(replacement, match.from, match.to); - } - - // Re-search on the same transaction (will find 0 matches since all were replaced) - tr = tr.setMeta(findReplaceKey, { - type: 'search', - query: pluginState.query, - caseSensitive: pluginState.caseSensitive, - } as FindReplaceAction); - - view.dispatch(tr); - return findReplaceKey.getState(view.state); + const pluginState = findReplaceKey.getState(view.state); + if (!pluginState || pluginState.matches.length === 0) return pluginState; + + // Replace in reverse order to preserve earlier positions + let tr = view.state.tr; + for (let i = pluginState.matches.length - 1; i >= 0; i--) { + const match = pluginState.matches[i]; + tr = tr.insertText(replacement, match.from, match.to); + } + + // Re-search on the same transaction (will find 0 matches since all were replaced) + tr = tr.setMeta(findReplaceKey, { + type: 'search', + query: pluginState.query, + caseSensitive: pluginState.caseSensitive + } as FindReplaceAction); + + view.dispatch(tr); + return findReplaceKey.getState(view.state); } /** Scroll the current match into view by querying the DOM for the highlight */ export function scrollToCurrentMatch(view: EditorView): void { - // Use requestAnimationFrame to wait for decorations to render - requestAnimationFrame(() => { - const el = view.dom.querySelector('.find-match-current'); - if (el) { - el.scrollIntoView({ block: 'center', behavior: 'smooth' }); - } - }); + // Use requestAnimationFrame to wait for decorations to render + requestAnimationFrame(() => { + const el = view.dom.querySelector('.find-match-current'); + if (el) { + el.scrollIntoView({ block: 'center', behavior: 'smooth' }); + } + }); } diff --git a/src/lib/editor/input/InputModeManager.ts b/src/lib/editor/input/InputModeManager.ts index bf67e04..7794e49 100644 --- a/src/lib/editor/input/InputModeManager.ts +++ b/src/lib/editor/input/InputModeManager.ts @@ -17,100 +17,100 @@ export type InputScheme = 'mozhi' | 'inscript1' | 'inscript2'; * Controls whether we're typing in English or Malayalam, and which input scheme is active. */ export class InputModeManager { - // "static" means this property belongs to the class itself, not to instances. - // We use it to store the single shared instance (singleton pattern). - private static instance: InputModeManager; - - /** Whether Malayalam input is currently active (false = English passthrough) */ - isMalayalam: boolean = false; - - /** The currently selected Malayalam input scheme */ - scheme: InputScheme = 'mozhi'; - - /** The Mozhi transliteration engine instance — stateful, tracks recent output */ - private mozhiEngine: MozhiEngine; - - // Subscribers notified whenever isMalayalam or scheme changes. Lets the - // StatusBar (and anything else showing mode) stay in sync without polling. - private listeners: Set<() => void> = new Set(); - - // "private constructor" prevents anyone from calling `new InputModeManager()` directly. - // They must use getInstance() instead, ensuring only one instance ever exists. - private constructor() { - this.mozhiEngine = new MozhiEngine(); - } - - /** Register a change listener. Returns a function that removes it. */ - subscribe(fn: () => void): () => void { - this.listeners.add(fn); - return () => this.listeners.delete(fn); - } - - private notify(): void { - for (const fn of this.listeners) fn(); - } - - /** Get the singleton instance */ - static getInstance(): InputModeManager { - if (!InputModeManager.instance) { - InputModeManager.instance = new InputModeManager(); - } - return InputModeManager.instance; - } - - /** Toggle between English and Malayalam input modes. Returns the new state. */ - toggle(): boolean { - this.isMalayalam = !this.isMalayalam; - // Reset Mozhi buffer when toggling modes — the context is no longer valid - this.mozhiEngine.reset(); - this.notify(); - return this.isMalayalam; - } - - /** Set the active input scheme */ - setScheme(scheme: InputScheme): void { - this.scheme = scheme; - // Reset Mozhi buffer when switching schemes - this.mozhiEngine.reset(); - this.notify(); - } - - /** - * Reset the Mozhi engine's internal buffer. - * Call this on word boundaries (space, enter) and cursor movements - * so the engine doesn't try to combine across word boundaries. - */ - resetMozhi(): void { - this.mozhiEngine.reset(); - } - - /** - * Process a keypress through the active input scheme. - * Returns an InputResult with the text to insert and how many characters to delete, - * or null if the key should pass through unchanged. - */ - processKey(key: string): InputResult | null { - // If we're in English mode, don't transform anything - if (!this.isMalayalam) { - return null; - } - - // Delegate to the correct scheme handler - switch (this.scheme) { - case 'inscript1': { - // Inscript schemes are stateless 1:1 keymaps — wrap result in InputResult - const ch = processInscript1Key(key); - return ch ? { text: ch, deleteBack: 0 } : null; - } - case 'inscript2': { - const ch = processInscript2Key(key); - return ch ? { text: ch, deleteBack: 0 } : null; - } - case 'mozhi': - // Mozhi is stateful — delegate to the engine which tracks context - return this.mozhiEngine.processKey(key); - default: - return null; - } - } + // "static" means this property belongs to the class itself, not to instances. + // We use it to store the single shared instance (singleton pattern). + private static instance: InputModeManager; + + /** Whether Malayalam input is currently active (false = English passthrough) */ + isMalayalam: boolean = false; + + /** The currently selected Malayalam input scheme */ + scheme: InputScheme = 'mozhi'; + + /** The Mozhi transliteration engine instance — stateful, tracks recent output */ + private mozhiEngine: MozhiEngine; + + // Subscribers notified whenever isMalayalam or scheme changes. Lets the + // StatusBar (and anything else showing mode) stay in sync without polling. + private listeners: Set<() => void> = new Set(); + + // "private constructor" prevents anyone from calling `new InputModeManager()` directly. + // They must use getInstance() instead, ensuring only one instance ever exists. + private constructor() { + this.mozhiEngine = new MozhiEngine(); + } + + /** Register a change listener. Returns a function that removes it. */ + subscribe(fn: () => void): () => void { + this.listeners.add(fn); + return () => this.listeners.delete(fn); + } + + private notify(): void { + for (const fn of this.listeners) fn(); + } + + /** Get the singleton instance */ + static getInstance(): InputModeManager { + if (!InputModeManager.instance) { + InputModeManager.instance = new InputModeManager(); + } + return InputModeManager.instance; + } + + /** Toggle between English and Malayalam input modes. Returns the new state. */ + toggle(): boolean { + this.isMalayalam = !this.isMalayalam; + // Reset Mozhi buffer when toggling modes — the context is no longer valid + this.mozhiEngine.reset(); + this.notify(); + return this.isMalayalam; + } + + /** Set the active input scheme */ + setScheme(scheme: InputScheme): void { + this.scheme = scheme; + // Reset Mozhi buffer when switching schemes + this.mozhiEngine.reset(); + this.notify(); + } + + /** + * Reset the Mozhi engine's internal buffer. + * Call this on word boundaries (space, enter) and cursor movements + * so the engine doesn't try to combine across word boundaries. + */ + resetMozhi(): void { + this.mozhiEngine.reset(); + } + + /** + * Process a keypress through the active input scheme. + * Returns an InputResult with the text to insert and how many characters to delete, + * or null if the key should pass through unchanged. + */ + processKey(key: string): InputResult | null { + // If we're in English mode, don't transform anything + if (!this.isMalayalam) { + return null; + } + + // Delegate to the correct scheme handler + switch (this.scheme) { + case 'inscript1': { + // Inscript schemes are stateless 1:1 keymaps — wrap result in InputResult + const ch = processInscript1Key(key); + return ch ? { text: ch, deleteBack: 0 } : null; + } + case 'inscript2': { + const ch = processInscript2Key(key); + return ch ? { text: ch, deleteBack: 0 } : null; + } + case 'mozhi': + // Mozhi is stateful — delegate to the engine which tracks context + return this.mozhiEngine.processKey(key); + default: + return null; + } + } } diff --git a/src/lib/editor/input/inscript1.ts b/src/lib/editor/input/inscript1.ts index 735b940..23549d3 100644 --- a/src/lib/editor/input/inscript1.ts +++ b/src/lib/editor/input/inscript1.ts @@ -3,76 +3,76 @@ // It shares many mappings with Inscript 2 but has some differences. const INSCRIPT1_MAP: Record = { - // Vowels - 'D': 'അ', - 'E': 'ആ', - 'F': 'ഇ', - 'R': 'ഈ', - 'G': 'ഉ', - 'T': 'ഊ', - 'S': 'ഋ', + // Vowels + D: 'അ', + E: 'ആ', + F: 'ഇ', + R: 'ഈ', + G: 'ഉ', + T: 'ഊ', + S: 'ഋ', - // Vowel signs (matras) - 'e': 'ാ', - 'f': 'ി', - 'r': 'ീ', - 'g': 'ു', - 't': 'ൂ', - 's': 'ൃ', - 'w': 'േ', - 'W': 'ഐ', - 'a': 'ോ', - 'A': 'ഔ', - 'q': 'ൌ', - 'd': 'ൊ', - 'z': 'െ', - 'Z': 'ൈ', + // Vowel signs (matras) + e: 'ാ', + f: 'ി', + r: 'ീ', + g: 'ു', + t: 'ൂ', + s: 'ൃ', + w: 'േ', + W: 'ഐ', + a: 'ോ', + A: 'ഔ', + q: 'ൌ', + d: 'ൊ', + z: 'െ', + Z: 'ൈ', - // Consonants - 'k': 'ക', - 'K': 'ഖ', - 'i': 'ഗ', - 'I': 'ഘ', - 'U': 'ങ', - ';': 'ച', - ':': 'ഛ', - 'p': 'ജ', - 'P': 'ഝ', - '}': 'ഞ', - '\'': 'ട', - '"': 'ഠ', - '[': 'ഡ', - '{': 'ഢ', - 'C': 'ണ', - 'l': 'ത', - 'L': 'ഥ', - 'o': 'ദ', - 'O': 'ധ', - 'v': 'ന', - 'h': 'പ', - 'H': 'ഫ', - 'y': 'ബ', - 'Y': 'ഭ', - 'c': 'മ', - '/': 'യ', - 'j': 'ര', - 'n': 'ല', - 'b': 'വ', - 'M': 'ശ', - '<': 'ഷ', - 'm': 'സ', - 'u': 'ഹ', - 'N': 'ള', - '>': 'ഴ', - 'B': 'റ', + // Consonants + k: 'ക', + K: 'ഖ', + i: 'ഗ', + I: 'ഘ', + U: 'ങ', + ';': 'ച', + ':': 'ഛ', + p: 'ജ', + P: 'ഝ', + '}': 'ഞ', + "'": 'ട', + '"': 'ഠ', + '[': 'ഡ', + '{': 'ഢ', + C: 'ണ', + l: 'ത', + L: 'ഥ', + o: 'ദ', + O: 'ധ', + v: 'ന', + h: 'പ', + H: 'ഫ', + y: 'ബ', + Y: 'ഭ', + c: 'മ', + '/': 'യ', + j: 'ര', + n: 'ല', + b: 'വ', + M: 'ശ', + '<': 'ഷ', + m: 'സ', + u: 'ഹ', + N: 'ള', + '>': 'ഴ', + B: 'റ', - // Special - 'x': '്', - 'X': 'ം', - '_': 'ഃ', + // Special + x: '്', + X: 'ം', + _: 'ഃ', - // Conjunct helper - ']': '്', + // Conjunct helper + ']': '്' }; /** @@ -80,5 +80,5 @@ const INSCRIPT1_MAP: Record = { * Returns the corresponding Malayalam character, or null if the key has no mapping. */ export function processInscript1Key(key: string): string | null { - return INSCRIPT1_MAP[key] ?? null; + return INSCRIPT1_MAP[key] ?? null; } diff --git a/src/lib/editor/input/inscript2.ts b/src/lib/editor/input/inscript2.ts index 9ef47cf..10c6f96 100644 --- a/src/lib/editor/input/inscript2.ts +++ b/src/lib/editor/input/inscript2.ts @@ -3,89 +3,89 @@ // Each key on a US QWERTY keyboard maps to a specific Malayalam Unicode character. const INSCRIPT2_MAP: Record = { - // Vowels (unshifted) - 'D': 'അ', // a - 'E': 'ആ', // aa - 'F': 'ഇ', // i - 'R': 'ഈ', // ii - 'G': 'ഉ', // u - 'T': 'ഊ', // uu - 'S': 'ഋ', // ri - '+': 'ഌ', // li + // Vowels (unshifted) + D: 'അ', // a + E: 'ആ', // aa + F: 'ഇ', // i + R: 'ഈ', // ii + G: 'ഉ', // u + T: 'ഊ', // uu + S: 'ഋ', // ri + '+': 'ഌ', // li - // Vowel signs (matras) — used after consonants - 'e': 'ാ', // aa matra - 'f': 'ി', // i matra - 'r': 'ീ', // ii matra - 'g': 'ു', // u matra - 't': 'ൂ', // uu matra - 's': 'ൃ', // ri matra - 'Z': 'ൈ', // ai matra - 'w': 'േ', // ee matra - 'W': 'ഐ', // ai vowel - 'a': 'ോ', // oo matra - 'A': 'ഔ', // au vowel - 'q': 'ൌ', // au matra - 'd': 'ൊ', // o matra + // Vowel signs (matras) — used after consonants + e: 'ാ', // aa matra + f: 'ി', // i matra + r: 'ീ', // ii matra + g: 'ു', // u matra + t: 'ൂ', // uu matra + s: 'ൃ', // ri matra + Z: 'ൈ', // ai matra + w: 'േ', // ee matra + W: 'ഐ', // ai vowel + a: 'ോ', // oo matra + A: 'ഔ', // au vowel + q: 'ൌ', // au matra + d: 'ൊ', // o matra - // Consonants - 'k': 'ക', // ka - 'K': 'ഖ', // kha - 'i': 'ഗ', // ga - 'I': 'ഘ', // gha - 'U': 'ങ', // nga - ';': 'ച', // cha - ':': 'ഛ', // chha - 'p': 'ജ', // ja - 'P': 'ഝ', // jha - '}': 'ഞ', // nja - '\'': 'ട', // tta - '"': 'ഠ', // ttha - '[': 'ഡ', // dda - '{': 'ഢ', // ddha - 'C': 'ണ', // nna - 'l': 'ത', // tha - 'L': 'ഥ', // thha - 'o': 'ദ', // da - 'O': 'ധ', // dha - 'v': 'ന', // na - 'h': 'പ', // pa - 'H': 'ഫ', // pha - 'y': 'ബ', // ba - 'Y': 'ഭ', // bha - 'c': 'മ', // ma - '/': 'യ', // ya - 'j': 'ര', // ra - 'n': 'ല', // la - 'b': 'വ', // va - 'M': 'ശ', // sha - '<': 'ഷ', // ssa - 'm': 'സ', // sa - 'u': 'ഹ', // ha - 'N': 'ള', // lla - '>': 'ഴ', // zha - 'B': 'റ', // rra + // Consonants + k: 'ക', // ka + K: 'ഖ', // kha + i: 'ഗ', // ga + I: 'ഘ', // gha + U: 'ങ', // nga + ';': 'ച', // cha + ':': 'ഛ', // chha + p: 'ജ', // ja + P: 'ഝ', // jha + '}': 'ഞ', // nja + "'": 'ട', // tta + '"': 'ഠ', // ttha + '[': 'ഡ', // dda + '{': 'ഢ', // ddha + C: 'ണ', // nna + l: 'ത', // tha + L: 'ഥ', // thha + o: 'ദ', // da + O: 'ധ', // dha + v: 'ന', // na + h: 'പ', // pa + H: 'ഫ', // pha + y: 'ബ', // ba + Y: 'ഭ', // bha + c: 'മ', // ma + '/': 'യ', // ya + j: 'ര', // ra + n: 'ല', // la + b: 'വ', // va + M: 'ശ', // sha + '<': 'ഷ', // ssa + m: 'സ', // sa + u: 'ഹ', // ha + N: 'ള', // lla + '>': 'ഴ', // zha + B: 'റ', // rra - // Special characters - 'x': '്', // virama (chandrakkala) — removes inherent vowel - '_': 'ഃ', // visarga - 'X': 'ം', // anusvara - '\\': 'ർ', // chillu r + // Special characters + x: '്', // virama (chandrakkala) — removes inherent vowel + _: 'ഃ', // visarga + X: 'ം', // anusvara + '\\': 'ർ', // chillu r - // Chillu letters (using dead key or special mappings) - '|': 'ൽ', // chillu l + // Chillu letters (using dead key or special mappings) + '|': 'ൽ', // chillu l - // Digits — Malayalam numerals (shifted number keys) - ')': '൦', // 0 - '!': '൧', // 1 - '@': '൨', // 2 - '#': '൩', // 3 - '$': '൪', // 4 - '%': '൫', // 5 - '^': '൬', // 6 - '&': '൭', // 7 - '*': '൮', // 8 - '(': '൯', // 9 + // Digits — Malayalam numerals (shifted number keys) + ')': '൦', // 0 + '!': '൧', // 1 + '@': '൨', // 2 + '#': '൩', // 3 + $: '൪', // 4 + '%': '൫', // 5 + '^': '൬', // 6 + '&': '൭', // 7 + '*': '൮', // 8 + '(': '൯' // 9 }; /** @@ -93,5 +93,5 @@ const INSCRIPT2_MAP: Record = { * Returns the corresponding Malayalam character, or null if the key has no mapping. */ export function processInscript2Key(key: string): string | null { - return INSCRIPT2_MAP[key] ?? null; + return INSCRIPT2_MAP[key] ?? null; } diff --git a/src/lib/editor/input/mozhi.ts b/src/lib/editor/input/mozhi.ts index ed53704..97ca979 100644 --- a/src/lib/editor/input/mozhi.ts +++ b/src/lib/editor/input/mozhi.ts @@ -7,10 +7,10 @@ /** Result from processing a keystroke through an input scheme */ export interface InputResult { - /** The Malayalam text to insert */ - text: string; - /** Number of characters to delete backwards before inserting (for Mozhi backtracking) */ - deleteBack: number; + /** The Malayalam text to insert */ + text: string; + /** Number of characters to delete backwards before inserting (for Mozhi backtracking) */ + deleteBack: number; } /** @@ -19,217 +19,255 @@ export interface InputResult { * Values are the Malayalam text that should replace the matched key. */ function buildConversionHash(): Map { - const hash = new Map(); + const hash = new Map(); - // === VOWEL SIGNS (virama + Latin → vowel sign) === - // When a consonant has virama (്) and you type a vowel letter, - // the virama is replaced with the corresponding vowel sign - hash.set('്a', ''); // inherent 'a' — just removes virama - hash.set('്e', 'െ'); // e matra - hash.set('്i', 'ി'); // i matra - hash.set('്o', 'ൊ'); // o matra - hash.set('്u', 'ു'); // u matra - hash.set('്A', 'ാ'); // aa matra - hash.set('്E', 'േ'); // ee matra - hash.set('്I', 'ീ'); // ii matra - hash.set('്O', 'ോ'); // oo matra - hash.set('്U', 'ൂ'); // uu matra - hash.set('്Y', 'ൈ'); // ai matra - // Double-vowel upgrades (e.g., short e → long ii) - hash.set('െe', 'ീ'); // e + e → ii - hash.set('ൊo', 'ൂ'); // o + o → uu - hash.set('ിi', 'ീ'); // i + i → ii - hash.set('ിe', 'ീ'); // i + e → ii - hash.set('ുu', 'ൂ'); // u + u → uu - hash.set('ുo', 'ൂ'); // u + o → uu (alternate) - hash.set('്r', '്ര്'); // r after virama → ra-virama (conjunct) + // === VOWEL SIGNS (virama + Latin → vowel sign) === + // When a consonant has virama (്) and you type a vowel letter, + // the virama is replaced with the corresponding vowel sign + hash.set('്a', ''); // inherent 'a' — just removes virama + hash.set('്e', 'െ'); // e matra + hash.set('്i', 'ി'); // i matra + hash.set('്o', 'ൊ'); // o matra + hash.set('്u', 'ു'); // u matra + hash.set('്A', 'ാ'); // aa matra + hash.set('്E', 'േ'); // ee matra + hash.set('്I', 'ീ'); // ii matra + hash.set('്O', 'ോ'); // oo matra + hash.set('്U', 'ൂ'); // uu matra + hash.set('്Y', 'ൈ'); // ai matra + // Double-vowel upgrades (e.g., short e → long ii) + hash.set('െe', 'ീ'); // e + e → ii + hash.set('ൊo', 'ൂ'); // o + o → uu + hash.set('ിi', 'ീ'); // i + i → ii + hash.set('ിe', 'ീ'); // i + e → ii + hash.set('ുu', 'ൂ'); // u + u → uu + hash.set('ുo', 'ൂ'); // u + o → uu (alternate) + hash.set('്r', '്ര്'); // r after virama → ra-virama (conjunct) - // === ROMAN → CONSONANT (with virama) === - // Single Latin letters map to Malayalam consonant + virama - hash.set('k', 'ക്'); - hash.set('g', 'ഗ്'); - hash.set('j', 'ജ്'); - hash.set('T', 'ട്'); - hash.set('D', 'ഡ്'); - hash.set('d', 'ദ്'); - hash.set('p', 'പ്'); - hash.set('f', 'ഫ്'); - hash.set('b', 'ബ്'); - hash.set('y', 'യ്'); - hash.set('v', 'വ്'); - hash.set('w', 'വ്'); - hash.set('z', 'ശ്'); - hash.set('S', 'ശ്'); - hash.set('s', 'സ്'); - hash.set('h', 'ഹ്'); - hash.set('x', 'ക്ഷ്'); - hash.set('R', 'റ്'); - hash.set('t', 'റ്റ്'); - // c maps to ക് with ZWJ (used as intermediate for ch → ച്) - hash.set('c', 'ക്\u200D'); + // === ROMAN → CONSONANT (with virama) === + // Single Latin letters map to Malayalam consonant + virama + hash.set('k', 'ക്'); + hash.set('g', 'ഗ്'); + hash.set('j', 'ജ്'); + hash.set('T', 'ട്'); + hash.set('D', 'ഡ്'); + hash.set('d', 'ദ്'); + hash.set('p', 'പ്'); + hash.set('f', 'ഫ്'); + hash.set('b', 'ബ്'); + hash.set('y', 'യ്'); + hash.set('v', 'വ്'); + hash.set('w', 'വ്'); + hash.set('z', 'ശ്'); + hash.set('S', 'ശ്'); + hash.set('s', 'സ്'); + hash.set('h', 'ഹ്'); + hash.set('x', 'ക്ഷ്'); + hash.set('R', 'റ്'); + hash.set('t', 'റ്റ്'); + // c maps to ക് with ZWJ (used as intermediate for ch → ച്) + hash.set('c', 'ക്\u200D'); - // Aspirated consonants (consonant output + 'h' → aspirated consonant) - hash.set('ക്h', 'ഖ്'); // k + h → kh - hash.set('ഗ്h', 'ഘ്'); // g + h → gh - hash.set('ക്\u200Dh', 'ച്'); // c + h → ch - hash.set('ച്h', 'ഛ്'); // ch + h → chh - hash.set('ജ്h', 'ഝ്'); // j + h → jh - hash.set('ട്h', 'ഠ്'); // T + h → Th - hash.set('ഡ്h', 'ഢ്'); // D + h → Dh - hash.set('റ്റ്h', 'ത്'); // t + h → th (via റ്റ്) - hash.set('ത്h', 'ഥ്'); // th + h → thh - hash.set('ദ്h', 'ധ്'); // d + h → dh - hash.set('പ്h', 'ഫ്'); // p + h → ph - hash.set('ബ്h', 'ഭ്'); // b + h → bh - hash.set('സ്h', 'ഷ്'); // s + h → sh - hash.set('ശ്h', 'ഴ്'); // S/z + h → zh + // Aspirated consonants (consonant output + 'h' → aspirated consonant) + hash.set('ക്h', 'ഖ്'); // k + h → kh + hash.set('ഗ്h', 'ഘ്'); // g + h → gh + hash.set('ക്\u200Dh', 'ച്'); // c + h → ch + hash.set('ച്h', 'ഛ്'); // ch + h → chh + hash.set('ജ്h', 'ഝ്'); // j + h → jh + hash.set('ട്h', 'ഠ്'); // T + h → Th + hash.set('ഡ്h', 'ഢ്'); // D + h → Dh + hash.set('റ്റ്h', 'ത്'); // t + h → th (via റ്റ്) + hash.set('ത്h', 'ഥ്'); // th + h → thh + hash.set('ദ്h', 'ധ്'); // d + h → dh + hash.set('പ്h', 'ഫ്'); // p + h → ph + hash.set('ബ്h', 'ഭ്'); // b + h → bh + hash.set('സ്h', 'ഷ്'); // s + h → sh + hash.set('ശ്h', 'ഴ്'); // S/z + h → zh - // Nasal consonants from chillu combinations - hash.set('ൻg', 'ങ്'); // n + g → ng - hash.set('ൻj', 'ഞ്'); // n + j → nj - hash.set('ൻh', 'ഞ്'); // n + h → nj (alternate) + // Nasal consonants from chillu combinations + hash.set('ൻg', 'ങ്'); // n + g → ng + hash.set('ൻj', 'ഞ്'); // n + j → nj + hash.set('ൻh', 'ഞ്'); // n + h → nj (alternate) - // === CHILLU LETTERS (standalone consonant finals) === - hash.set('N', 'ൺ'); // retroflex N chillu - hash.set('n', 'ൻ'); // dental n chillu - hash.set('m', 'ം'); // anusvara (m) - hash.set('r', 'ർ'); // chillu r - hash.set('l', 'ൽ'); // chillu l - hash.set('L', 'ൾ'); // chillu L (retroflex l) + // === CHILLU LETTERS (standalone consonant finals) === + hash.set('N', 'ൺ'); // retroflex N chillu + hash.set('n', 'ൻ'); // dental n chillu + hash.set('m', 'ം'); // anusvara (m) + hash.set('r', 'ർ'); // chillu r + hash.set('l', 'ൽ'); // chillu l + hash.set('L', 'ൾ'); // chillu L (retroflex l) - // === STANDALONE VOWELS === - hash.set('a', 'അ'); - hash.set('അa', 'ആ'); // a + a → aa - hash.set('A', 'ആ'); - hash.set('e', 'എ'); - hash.set('E', 'ഏ'); - hash.set('എe', 'ഈ'); // e + e → ii (Mozhi convention) - hash.set('i', 'ഇ'); - hash.set('ഇi', 'ഈ'); // i + i → ii - hash.set('ഇe', 'ഈ'); // i + e → ii - hash.set('അi', 'ഐ'); // a + i → ai - hash.set('I', 'ഐ'); - hash.set('o', 'ഒ'); - hash.set('ഒo', 'ഊ'); // o + o → uu - hash.set('O', 'ഓ'); - hash.set('അu', 'ഔ'); // a + u → au - hash.set('ഒu', 'ഔ'); // o + u → au - hash.set('u', 'ഉ'); - hash.set('ഉu', 'ഊ'); // u + u → uu - hash.set('U', 'ഊ'); - hash.set('H', 'ഃ'); // visarga - hash.set('റ്h', 'ഋ'); // special: R + h → Ri - hash.set('ർ^', 'ഋ'); // chillu r + ^ → Ri - hash.set('ഋ^', 'ൠ'); // Ri + ^ → long Ri - hash.set('ൽ^', 'ഌ'); // chillu l + ^ → Li - hash.set('ഌ^', 'ൡ'); // Li + ^ → long Li + // === STANDALONE VOWELS === + hash.set('a', 'അ'); + hash.set('അa', 'ആ'); // a + a → aa + hash.set('A', 'ആ'); + hash.set('e', 'എ'); + hash.set('E', 'ഏ'); + hash.set('എe', 'ഈ'); // e + e → ii (Mozhi convention) + hash.set('i', 'ഇ'); + hash.set('ഇi', 'ഈ'); // i + i → ii + hash.set('ഇe', 'ഈ'); // i + e → ii + hash.set('അi', 'ഐ'); // a + i → ai + hash.set('I', 'ഐ'); + hash.set('o', 'ഒ'); + hash.set('ഒo', 'ഊ'); // o + o → uu + hash.set('O', 'ഓ'); + hash.set('അu', 'ഔ'); // a + u → au + hash.set('ഒu', 'ഔ'); // o + u → au + hash.set('u', 'ഉ'); + hash.set('ഉu', 'ഊ'); // u + u → uu + hash.set('U', 'ഊ'); + hash.set('H', 'ഃ'); // visarga + hash.set('റ്h', 'ഋ'); // special: R + h → Ri + hash.set('ർ^', 'ഋ'); // chillu r + ^ → Ri + hash.set('ഋ^', 'ൠ'); // Ri + ^ → long Ri + hash.set('ൽ^', 'ഌ'); // chillu l + ^ → Li + hash.set('ഌ^', 'ൡ'); // Li + ^ → long Li - // === NUMERALS === - hash.set('1', '൧'); - hash.set('2', '൨'); - hash.set('3', '൩'); - hash.set('4', '൪'); - hash.set('5', '൫'); - hash.set('6', '൬'); - hash.set('7', '൭'); - hash.set('8', '൮'); - hash.set('9', '൯'); - hash.set('0', '൦'); + // === NUMERALS === + hash.set('1', '൧'); + hash.set('2', '൨'); + hash.set('3', '൩'); + hash.set('4', '൪'); + hash.set('5', '൫'); + hash.set('6', '൬'); + hash.set('7', '൭'); + hash.set('8', '൮'); + hash.set('9', '൯'); + hash.set('0', '൦'); - // === CONJUNCTS (chillu + consonant → nasal+consonant cluster) === - hash.set('ൻt', 'ന്റ്'); // n + t → nta - hash.set('ന്റ്h', 'ന്ത്'); // nta + h → ntha - hash.set('ൻk', 'ങ്ക്'); // n + k → nka - hash.set('ൻn', 'ന്ന്'); // n + n → nna - hash.set('ൺN', 'ണ്ണ്'); // N + N → NNa - hash.set('ൾL', 'ള്ള്'); // L + L → LLa - hash.set('ൽl', 'ല്ല്'); // l + l → lla - hash.set('ംm', 'മ്മ്'); // m + m → mma - hash.set('ൻm', 'ന്മ്'); // n + m → nma - hash.set('ന്ന്g', 'ങ്ങ്'); // nn + g → nnga - hash.set('ൻd', 'ന്ദ്'); // n + d → nda - hash.set('ൺm', 'ണ്മ്'); // N + m → Nma - hash.set('ൽp', 'ല്പ്'); // l + p → lpa - hash.set('ംp', 'മ്പ്'); // m + p → mpa - hash.set('റ്റ്t', 'ട്ട്'); // t + t → TTa - hash.set('ൻT', 'ണ്ട്'); // n + T → NDa - hash.set('ൺT', 'ണ്ട്'); // N + T → NDa - hash.set('്ര്^', 'ൃ'); // ra-virama + ^ → Ri sign - hash.set('ൻc', 'ൻ\u200D'); // n + c → intermediate - hash.set('ൻ\u200Dh', 'ഞ്ച്'); // n + ch → ncha - hash.set('ൺD', 'ണ്ഡ്'); // N + D → NDa (retroflex) + // === CONJUNCTS (chillu + consonant → nasal+consonant cluster) === + hash.set('ൻt', 'ന്റ്'); // n + t → nta + hash.set('ന്റ്h', 'ന്ത്'); // nta + h → ntha + hash.set('ൻk', 'ങ്ക്'); // n + k → nka + hash.set('ൻn', 'ന്ന്'); // n + n → nna + hash.set('ൺN', 'ണ്ണ്'); // N + N → NNa + hash.set('ൾL', 'ള്ള്'); // L + L → LLa + hash.set('ൽl', 'ല്ല്'); // l + l → lla + hash.set('ംm', 'മ്മ്'); // m + m → mma + hash.set('ൻm', 'ന്മ്'); // n + m → nma + hash.set('ന്ന്g', 'ങ്ങ്'); // nn + g → nnga + hash.set('ൻd', 'ന്ദ്'); // n + d → nda + hash.set('ൺm', 'ണ്മ്'); // N + m → Nma + hash.set('ൽp', 'ല്പ്'); // l + p → lpa + hash.set('ംp', 'മ്പ്'); // m + p → mpa + hash.set('റ്റ്t', 'ട്ട്'); // t + t → TTa + hash.set('ൻT', 'ണ്ട്'); // n + T → NDa + hash.set('ൺT', 'ണ്ട്'); // N + T → NDa + hash.set('്ര്^', 'ൃ'); // ra-virama + ^ → Ri sign + hash.set('ൻc', 'ൻ\u200D'); // n + c → intermediate + hash.set('ൻ\u200Dh', 'ഞ്ച്'); // n + ch → ncha + hash.set('ൺD', 'ണ്ഡ്'); // N + D → NDa (retroflex) - // === CAPS (double/geminate consonants) === - hash.set('B', 'ബ്ബ്'); - hash.set('C', 'ക്ക്\u200D'); - hash.set('F', 'ഫ്'); - hash.set('G', 'ഗ്ഗ്'); - hash.set('J', 'ജ്ജ്'); - hash.set('K', 'ക്ക്'); - hash.set('M', 'മ്മ്'); - hash.set('P', 'പ്പ്'); - hash.set('Q', 'ക്യൂ'); - hash.set('V', 'വ്വ്'); - hash.set('W', 'വ്വ്'); - hash.set('X', 'ക്ഷ്'); - hash.set('Y', 'യ്യ്'); - hash.set('Z', 'ശ്ശ്'); + // === CAPS (double/geminate consonants) === + hash.set('B', 'ബ്ബ്'); + hash.set('C', 'ക്ക്\u200D'); + hash.set('F', 'ഫ്'); + hash.set('G', 'ഗ്ഗ്'); + hash.set('J', 'ജ്ജ്'); + hash.set('K', 'ക്ക്'); + hash.set('M', 'മ്മ്'); + hash.set('P', 'പ്പ്'); + hash.set('Q', 'ക്യൂ'); + hash.set('V', 'വ്വ്'); + hash.set('W', 'വ്വ്'); + hash.set('X', 'ക്ഷ്'); + hash.set('Y', 'യ്യ്'); + hash.set('Z', 'ശ്ശ്'); - // === OTHERS (special combinations) === - hash.set('്L', '്ല്'); // virama + L → la-virama (conjunct) - hash.set('~', '്\u200C'); // tilde → virama + ZWNJ - hash.set('്~', '്\u200C'); // already virama + tilde - hash.set('\u200C~', '\u200C'); // ZWNJ + tilde stays ZWNJ - hash.set('ം~', 'മ്'); // anusvara + tilde → ma-virama - hash.set('ക്\u200Dc', 'ക്ക്\u200D'); // c + c → kka-ZWJ - hash.set('ക്ക്\u200Dh', 'ച്ച്'); // cc + h → ccha - hash.set('q', 'ക്യൂ'); - hash.set('_', '\u200C'); // underscore → ZWNJ + // === OTHERS (special combinations) === + hash.set('്L', '്ല്'); // virama + L → la-virama (conjunct) + hash.set('~', '്\u200C'); // tilde → virama + ZWNJ + hash.set('്~', '്\u200C'); // already virama + tilde + hash.set('\u200C~', '\u200C'); // ZWNJ + tilde stays ZWNJ + hash.set('ം~', 'മ്'); // anusvara + tilde → ma-virama + hash.set('ക്\u200Dc', 'ക്ക്\u200D'); // c + c → kka-ZWJ + hash.set('ക്ക്\u200Dh', 'ച്ച്'); // cc + h → ccha + hash.set('q', 'ക്യൂ'); + hash.set('_', '\u200C'); // underscore → ZWNJ - // === DYNAMICALLY GENERATED: consonant + second vowel === - // When a consonant already has inherent 'a' (no virama), typing another - // vowel letter changes it to a long vowel sign - const consonantList = [ - 'ക','ഖ','ഗ','ഘ','ങ','ച','ഛ','ജ','ഝ','ഞ', - 'ട','ഠ','ഡ','ഢ','ണ','ത','ഥ','ദ','ധ','ന', - 'പ','ഫ','ബ','ഭ','മ','യ','ര','ല','വ', - 'ശ','ഷ','സ','ഹ','ള','ഴ','റ','റ്റ' - ]; - for (const c of consonantList) { - hash.set(c + 'a', c + 'ാ'); // a after consonant → aa matra - hash.set(c + 'e', c + 'േ'); // e after consonant → ee matra - hash.set(c + 'i', c + 'ൈ'); // i after consonant → ai matra - hash.set(c + 'o', c + 'ോ'); // o after consonant → oo matra - hash.set(c + 'u', c + 'ൗ'); // u after consonant → au sign - } + // === DYNAMICALLY GENERATED: consonant + second vowel === + // When a consonant already has inherent 'a' (no virama), typing another + // vowel letter changes it to a long vowel sign + const consonantList = [ + 'ക', + 'ഖ', + 'ഗ', + 'ഘ', + 'ങ', + 'ച', + 'ഛ', + 'ജ', + 'ഝ', + 'ഞ', + 'ട', + 'ഠ', + 'ഡ', + 'ഢ', + 'ണ', + 'ത', + 'ഥ', + 'ദ', + 'ധ', + 'ന', + 'പ', + 'ഫ', + 'ബ', + 'ഭ', + 'മ', + 'യ', + 'ര', + 'ല', + 'വ', + 'ശ', + 'ഷ', + 'സ', + 'ഹ', + 'ള', + 'ഴ', + 'റ', + 'റ്റ' + ]; + for (const c of consonantList) { + hash.set(c + 'a', c + 'ാ'); // a after consonant → aa matra + hash.set(c + 'e', c + 'േ'); // e after consonant → ee matra + hash.set(c + 'i', c + 'ൈ'); // i after consonant → ai matra + hash.set(c + 'o', c + 'ോ'); // o after consonant → oo matra + hash.set(c + 'u', c + 'ൗ'); // u after consonant → au sign + } - // === DYNAMICALLY GENERATED: chillu + vowel/consonant === - // Chillus are "half consonants". When followed by a vowel, they revert - // to the full consonant form with the vowel sign. - const chilluEntries: [string, string][] = [ - ['ൺ', 'ണ'], ['ൻ', 'ന'], ['ം', 'മ'], ['ർ', 'ര'], ['ൽ', 'ല'], ['ൾ', 'ള'], - ['്\u200D', ''] // virama+ZWJ → empty base - ]; - for (const [chillu, base] of chilluEntries) { - hash.set(chillu + 'a', base); // + a → base consonant (inherent a) - hash.set(chillu + 'e', base + 'െ'); // + e → base + e matra - hash.set(chillu + 'i', base + 'ി'); // + i → base + i matra - hash.set(chillu + 'o', base + 'ൊ'); // + o → base + o matra - hash.set(chillu + 'u', base + 'ു'); // + u → base + u matra - hash.set(chillu + 'A', base + 'ാ'); // + A → base + aa matra - hash.set(chillu + 'E', base + 'േ'); // + E → base + ee matra - hash.set(chillu + 'I', base + 'ീ'); // + I → base + ii matra - hash.set(chillu + 'O', base + 'ോ'); // + O → base + oo matra - hash.set(chillu + 'U', base + 'ൂ'); // + U → base + uu matra - hash.set(chillu + 'Y', base + 'ൈ'); // + Y → base + ai matra - hash.set(chillu + 'r', base + '്ര്'); // + r → base + ra conjunct - hash.set(chillu + 'y', base + '്യ്'); // + y → base + ya conjunct - hash.set(chillu + 'v', base + '്വ്'); // + v → base + va conjunct - hash.set(chillu + 'w', base + '്വ്'); // + w → base + va conjunct - hash.set(chillu + '~', base + '്\u200C'); // + ~ → base + virama + ZWNJ - } + // === DYNAMICALLY GENERATED: chillu + vowel/consonant === + // Chillus are "half consonants". When followed by a vowel, they revert + // to the full consonant form with the vowel sign. + const chilluEntries: [string, string][] = [ + ['ൺ', 'ണ'], + ['ൻ', 'ന'], + ['ം', 'മ'], + ['ർ', 'ര'], + ['ൽ', 'ല'], + ['ൾ', 'ള'], + ['്\u200D', ''] // virama+ZWJ → empty base + ]; + for (const [chillu, base] of chilluEntries) { + hash.set(chillu + 'a', base); // + a → base consonant (inherent a) + hash.set(chillu + 'e', base + 'െ'); // + e → base + e matra + hash.set(chillu + 'i', base + 'ി'); // + i → base + i matra + hash.set(chillu + 'o', base + 'ൊ'); // + o → base + o matra + hash.set(chillu + 'u', base + 'ു'); // + u → base + u matra + hash.set(chillu + 'A', base + 'ാ'); // + A → base + aa matra + hash.set(chillu + 'E', base + 'േ'); // + E → base + ee matra + hash.set(chillu + 'I', base + 'ീ'); // + I → base + ii matra + hash.set(chillu + 'O', base + 'ോ'); // + O → base + oo matra + hash.set(chillu + 'U', base + 'ൂ'); // + U → base + uu matra + hash.set(chillu + 'Y', base + 'ൈ'); // + Y → base + ai matra + hash.set(chillu + 'r', base + '്ര്'); // + r → base + ra conjunct + hash.set(chillu + 'y', base + '്യ്'); // + y → base + ya conjunct + hash.set(chillu + 'v', base + '്വ്'); // + v → base + va conjunct + hash.set(chillu + 'w', base + '്വ്'); // + w → base + va conjunct + hash.set(chillu + '~', base + '്\u200C'); // + ~ → base + virama + ZWNJ + } - return hash; + return hash; } /** @@ -241,112 +279,112 @@ function buildConversionHash(): Map { * Returns how many characters to delete backwards and what new text to insert. */ export class MozhiEngine { - /** The conversion hash: maps (Malayalam context + Latin key) → Malayalam output */ - private hash: Map; - /** Maximum key length to try when matching in the hash */ - private maxKeyLength: number; - /** Recent Malayalam output buffer — tracks what was last inserted into the editor */ - private cyrBuffer: string; + /** The conversion hash: maps (Malayalam context + Latin key) → Malayalam output */ + private hash: Map; + /** Maximum key length to try when matching in the hash */ + private maxKeyLength: number; + /** Recent Malayalam output buffer — tracks what was last inserted into the editor */ + private cyrBuffer: string; - constructor() { - this.hash = buildConversionHash(); - // Find the longest key in the hash to know how far back to look - this.maxKeyLength = 0; - for (const key of this.hash.keys()) { - if (key.length > this.maxKeyLength) { - this.maxKeyLength = key.length; - } - } - this.cyrBuffer = ''; - } + constructor() { + this.hash = buildConversionHash(); + // Find the longest key in the hash to know how far back to look + this.maxKeyLength = 0; + for (const key of this.hash.keys()) { + if (key.length > this.maxKeyLength) { + this.maxKeyLength = key.length; + } + } + this.cyrBuffer = ''; + } - /** Reset the engine state. Call on space, punctuation, cursor change, mode toggle. */ - reset(): void { - this.cyrBuffer = ''; - } + /** Reset the engine state. Call on space, punctuation, cursor change, mode toggle. */ + reset(): void { + this.cyrBuffer = ''; + } - /** - * Process a single keystroke through the Mozhi engine. - * - * Combines the recent output buffer (cyrBuffer) with the new Latin key, - * then runs greedy transliteration on the combined string. Computes a diff - * between the old buffer and the new result to determine how many characters - * to delete backwards and what new text to insert. - * - * Returns null if no conversion happened (the key should pass through to the editor). - */ - processKey(key: string): InputResult | null { - // Combine recent Malayalam output with the new Latin keystroke. - // The hash has entries like "ക്h" → "ഖ്" that match Malayalam+Latin sequences. - const input = this.cyrBuffer + key; + /** + * Process a single keystroke through the Mozhi engine. + * + * Combines the recent output buffer (cyrBuffer) with the new Latin key, + * then runs greedy transliteration on the combined string. Computes a diff + * between the old buffer and the new result to determine how many characters + * to delete backwards and what new text to insert. + * + * Returns null if no conversion happened (the key should pass through to the editor). + */ + processKey(key: string): InputResult | null { + // Combine recent Malayalam output with the new Latin keystroke. + // The hash has entries like "ക്h" → "ഖ്" that match Malayalam+Latin sequences. + const input = this.cyrBuffer + key; - // Run greedy left-to-right transliteration on the combined string - const result = this.transliterate(input); + // Run greedy left-to-right transliteration on the combined string + const result = this.transliterate(input); - // Find the longest common prefix between old cyrBuffer and new result. - // Characters in the common prefix don't need to change in the editor. - let commonPrefix = 0; - const minLen = Math.min(this.cyrBuffer.length, result.length); - for (let i = 0; i < minLen; i++) { - if (this.cyrBuffer[i] === result[i]) { - commonPrefix++; - } else { - break; - } - } + // Find the longest common prefix between old cyrBuffer and new result. + // Characters in the common prefix don't need to change in the editor. + let commonPrefix = 0; + const minLen = Math.min(this.cyrBuffer.length, result.length); + for (let i = 0; i < minLen; i++) { + if (this.cyrBuffer[i] === result[i]) { + commonPrefix++; + } else { + break; + } + } - // deleteBack: how many chars of the old buffer need to be removed from the editor - const deleteBack = this.cyrBuffer.length - commonPrefix; - // newText: the new characters to insert after the deletion - const newText = result.slice(commonPrefix); + // deleteBack: how many chars of the old buffer need to be removed from the editor + const deleteBack = this.cyrBuffer.length - commonPrefix; + // newText: the new characters to insert after the deletion + const newText = result.slice(commonPrefix); - // Update the buffer with the new result (keep only the tail for future matching) - this.cyrBuffer = result.slice(Math.max(0, result.length - this.maxKeyLength)); + // Update the buffer with the new result (keep only the tail for future matching) + this.cyrBuffer = result.slice(Math.max(0, result.length - this.maxKeyLength)); - // If no conversion happened — the result is just cyrBuffer + key literally appended — - // return null to let the key pass through to the editor unchanged - if (deleteBack === 0 && newText === key) { - return null; - } + // If no conversion happened — the result is just cyrBuffer + key literally appended — + // return null to let the key pass through to the editor unchanged + if (deleteBack === 0 && newText === key) { + return null; + } - return { text: newText, deleteBack }; - } + return { text: newText, deleteBack }; + } - /** - * Greedy left-to-right transliteration. - * - * Scans the input string from left to right. At each position, tries to match - * the longest possible substring against the conversion hash. If a match is found, - * the matched portion is replaced with the hash value and the position advances - * past it. If no match is found, the character passes through unchanged. - */ - private transliterate(src: string): string { - let output = ''; - let pos = 0; + /** + * Greedy left-to-right transliteration. + * + * Scans the input string from left to right. At each position, tries to match + * the longest possible substring against the conversion hash. If a match is found, + * the matched portion is replaced with the hash value and the position advances + * past it. If no match is found, the character passes through unchanged. + */ + private transliterate(src: string): string { + let output = ''; + let pos = 0; - while (pos < src.length) { - let matched = false; - // Try longest match first, then progressively shorter - const maxLen = Math.min(this.maxKeyLength, src.length - pos); - for (let len = maxLen; len > 0; len--) { - const sub = src.substring(pos, pos + len); - const mapped = this.hash.get(sub); - // mapped !== undefined because empty string '' is a valid mapping - // (e.g., '്a' → '' removes the virama to reveal inherent 'a') - if (mapped !== undefined) { - output += mapped; - pos += len; - matched = true; - break; - } - } - if (!matched) { - // No hash match — pass through the character as-is - output += src[pos]; - pos++; - } - } + while (pos < src.length) { + let matched = false; + // Try longest match first, then progressively shorter + const maxLen = Math.min(this.maxKeyLength, src.length - pos); + for (let len = maxLen; len > 0; len--) { + const sub = src.substring(pos, pos + len); + const mapped = this.hash.get(sub); + // mapped !== undefined because empty string '' is a valid mapping + // (e.g., '്a' → '' removes the virama to reveal inherent 'a') + if (mapped !== undefined) { + output += mapped; + pos += len; + matched = true; + break; + } + } + if (!matched) { + // No hash match — pass through the character as-is + output += src[pos]; + pos++; + } + } - return output; - } + return output; + } } diff --git a/src/lib/editor/keymap.ts b/src/lib/editor/keymap.ts index 800494e..abd9f5e 100644 --- a/src/lib/editor/keymap.ts +++ b/src/lib/editor/keymap.ts @@ -56,7 +56,7 @@ const handleEnter: Command = (state, dispatch) => { if (isEmpty && typeName === 'character') { if (dispatch) { const pos = state.selection.$from.before(); - let tr = state.tr.setNodeMarkup(pos, screenplaySchema.nodes.action); + const tr = state.tr.setNodeMarkup(pos, screenplaySchema.nodes.action); tr.scrollIntoView(); dispatch(tr); } @@ -140,7 +140,12 @@ const handleEnter: Command = (state, dispatch) => { * Used when switching into or out of a parenthetical so the parens live in * the actual content rather than in CSS (issue #59). */ -function replaceBlockContent(tr: Transaction, blockPos: number, block: ProseNode, newContent: Fragment): Transaction { +function replaceBlockContent( + tr: Transaction, + blockPos: number, + block: ProseNode, + newContent: Fragment +): Transaction { const from = blockPos + 1; const to = blockPos + 1 + block.content.size; return tr.replaceWith(from, to, newContent); diff --git a/src/lib/editor/parsePastedScript.ts b/src/lib/editor/parsePastedScript.ts index 9fdfa11..992910e 100644 --- a/src/lib/editor/parsePastedScript.ts +++ b/src/lib/editor/parsePastedScript.ts @@ -39,47 +39,47 @@ // the Latin rule and are the reliable path. interface PMNode { - type: string; - content?: PMText[]; + type: string; + content?: PMText[]; } interface PMText { - type: 'text'; - text: string; + type: 'text'; + text: string; } const SCENE_HEADING = /^(INT\.?\/EXT\.?|EXT\.?\/INT\.?|INT\.?|EXT\.?|I\/E)\b/i; function textNode(text: string): PMText { - return { type: 'text', text }; + return { type: 'text', text }; } function block(type: string, text: string): PMNode { - if (!text) return { type }; - return { type, content: [textNode(text)] }; + if (!text) return { type }; + return { type, content: [textNode(text)] }; } /** All caps + no lowercase letters anywhere. Numbers, punctuation, and * whitespace are allowed. Empty strings return false. */ function isAllCaps(line: string): boolean { - if (!line.trim()) return false; - // Has at least one Latin letter and no lowercase Latin letter. Malayalam - // text has no concept of case — we deliberately don't treat Malayalam-only - // lines as character names because that would misclassify a long action - // sentence in Malayalam as a speaker name. - if (!/[A-Za-z]/.test(line)) return false; - return line === line.toUpperCase(); + if (!line.trim()) return false; + // Has at least one Latin letter and no lowercase Latin letter. Malayalam + // text has no concept of case — we deliberately don't treat Malayalam-only + // lines as character names because that would misclassify a long action + // sentence in Malayalam as a speaker name. + if (!/[A-Za-z]/.test(line)) return false; + return line === line.toUpperCase(); } /** Latin-script character name: all-caps, no terminal punctuation, * conservative shape. */ function looksLikeLatinCharacterName(line: string): boolean { - const t = line.trim(); - if (t.length === 0 || t.length > 60) return false; - if (!isAllCaps(t)) return false; - // Character names typically don't end with sentence punctuation. - // "JOHN (V.O.)" and "JANE (CONT'D)" are common forms. - return /^[A-Z0-9][A-Z0-9 .'()/,&-]*$/.test(t); + const t = line.trim(); + if (t.length === 0 || t.length > 60) return false; + if (!isAllCaps(t)) return false; + // Character names typically don't end with sentence punctuation. + // "JOHN (V.O.)" and "JANE (CONT'D)" are common forms. + return /^[A-Z0-9][A-Z0-9 .'()/,&-]*$/.test(t); } /** Malayalam-script character name candidate. Returns true if the line @@ -87,21 +87,21 @@ function looksLikeLatinCharacterName(line: string): boolean { * a lookahead at the next non-blank line, since a short Malayalam phrase * on its own is otherwise indistinguishable from an action sentence. */ function looksLikeMalayalamNameCandidate(line: string): boolean { - const t = line.trim(); - if (t.length === 0 || t.length > 30) return false; - // Has Malayalam content (at least one Malayalam codepoint). - if (!/[ഀ-ൿ]/.test(t)) return false; - // Reject lines with sentence-ending punctuation — character names - // never end a sentence. Includes Malayalam danda (।) which some - // writers use as a full stop. - if (/[.!?,;:।]$/.test(t)) return false; - // Reject lines with quotation marks — those are dialogue or quoted - // content, not a speaker name. - if (/[“”"‘’']/.test(t)) return false; - // Conservative word cap — names are 1–4 words; longer is almost - // certainly an action sentence. - if (t.split(/\s+/).length > 4) return false; - return true; + const t = line.trim(); + if (t.length === 0 || t.length > 30) return false; + // Has Malayalam content (at least one Malayalam codepoint). + if (!/[ഀ-ൿ]/.test(t)) return false; + // Reject lines with sentence-ending punctuation — character names + // never end a sentence. Includes Malayalam danda (।) which some + // writers use as a full stop. + if (/[.!?,;:।]$/.test(t)) return false; + // Reject lines with quotation marks — those are dialogue or quoted + // content, not a speaker name. + if (/[“”"‘’']/.test(t)) return false; + // Conservative word cap — names are 1–4 words; longer is almost + // certainly an action sentence. + if (t.split(/\s+/).length > 4) return false; + return true; } /** Combined check used by the parser. Latin all-caps is decisive on its @@ -121,29 +121,29 @@ function looksLikeMalayalamNameCandidate(line: string): boolean { * names get fixed in the editor, but we don't sprinkle phantom * "characters" through pasted prose. */ function looksLikeCharacterName(line: string, nextNonBlank: string | null): boolean { - if (looksLikeLatinCharacterName(line)) return true; - if (!looksLikeMalayalamNameCandidate(line)) return false; + if (looksLikeLatinCharacterName(line)) return true; + if (!looksLikeMalayalamNameCandidate(line)) return false; - // Signal 1: explicit name marker (`:` at the end of the line). The - // colon is dropped at character emission so the editor sees just the - // name — caller handles that. - if (line.trim().endsWith(':')) return true; + // Signal 1: explicit name marker (`:` at the end of the line). The + // colon is dropped at character emission so the editor sees just the + // name — caller handles that. + if (line.trim().endsWith(':')) return true; - // Signal 2: next non-blank is a parenthetical. - if (nextNonBlank && isParenthetical(nextNonBlank)) return true; + // Signal 2: next non-blank is a parenthetical. + if (nextNonBlank && isParenthetical(nextNonBlank)) return true; - return false; + return false; } function looksLikeTransition(line: string): boolean { - const t = line.trim(); - if (!isAllCaps(t)) return false; - return t.endsWith(':') || /\bTO:$/i.test(t); + const t = line.trim(); + if (!isAllCaps(t)) return false; + return t.endsWith(':') || /\bTO:$/i.test(t); } function isParenthetical(line: string): boolean { - const t = line.trim(); - return t.startsWith('(') && t.endsWith(')') && t.length >= 2; + const t = line.trim(); + return t.startsWith('(') && t.endsWith(')') && t.length >= 2; } /** Detect the "Name: Dialogue" same-line shape and split it into two @@ -153,21 +153,21 @@ function isParenthetical(line: string): boolean { * ("DAY:") are excluded because they'd already be inside a * scene_heading line, which is matched earlier in the pipeline. */ function trySplitNameColonDialogue(line: string): { name: string; dialogue: string } | null { - const idx = line.indexOf(':'); - if (idx < 1) return null; - const namePart = line.slice(0, idx).trim(); - const dialoguePart = line.slice(idx + 1).trim(); - if (namePart.length === 0 || dialoguePart.length === 0) return null; - // Don't trigger on URLs (`https://...`) or scene heading sub-parts. - if (/[/\\]/.test(namePart)) return null; - // Name must look like a name on its own — reuse the existing detectors. - if (looksLikeLatinCharacterName(namePart)) { - return { name: namePart, dialogue: dialoguePart }; - } - if (looksLikeMalayalamNameCandidate(namePart)) { - return { name: namePart, dialogue: dialoguePart }; - } - return null; + const idx = line.indexOf(':'); + if (idx < 1) return null; + const namePart = line.slice(0, idx).trim(); + const dialoguePart = line.slice(idx + 1).trim(); + if (namePart.length === 0 || dialoguePart.length === 0) return null; + // Don't trigger on URLs (`https://...`) or scene heading sub-parts. + if (/[/\\]/.test(namePart)) return null; + // Name must look like a name on its own — reuse the existing detectors. + if (looksLikeLatinCharacterName(namePart)) { + return { name: namePart, dialogue: dialoguePart }; + } + if (looksLikeMalayalamNameCandidate(namePart)) { + return { name: namePart, dialogue: dialoguePart }; + } + return null; } /** @@ -176,109 +176,109 @@ function trySplitNameColonDialogue(line: string): { name: string; dialogue: stri * documentStore.normalizeContentPayload expects. */ export function parsePastedScript(input: string): { - type: 'doc'; - content: PMNode[]; + type: 'doc'; + content: PMNode[]; } { - const nodes: PMNode[] = []; - // Normalize line endings so split('\n') works the same on every OS. - const lines = input.replace(/\r\n?/g, '\n').split('\n'); + const nodes: PMNode[] = []; + // Normalize line endings so split('\n') works the same on every OS. + const lines = input.replace(/\r\n?/g, '\n').split('\n'); - // "Are we currently inside a character dialogue block?" — set true when - // we emit a `character` node, stays true through any number of dialogue - // and parenthetical lines, reset on a blank line or any other element. - let inDialogueBlock = false; - // Action lines accumulate so a paragraph of action stays as one node - // rather than fragmenting into many. Flushed on every element transition. - let actionBuffer: string[] = []; + // "Are we currently inside a character dialogue block?" — set true when + // we emit a `character` node, stays true through any number of dialogue + // and parenthetical lines, reset on a blank line or any other element. + let inDialogueBlock = false; + // Action lines accumulate so a paragraph of action stays as one node + // rather than fragmenting into many. Flushed on every element transition. + let actionBuffer: string[] = []; - const flushAction = () => { - if (actionBuffer.length === 0) return; - nodes.push(block('action', actionBuffer.join(' ').trim())); - actionBuffer = []; - }; + const flushAction = () => { + if (actionBuffer.length === 0) return; + nodes.push(block('action', actionBuffer.join(' ').trim())); + actionBuffer = []; + }; - /** Find the next non-blank line strictly after index `i`, trimmed. - * Returns null at end of input. Used by the Malayalam character - * detector to disambiguate "short Malayalam phrase" from "speaker - * name followed by dialogue". */ - const peekNextNonBlank = (i: number): string | null => { - for (let j = i + 1; j < lines.length; j++) { - const t = lines[j].trim(); - if (t.length > 0) return t; - } - return null; - }; + /** Find the next non-blank line strictly after index `i`, trimmed. + * Returns null at end of input. Used by the Malayalam character + * detector to disambiguate "short Malayalam phrase" from "speaker + * name followed by dialogue". */ + const peekNextNonBlank = (i: number): string | null => { + for (let j = i + 1; j < lines.length; j++) { + const t = lines[j].trim(); + if (t.length > 0) return t; + } + return null; + }; - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); - if (line.length === 0) { - flushAction(); - inDialogueBlock = false; - continue; - } + if (line.length === 0) { + flushAction(); + inDialogueBlock = false; + continue; + } - if (SCENE_HEADING.test(line)) { - flushAction(); - inDialogueBlock = false; - // Uppercase the heading to match the editor's auto-uppercase behavior. - nodes.push(block('scene_heading', line.toUpperCase())); - continue; - } + if (SCENE_HEADING.test(line)) { + flushAction(); + inDialogueBlock = false; + // Uppercase the heading to match the editor's auto-uppercase behavior. + nodes.push(block('scene_heading', line.toUpperCase())); + continue; + } - if (looksLikeTransition(line)) { - flushAction(); - inDialogueBlock = false; - nodes.push(block('transition', line)); - continue; - } + if (looksLikeTransition(line)) { + flushAction(); + inDialogueBlock = false; + nodes.push(block('transition', line)); + continue; + } - if (inDialogueBlock && isParenthetical(line)) { - // Keep the parens — the editor stores them in content (see #59). - nodes.push(block('parenthetical', line)); - continue; - } + if (inDialogueBlock && isParenthetical(line)) { + // Keep the parens — the editor stores them in content (see #59). + nodes.push(block('parenthetical', line)); + continue; + } - if (inDialogueBlock) { - // Anything that follows a character (and isn't a parenthetical / - // scene heading / transition) is dialogue. - nodes.push(block('dialogue', line)); - continue; - } + if (inDialogueBlock) { + // Anything that follows a character (and isn't a parenthetical / + // scene heading / transition) is dialogue. + nodes.push(block('dialogue', line)); + continue; + } - // Same-line "Name: Dialogue" form — common in plain-text Malayalam - // (and some English) screenplay drafts. Detected here so we emit - // both nodes from the single source line. - const colonSplit = trySplitNameColonDialogue(line); - if (colonSplit) { - flushAction(); - nodes.push(block('character', colonSplit.name)); - nodes.push(block('dialogue', colonSplit.dialogue)); - inDialogueBlock = true; - continue; - } + // Same-line "Name: Dialogue" form — common in plain-text Malayalam + // (and some English) screenplay drafts. Detected here so we emit + // both nodes from the single source line. + const colonSplit = trySplitNameColonDialogue(line); + if (colonSplit) { + flushAction(); + nodes.push(block('character', colonSplit.name)); + nodes.push(block('dialogue', colonSplit.dialogue)); + inDialogueBlock = true; + continue; + } - if (looksLikeCharacterName(line, peekNextNonBlank(i))) { - flushAction(); - // Strip the trailing colon (it was a marker, not part of the name). - const name = line.replace(/:\s*$/, '').trim(); - nodes.push(block('character', name)); - inDialogueBlock = true; - continue; - } + if (looksLikeCharacterName(line, peekNextNonBlank(i))) { + flushAction(); + // Strip the trailing colon (it was a marker, not part of the name). + const name = line.replace(/:\s*$/, '').trim(); + nodes.push(block('character', name)); + inDialogueBlock = true; + continue; + } - // Default — accumulate into the action buffer so a wrapped paragraph - // stays one node. - actionBuffer.push(line); - } + // Default — accumulate into the action buffer so a wrapped paragraph + // stays one node. + actionBuffer.push(line); + } - flushAction(); + flushAction(); - // Editor schema requires at least one block. If the input parsed to - // nothing, hand back the same minimal-doc shape new_screenplay uses. - if (nodes.length === 0) { - nodes.push({ type: 'scene_heading' }); - } + // Editor schema requires at least one block. If the input parsed to + // nothing, hand back the same minimal-doc shape new_screenplay uses. + if (nodes.length === 0) { + nodes.push({ type: 'scene_heading' }); + } - return { type: 'doc', content: nodes }; + return { type: 'doc', content: nodes }; } diff --git a/src/lib/editor/sceneTimeOfDay.ts b/src/lib/editor/sceneTimeOfDay.ts index c7c0db7..3d39ce0 100644 --- a/src/lib/editor/sceneTimeOfDay.ts +++ b/src/lib/editor/sceneTimeOfDay.ts @@ -20,7 +20,12 @@ export const sceneTimeKey = new PluginKey('scene-time'); * `'time-day'` / `'time-night'` for tinted classes, or empty string * when no recognized time word appears (so the numeral stays neutral). */ function classFor(headingText: string): string { - const tail = headingText.split(/\s[-–—]\s|\s-\s/).pop()?.trim().toUpperCase() ?? ''; + const tail = + headingText + .split(/\s[-–—]\s|\s-\s/) + .pop() + ?.trim() + .toUpperCase() ?? ''; if (/\b(NIGHT|DUSK|EVENING)\b/.test(tail)) return 'time-night'; if (/\b(DAY|DAWN|MORNING|AFTERNOON)\b/.test(tail)) return 'time-day'; return ''; diff --git a/src/lib/editor/schema.ts b/src/lib/editor/schema.ts index 7924d1e..027c587 100644 --- a/src/lib/editor/schema.ts +++ b/src/lib/editor/schema.ts @@ -114,11 +114,7 @@ const marks: Record = { return ['em', 0]; }, // Parse and tags back into the italic mark - parseDOM: [ - { tag: 'em' }, - { tag: 'i' }, - { style: 'font-style=italic' } - ] + parseDOM: [{ tag: 'em' }, { tag: 'i' }, { style: 'font-style=italic' }] }, underline: { @@ -127,10 +123,7 @@ const marks: Record = { return ['u', 0]; }, // Parse tags and text-decoration style back into the underline mark - parseDOM: [ - { tag: 'u' }, - { style: 'text-decoration=underline' } - ] + parseDOM: [{ tag: 'u' }, { style: 'text-decoration=underline' }] } }; diff --git a/src/lib/editor/smartQuotes.ts b/src/lib/editor/smartQuotes.ts index 298c1bf..9c633f3 100644 --- a/src/lib/editor/smartQuotes.ts +++ b/src/lib/editor/smartQuotes.ts @@ -11,75 +11,79 @@ import type { Transaction } from 'prosemirror-state'; * rather than opens. Whitespace, line start, and openers cause the * quote to open. */ function isClosingContext(ch: string | undefined): boolean { - if (!ch) return false; // start of node — opening - // Whitespace and brackets/parens force opening regardless of script. - if (/[\s({[]/.test(ch)) return false; - return true; + if (!ch) return false; // start of node — opening + // Whitespace and brackets/parens force opening regardless of script. + if (/[\s({[]/.test(ch)) return false; + return true; } /** Detect if a character looks like Malayalam script (or any non-Latin * Indic block we want to leave alone). Conservative — only Latin and * common ASCII drive the quote-pair logic. */ function isMalayalamRange(ch: string): boolean { - const code = ch.codePointAt(0) ?? 0; - // Malayalam: U+0D00–U+0D7F. Extend later if other Indic scripts also - // need quote-protection — but for now Malayalam is the only one we - // ship input methods for, so it's the only realistic case. - return code >= 0x0d00 && code <= 0x0d7f; + const code = ch.codePointAt(0) ?? 0; + // Malayalam: U+0D00–U+0D7F. Extend later if other Indic scripts also + // need quote-protection — but for now Malayalam is the only one we + // ship input methods for, so it's the only realistic case. + return code >= 0x0d00 && code <= 0x0d7f; } const STRAIGHT_DOUBLE = '"'; const STRAIGHT_SINGLE = "'"; -const LEFT_DOUBLE = '“'; // " -const RIGHT_DOUBLE = '”'; // " -const LEFT_SINGLE = '‘'; // ' -const RIGHT_SINGLE = '’'; // ' +const LEFT_DOUBLE = '“'; // " +const RIGHT_DOUBLE = '”'; // " +const LEFT_SINGLE = '‘'; // ' +const RIGHT_SINGLE = '’'; // ' /** Plugin: scans every doc-changing transaction's inserted slice for * straight quotes that were just typed, and rewrites them in-place to * the appropriate curly form based on the preceding character. */ export const smartQuotesPlugin = new Plugin({ - appendTransaction(transactions: readonly Transaction[], _oldState, newState) { - if (!transactions.some((tr) => tr.docChanged)) return null; + appendTransaction(transactions: readonly Transaction[], _oldState, newState) { + if (!transactions.some((tr) => tr.docChanged)) return null; - const { doc } = newState; - let tr = newState.tr; - let changed = false; + const { doc } = newState; + let tr = newState.tr; + let changed = false; - // Walk every transaction's step ranges to find recently typed regions. - // We only need to consider positions whose character is a straight - // quote AND whose predecessor isn't already curly (so we don't loop). - for (const transaction of transactions) { - transaction.steps.forEach((step, idx) => { - const map = step.getMap(); - map.forEach((_oldStart, _oldEnd, newStart, newEnd) => { - // Walk every position in the inserted range. - for (let pos = newStart; pos < newEnd; pos++) { - const ch = doc.textBetween(pos, pos + 1); - if (ch !== STRAIGHT_DOUBLE && ch !== STRAIGHT_SINGLE) continue; + // Walk every transaction's step ranges to find recently typed regions. + // We only need to consider positions whose character is a straight + // quote AND whose predecessor isn't already curly (so we don't loop). + for (const transaction of transactions) { + transaction.steps.forEach((step, idx) => { + const map = step.getMap(); + map.forEach((_oldStart, _oldEnd, newStart, newEnd) => { + // Walk every position in the inserted range. + for (let pos = newStart; pos < newEnd; pos++) { + const ch = doc.textBetween(pos, pos + 1); + if (ch !== STRAIGHT_DOUBLE && ch !== STRAIGHT_SINGLE) continue; - // Look at the character immediately before. textBetween at - // start-of-doc returns '' which we treat as opening context. - const prev = pos > 0 ? doc.textBetween(pos - 1, pos) : ''; + // Look at the character immediately before. textBetween at + // start-of-doc returns '' which we treat as opening context. + const prev = pos > 0 ? doc.textBetween(pos - 1, pos) : ''; - // Skip in Malayalam runs — adjacent Malayalam text means the - // writer is inside a Malayalam phrase; curly quotes are wrong - // there. Also skip if the previous char is already a curly - // quote (defensive — shouldn't happen, but cheap to check). - if (prev && isMalayalamRange(prev)) continue; + // Skip in Malayalam runs — adjacent Malayalam text means the + // writer is inside a Malayalam phrase; curly quotes are wrong + // there. Also skip if the previous char is already a curly + // quote (defensive — shouldn't happen, but cheap to check). + if (prev && isMalayalamRange(prev)) continue; - const replacement = - ch === STRAIGHT_DOUBLE - ? isClosingContext(prev) ? RIGHT_DOUBLE : LEFT_DOUBLE - : isClosingContext(prev) ? RIGHT_SINGLE : LEFT_SINGLE; + const replacement = + ch === STRAIGHT_DOUBLE + ? isClosingContext(prev) + ? RIGHT_DOUBLE + : LEFT_DOUBLE + : isClosingContext(prev) + ? RIGHT_SINGLE + : LEFT_SINGLE; - tr = tr.replaceWith(pos, pos + 1, newState.schema.text(replacement)); - changed = true; - } - }); - }); - } + tr = tr.replaceWith(pos, pos + 1, newState.schema.text(replacement)); + changed = true; + } + }); + }); + } - return changed ? tr : null; - }, + return changed ? tr : null; + } }); diff --git a/src/lib/stores/documentStore.svelte.ts b/src/lib/stores/documentStore.svelte.ts index 72a25b3..0544c05 100644 --- a/src/lib/stores/documentStore.svelte.ts +++ b/src/lib/stores/documentStore.svelte.ts @@ -7,70 +7,70 @@ import type { MessageDialogResult } from '@tauri-apps/plugin-dialog'; /** TypeScript interface matching the Rust ScreenplayDocument struct */ export interface ScreenplayMeta { - title: string; - author: string; - director: string; - /** One-line tagline / logline rendered below the title on the title page. */ - tagline: string; - /** Registration / copyright identifier shown alongside contact info. */ - registration_number: string; - /** Short footnote printed at the bottom of the title page — typically - * a confidentiality line, a "based on" credit, or a dedication. */ - footnote: string; - contact: string; - draft_number: number; - draft_date: string; - created_at: string; - updated_at: string; - /** Non-standard Fountain title-page keys (`Source`, `Copyright`, custom - * keys) preserved verbatim for round-trip. Empty/absent on documents - * that never went through Fountain import. (#185 / #184) */ - extra?: Record; + title: string; + author: string; + director: string; + /** One-line tagline / logline rendered below the title on the title page. */ + tagline: string; + /** Registration / copyright identifier shown alongside contact info. */ + registration_number: string; + /** Short footnote printed at the bottom of the title page — typically + * a confidentiality line, a "based on" credit, or a dedication. */ + footnote: string; + contact: string; + draft_number: number; + draft_date: string; + created_at: string; + updated_at: string; + /** Non-standard Fountain title-page keys (`Source`, `Copyright`, custom + * keys) preserved verbatim for round-trip. Empty/absent on documents + * that never went through Fountain import. (#185 / #184) */ + extra?: Record; } export interface ScreenplaySettings { - font: string; - default_language: string; - input_scheme: string; - scene_number_start: number; - show_characters_below_header: boolean; - /** Editor font size in pixels — clamped to 10..=18 by the backend. - * PDF output is unaffected (PDFs use their own fixed sizes). (#123) */ - editor_font_size: number; + font: string; + default_language: string; + input_scheme: string; + scene_number_start: number; + show_characters_below_header: boolean; + /** Editor font size in pixels — clamped to 10..=18 by the backend. + * PDF output is unaffected (PDFs use their own fixed sizes). (#123) */ + editor_font_size: number; } export interface ScreenplayStory { - idea: string; - synopsis: string; - treatment: string; - narrative: string; + idea: string; + synopsis: string; + treatment: string; + narrative: string; } export interface SceneCard { - /** 0-based pointer into the flat ordered list of scene_heading nodes in - * `content` — `scene_index: 0` is the first scene in document order. - * - * Not a stable ID: reordering or deleting scenes rewrites every card's - * `scene_index` to stay aligned (see SceneCardsView drag/delete). - * - * Series: `buildSeriesExportDocument` flattens episode cards into a - * single list by offsetting each episode's `scene_index` by the number - * of scene_headings in earlier episodes, so the backend always sees a - * flat index against the whole exported document. */ - scene_index: number; - description: string; - shoot_notes: string; - /** Comma-separated characters present in the scene but with no dialogue. - * Merged with auto-detected speakers when rendering the characters line. */ - extra_characters: string; - /** ISO date or free-form "Day N" string for shoot planning (#124). - * Empty means unscheduled. Stored as a string so partial planning - * values are permitted before a real schedule firms up. */ - scheduled_date: string; - /** Free-text grouping tag — typically the real-world filming location. - * Empty means ungrouped. SceneCardsView's "Group by location" toggle - * clusters cards on this value. (#124) */ - location_group: string; + /** 0-based pointer into the flat ordered list of scene_heading nodes in + * `content` — `scene_index: 0` is the first scene in document order. + * + * Not a stable ID: reordering or deleting scenes rewrites every card's + * `scene_index` to stay aligned (see SceneCardsView drag/delete). + * + * Series: `buildSeriesExportDocument` flattens episode cards into a + * single list by offsetting each episode's `scene_index` by the number + * of scene_headings in earlier episodes, so the backend always sees a + * flat index against the whole exported document. */ + scene_index: number; + description: string; + shoot_notes: string; + /** Comma-separated characters present in the scene but with no dialogue. + * Merged with auto-detected speakers when rendering the characters line. */ + extra_characters: string; + /** ISO date or free-form "Day N" string for shoot planning (#124). + * Empty means unscheduled. Stored as a string so partial planning + * values are permitted before a real schedule firms up. */ + scheduled_date: string; + /** Free-text grouping tag — typically the real-world filming location. + * Empty means ungrouped. SceneCardsView's "Group by location" toggle + * clusters cards on this value. (#124) */ + location_group: string; } /** Lifecycle marker for an episode (#141). */ @@ -80,20 +80,20 @@ export type EpisodeStatus = 'outline' | 'draft' | 'revision' | 'final'; * editor feature (navigator, export, scene cards, story) can run against it * without branching on project type. */ export interface Episode { - id: string; - number: number; - title: string; - status: EpisodeStatus; - content: unknown; - meta: ScreenplayMeta; - settings: ScreenplaySettings; - story: ScreenplayStory; - scene_cards: SceneCard[]; + id: string; + number: number; + title: string; + status: EpisodeStatus; + content: unknown; + meta: ScreenplayMeta; + settings: ScreenplaySettings; + story: ScreenplayStory; + scene_cards: SceneCard[]; } export interface SeriesData { - title: string; - episodes: Episode[]; + title: string; + episodes: Episode[]; } /** `"film"` means the top-level meta/settings/story/content/scene_cards are @@ -103,22 +103,22 @@ export interface SeriesData { export type ProjectType = 'film' | 'series'; export interface ScreenplayDocument { - type?: ProjectType; - series?: SeriesData | null; - content: unknown; - meta: ScreenplayMeta; - settings: ScreenplaySettings; - story: ScreenplayStory; - scene_cards: SceneCard[]; + type?: ProjectType; + series?: SeriesData | null; + content: unknown; + meta: ScreenplayMeta; + settings: ScreenplaySettings; + story: ScreenplayStory; + scene_cards: SceneCard[]; } /** Returned by the `load_autosave` Tauri command when an autosave sidecar * is newer than the file the user just opened (#118). The frontend * prompts the writer with Restore / Discard before applying. */ export interface AutosaveInfo { - document: ScreenplayDocument; - autosave_time_ms: number; - original_time_ms: number; + document: ScreenplayDocument; + autosave_time_ms: number; + original_time_ms: number; } /** Counts and warnings produced by the Fountain importer. Mirrors @@ -126,21 +126,21 @@ export interface AutosaveInfo { * The frontend renders these into the import-summary toast so the * writer knows what was transformed or dropped. (#187) */ export interface FountainImportSummary { - boneyards_dropped: number; - notes_count: number; - synopses_count: number; - sections_count: number; - dual_dialogue_count: number; - scene_numbers_dropped: number; - emphasis_stripped: number; - warnings: string[]; + boneyards_dropped: number; + notes_count: number; + synopses_count: number; + sections_count: number; + dual_dialogue_count: number; + scene_numbers_dropped: number; + emphasis_stripped: number; + warnings: string[]; } /** Bundle returned by `import_fountain_as_film` / `import_fountain_as_episode`. * Matches `FountainImportResult` on the Rust side. */ export interface FountainImportResult { - document: ScreenplayDocument; - summary: FountainImportSummary; + document: ScreenplayDocument; + summary: FountainImportSummary; } /** Counts and warnings produced by the Final Draft (.fdx) importer. @@ -151,27 +151,27 @@ export interface FountainImportResult { * doesn't. The toast renderer treats the summary as a generic counts * bag and only shows non-zero entries. (#192) */ export interface FdxImportSummary { - dual_dialogue_count: number; - script_notes_dropped: number; - revisions_dropped: number; - locked_scene_numbers_dropped: number; - tag_data_dropped: number; - unknown_types_folded_to_action: number; - warnings: string[]; + dual_dialogue_count: number; + script_notes_dropped: number; + revisions_dropped: number; + locked_scene_numbers_dropped: number; + tag_data_dropped: number; + unknown_types_folded_to_action: number; + warnings: string[]; } /** Bundle returned by `import_fdx_as_film` / `import_fdx_as_episode`. * Matches `FdxImportResult` on the Rust side. */ export interface FdxImportResult { - document: ScreenplayDocument; - summary: FdxImportSummary; + document: ScreenplayDocument; + summary: FdxImportSummary; } /** Discriminated-union summary the toast component consumes. The kind * field tells the renderer which set of count labels to surface. */ export type AnyImportSummary = - | ({ kind: 'fountain' } & FountainImportSummary) - | ({ kind: 'fdx' } & FdxImportSummary); + | ({ kind: 'fountain' } & FountainImportSummary) + | ({ kind: 'fdx' } & FdxImportSummary); /** Convert a ProseMirror-ish content payload into canonical * `{type:'doc', content:[...]}` shape. Accepts three inputs: @@ -184,808 +184,815 @@ export type AnyImportSummary = * layer (Scene Navigator, Scene Cards, PDF export) see the same shape the * Editor does — without pulling ProseMirror into the store module. */ function normalizeContentPayload(content: unknown): unknown { - const emptyDoc = { type: 'doc', content: [] as unknown[] }; - if (!content) return emptyDoc; - const wrapNode = (raw: unknown): unknown => { - if (!raw || typeof raw !== 'object') return null; - const node = raw as { type?: string; text?: unknown; content?: unknown }; - if (typeof node.type !== 'string') return null; - if (Array.isArray(node.content)) return node; - if (typeof node.text === 'string') { - const text = node.text; - const inline = text.length > 0 ? [{ type: 'text', text }] : []; - return { type: node.type, content: inline }; - } - return { type: node.type, content: [] }; - }; - if (Array.isArray(content)) { - const children = content.map(wrapNode).filter((n): n is object => n !== null); - return { type: 'doc', content: children }; - } - if (typeof content === 'object') { - const obj = content as { type?: string; content?: unknown }; - if (obj.type === 'doc') return content; - const wrapped = wrapNode(content); - return { type: 'doc', content: wrapped ? [wrapped] : [] }; - } - return emptyDoc; + const emptyDoc = { type: 'doc', content: [] as unknown[] }; + if (!content) return emptyDoc; + const wrapNode = (raw: unknown): unknown => { + if (!raw || typeof raw !== 'object') return null; + const node = raw as { type?: string; text?: unknown; content?: unknown }; + if (typeof node.type !== 'string') return null; + if (Array.isArray(node.content)) return node; + if (typeof node.text === 'string') { + const text = node.text; + const inline = text.length > 0 ? [{ type: 'text', text }] : []; + return { type: node.type, content: inline }; + } + return { type: node.type, content: [] }; + }; + if (Array.isArray(content)) { + const children = content.map(wrapNode).filter((n): n is object => n !== null); + return { type: 'doc', content: children }; + } + if (typeof content === 'object') { + const obj = content as { type?: string; content?: unknown }; + if (obj.type === 'doc') return content; + const wrapped = wrapNode(content); + return { type: 'doc', content: wrapped ? [wrapped] : [] }; + } + return emptyDoc; } /** Reactive document store — tracks the open file, its path, and dirty state */ class DocumentStore { - document = $state(null); - currentPath = $state(null); - isDirty = $state(false); - - /** Which episode the editor is currently showing, when the document is a - * Series project. Zero for film projects and ignored then. Persists - * across saves within a session but not across files — switching files - * resets to 0. */ - activeEpisodeIndex = $state(0); - - /** Timestamp (ms since epoch) of the last successful save. Null until the - * document has been saved at least once this session. Consumed by the - * status bar to render "Saved 2 min ago" relative time. */ - lastSavedAt = $state(null); - - /** Incremented only on newDocument() and openDocument() — signals the editor - * to reload its ProseMirror state. Not incremented by setContent(). */ - loadTrigger = $state(0); - - /** Snapshot of the document content at load time — only updated by New/Open, - * never by setContent(). The editor $effect reads this instead of document.content - * to avoid re-triggering on every keystroke. */ - loadedContent = $state(null); - - /** Monotonically increasing on every content change (setContent, episode - * switch, new/open). Consumers that only need eventually-consistent views - * of the document (Scene Navigator, Scene Cards, Statistics, etc.) read - * `contentVersionDebounced` below — bumped ~200 ms after the latest - * edit — to skip the per-keystroke recompute storm (#98–#101). */ - contentVersion = $state(0); - - /** Trails `contentVersion` by ~200 ms of idle. Reading this in a $derived - * re-runs once per "burst of typing" rather than once per keystroke. - * Components that need real-time updates (the editor itself) keep reading - * `activeContent` directly. */ - contentVersionDebounced = $state(0); - - /** Internal debounce timer for `contentVersionDebounced`. Module-level - * rather than $state because a setTimeout handle is not reactive data. */ - #debounceTimer: ReturnType | null = null; - #DEBOUNCE_MS = 200; - - /** Bump both content versions. The debounced one trails by DEBOUNCE_MS - * of typing-idle; the immediate one fires synchronously for any consumer - * that wants tightly coupled reactivity. Episode switches and new/open - * flush both immediately (the writer expects an instant view refresh - * when changing context). */ - #bumpContentVersion(immediate: boolean = false): void { - this.contentVersion++; - if (immediate) { - if (this.#debounceTimer) { - clearTimeout(this.#debounceTimer); - this.#debounceTimer = null; - } - this.contentVersionDebounced = this.contentVersion; - return; - } - if (this.#debounceTimer) clearTimeout(this.#debounceTimer); - this.#debounceTimer = setTimeout(() => { - this.contentVersionDebounced = this.contentVersion; - this.#debounceTimer = null; - }, this.#DEBOUNCE_MS); - } - - /** True when the open document is a multi-episode Series. Missing or - * `"film"` on the top-level `type` field both count as film (old files - * never wrote the field). */ - get isSeries(): boolean { - return this.document?.type === 'series'; - } - - /** The episode currently in focus for a Series project; null for films or - * when no document is open. Safe to access without null-checking the - * whole series structure. */ - get activeEpisode(): Episode | null { - if (!this.document || this.document.type !== 'series') return null; - const eps = this.document.series?.episodes ?? []; - if (eps.length === 0) return null; - const i = Math.max(0, Math.min(this.activeEpisodeIndex, eps.length - 1)); - return eps[i]; - } - - /** Meta for the part of the document the editor is currently showing. - * Series → active episode's meta; Film → top-level meta. Returns null when - * no document is open. Consumers that edit should route through the - * corresponding setters (or mutate in-place; svelte-5 state reacts either - * way) so both shapes stay consistent. */ - get activeMeta(): ScreenplayMeta | null { - if (!this.document) return null; - if (this.isSeries) return this.activeEpisode?.meta ?? null; - return this.document.meta; - } - - get activeSettings(): ScreenplaySettings | null { - if (!this.document) return null; - if (this.isSeries) return this.activeEpisode?.settings ?? null; - return this.document.settings; - } - - get activeStory(): ScreenplayStory | null { - if (!this.document) return null; - if (this.isSeries) return this.activeEpisode?.story ?? null; - return this.document.story; - } - - get activeContent(): unknown { - if (!this.document) return null; - if (this.isSeries) return this.activeEpisode?.content ?? null; - return this.document.content; - } - - get activeSceneCards(): SceneCard[] { - if (!this.document) return []; - if (this.isSeries) return this.activeEpisode?.scene_cards ?? []; - return this.document.scene_cards; - } - - /** Replace the active scene_cards array. Needed by flows that rebuild the - * list after a reorder/delete rather than mutating in place. */ - setActiveSceneCards(cards: SceneCard[]): void { - if (!this.document) return; - if (this.isSeries) { - const ep = this.activeEpisode; - if (ep) ep.scene_cards = cards; - } else { - this.document.scene_cards = cards; - } - } - - /** Switch which episode is active in the editor. Bumps `loadTrigger` so - * the editor reloads its ProseMirror state from the new episode's content; - * otherwise the view would keep showing the previous episode's doc. */ - setActiveEpisode(index: number): void { - if (!this.document || this.document.type !== 'series') return; - const eps = this.document.series?.episodes ?? []; - if (eps.length === 0) return; - const clamped = Math.max(0, Math.min(index, eps.length - 1)); - if (clamped === this.activeEpisodeIndex) return; - this.activeEpisodeIndex = clamped; - this.loadedContent = eps[clamped].content; - this.loadTrigger++; - // Episode switch is a context change — refresh derived views immediately, - // not on the typing-debounce. - this.#bumpContentVersion(true); - } - - /** Create a brand-new Series project with one empty episode. Title can - * be edited later via the Scene Navigator's inline rename. */ - async newSeries(seriesTitle: string): Promise { - try { - const doc = await invoke('new_screenplay'); - const ep = this.createEmptyEpisode(1, ''); - doc.type = 'series'; - doc.series = { title: seriesTitle, episodes: [ep] }; - this.document = doc; - this.currentPath = null; - this.isDirty = false; - this.lastSavedAt = null; - this.activeEpisodeIndex = 0; - this.loadedContent = ep.content; - this.loadTrigger++; - this.#bumpContentVersion(true); - } catch (error) { - console.error('Failed to create new series:', error); - } - } - - /** Append a new empty episode to the open series and activate it. */ - async addEpisode(title: string = ''): Promise { - if (!this.document || this.document.type !== 'series' || !this.document.series) return; - const number = this.document.series.episodes.length + 1; - const ep = this.createEmptyEpisode(number, title); - this.document.series.episodes.push(ep); - this.markDirty(); - this.setActiveEpisode(this.document.series.episodes.length - 1); - } - - /** Remove the episode at `index`. Refuses to drop the last remaining - * episode — a series with zero episodes isn't a meaningful state. */ - removeEpisode(index: number): void { - if (!this.document || this.document.type !== 'series' || !this.document.series) return; - const eps = this.document.series.episodes; - if (eps.length <= 1) return; - if (index < 0 || index >= eps.length) return; - eps.splice(index, 1); - this.renumberEpisodes(); - // Keep the active selection valid even if we removed at/before it. - const nextIndex = Math.max(0, Math.min(this.activeEpisodeIndex, eps.length - 1)); - this.activeEpisodeIndex = -1; // force loadTrigger bump even if clamping lands on same index - this.setActiveEpisode(nextIndex); - this.markDirty(); - } - - /** Move an episode up or down in the list; renumbers sequentially. */ - reorderEpisode(from: number, to: number): void { - if (!this.document || this.document.type !== 'series' || !this.document.series) return; - const eps = this.document.series.episodes; - if (from < 0 || from >= eps.length || to < 0 || to >= eps.length || from === to) return; - const moved = eps.splice(from, 1)[0]; - eps.splice(to, 0, moved); - this.renumberEpisodes(); - // If the active episode moved, keep the same Episode object active. - if (from === this.activeEpisodeIndex) this.activeEpisodeIndex = to; - else if (from < this.activeEpisodeIndex && to >= this.activeEpisodeIndex) this.activeEpisodeIndex--; - else if (from > this.activeEpisodeIndex && to <= this.activeEpisodeIndex) this.activeEpisodeIndex++; - this.markDirty(); - } - - /** Rename an episode in-place. */ - renameEpisode(index: number, title: string): void { - const eps = this.document?.series?.episodes; - if (!eps || index < 0 || index >= eps.length) return; - eps[index].title = title; - this.markDirty(); - } - - /** Update an episode's lifecycle status (#141). */ - setEpisodeStatus(index: number, status: EpisodeStatus): void { - const eps = this.document?.series?.episodes; - if (!eps || index < 0 || index >= eps.length) return; - eps[index].status = status; - this.markDirty(); - } - - /** Rename the series as a whole. */ - renameSeries(title: string): void { - if (!this.document?.series) return; - this.document.series.title = title; - this.markDirty(); - } - - private renumberEpisodes(): void { - const eps = this.document?.series?.episodes; - if (!eps) return; - for (let i = 0; i < eps.length; i++) eps[i].number = i + 1; - } - - private createEmptyEpisode(number: number, title: string): Episode { - // Episodes inherit the document's current font, default language, - // and input scheme so the series stays typographically coherent — - // falling back to blank defaults only when there is no document yet - // (e.g. creating the very first episode of a brand-new series). - const docSettings = this.document?.settings; - const settings = this.blankSettings(); - if (docSettings) { - settings.font = docSettings.font; - settings.default_language = docSettings.default_language; - settings.input_scheme = docSettings.input_scheme; - } - return { - id: typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : String(Date.now() + Math.random()), - number, - title, - status: 'outline', - content: { type: 'doc', content: [{ type: 'scene_heading' }] }, - meta: this.blankMeta(), - settings, - story: { idea: '', synopsis: '', treatment: '', narrative: '' }, - scene_cards: [], - }; - } - - private blankMeta(): ScreenplayMeta { - return { - title: '', - author: '', - director: '', - tagline: '', - registration_number: '', - footnote: '', - contact: '', - draft_number: 1, - draft_date: '', - created_at: '', - updated_at: '', - }; - } - - private blankSettings(): ScreenplaySettings { - return { - font: 'manjari', - default_language: 'malayalam', - input_scheme: 'mozhi', - scene_number_start: 1, - show_characters_below_header: false, - editor_font_size: 14, - }; - } - - /** Create a new empty screenplay via the Rust backend. - * Optional `title` is written into meta.title so the title-page - * preview, the OS window title, and the eventual "Save As" filename - * all carry the writer's chosen name from the first frame. */ - async newDocument(title?: string): Promise { - try { - const doc = await invoke('new_screenplay'); - if (title && title.trim()) { - doc.meta.title = title.trim(); - } - this.document = doc; - this.currentPath = null; - this.isDirty = false; - this.lastSavedAt = null; - this.activeEpisodeIndex = 0; - this.loadedContent = doc.content; - this.loadTrigger++; - this.#bumpContentVersion(true); - } catch (error) { - console.error('Failed to create new screenplay:', error); - } - } - - /** Create a new screenplay seeded with parsed content from a paste-to-script - * flow (#122). Same as newDocument() but with the editor preloaded, marked - * dirty so the writer is reminded to save. */ - async newDocumentFromContent(content: unknown): Promise { - try { - const doc = await invoke('new_screenplay'); - doc.content = content; - this.document = doc; - this.currentPath = null; - this.isDirty = true; - this.lastSavedAt = null; - this.activeEpisodeIndex = 0; - this.loadedContent = content; - this.loadTrigger++; - this.#bumpContentVersion(true); - } catch (error) { - console.error('Failed to create screenplay from pasted content:', error); - } - } - - /** Save the current document. If path is provided, save there; otherwise use currentPath. */ - async saveDocument(path?: string): Promise { - const savePath = path ?? this.currentPath; - if (!savePath || !this.document) return; - - try { - await invoke('save_screenplay', { path: savePath, document: this.document }); - this.currentPath = savePath; - this.isDirty = false; - this.lastSavedAt = Date.now(); - // Real save committed — the autosave sidecar is now stale, drop it - // (see #118). Cancel any pending autosave timer so we don't write - // a fresh sidecar moments after deleting the old one. - this.cancelPendingAutosave(); - void this.#discardAutosave(savePath); - - // If no explicit title set, derive it from the filename - if (!this.document.meta.title) { - const filename = savePath.split('/').pop() ?? savePath.split('\\').pop() ?? savePath; - this.document.meta.title = filename.replace(/\.screenplay$/, ''); - } - } catch (error) { - console.error('Failed to save screenplay:', error); - } - } - - /** Open a screenplay file from disk */ - async openDocument(path: string): Promise { - try { - let doc = await invoke('open_screenplay', { path }); - - // Crash-recovery (#118): if a `.autosave` sidecar exists and - // is newer than the file we just loaded, the previous session - // crashed (or quit without saving) with unsaved work. Offer the - // writer a choice — restore the autosave or keep the saved file. - try { - const autosave = await invoke('load_autosave', { path }); - if (autosave) { - const minutesOld = Math.max( - 1, - Math.round((autosave.autosave_time_ms - autosave.original_time_ms) / 60_000), - ); - const result: MessageDialogResult = await message( - `Scriptty found unsaved changes from your last session (about ${minutesOld} min newer than the saved file). Restore them?`, - { - title: 'Recover unsaved changes?', - kind: 'info', - buttons: { yes: 'Restore', no: 'Discard', cancel: 'Decide later' }, - }, - ); - if (result === 'Restore') { - doc = autosave.document; - // Keep the autosave file in place; the user is now editing - // recovered content, and a save will overwrite both. - } else if (result === 'Discard') { - // Drop the stale sidecar so we don't keep prompting. - void this.#discardAutosave(path); - } - // 'Cancel' (Decide later) — keep the autosave file untouched and - // proceed with the saved version. We'll prompt again next open. - } - } catch (recoverErr) { - // Recovery is best-effort; never block opening the document. - console.warn('Autosave recovery check failed:', recoverErr); - } - - // Normalize every content payload into canonical ProseMirror shape - // before anything else reads it. Slim-format files (series authored - // by hand, Fountain-like episode blocks) can store content as a flat - // [{type,text},...] array — the Editor's own normalizer handles it on - // load, but the Scene Navigator / Scene Cards read content straight - // from the store and would otherwise find no `content.content` to walk. - doc.content = normalizeContentPayload(doc.content); - if (doc.type === 'series' && doc.series) { - for (const ep of doc.series.episodes) { - ep.content = normalizeContentPayload(ep.content); - } - } - this.document = doc; - this.currentPath = path; - this.isDirty = false; - this.lastSavedAt = null; - this.activeEpisodeIndex = 0; - // In series mode, hand the active episode's content to the editor. - // Fall back to the top-level content for film and any malformed series. - const loaded = doc.type === 'series' ? doc.series?.episodes?.[0]?.content ?? doc.content : doc.content; - this.loadedContent = loaded; - this.loadTrigger++; - this.#bumpContentVersion(true); - } catch (error) { - console.error('Failed to open screenplay:', error); - } - } - - /** Parse a .fountain file into a brand-new Film document and load it - * into the store. Goes through the dirty-state guard via the caller. - * Returns the import summary (dropped boneyards, stripped emphasis, - * warnings) so the UI can surface a toast. Returns null if loading - * failed at the Tauri layer (file unreadable / parse error) — in - * that case an error message has already been logged. (#187) */ - async importFountainAsFilm(path: string): Promise { - try { - const result = await invoke('import_fountain_as_film', { path }); - const doc = result.document; - doc.content = normalizeContentPayload(doc.content); - this.document = doc; - // Imported docs are dirty by default — the writer hasn't saved as a - // .screenplay yet, and we want Save to surface the file dialog - // rather than silently writing back to the .fountain path. - this.currentPath = null; - this.isDirty = true; - this.lastSavedAt = null; - this.activeEpisodeIndex = 0; - this.loadedContent = doc.content; - this.loadTrigger++; - this.#bumpContentVersion(true); - return result.summary; - } catch (error) { - console.error('Failed to import Fountain file:', error); - await message(`Could not import Fountain file: ${error}`, { - title: 'Import failed', - kind: 'error', - }); - return null; - } - } - - /** Parse a .fountain file as a single episode and append it to the - * current Series document. Switches the active episode pointer to - * the new one so the editor lands on the imported content. Errors - * out via a native dialog if the current document isn't a series. - * (#187) */ - async importFountainAsEpisode(path: string): Promise { - if (!this.document || this.document.type !== 'series') { - await message( - 'Open a Series project first — Fountain files can only be imported as episodes into an existing series.', - { title: 'No series open', kind: 'info' }, - ); - return null; - } - try { - const result = await invoke('import_fountain_as_episode', { - path, - currentDocument: this.document, - }); - const doc = result.document; - // Normalize the new episode's content along with the rest. - if (doc.type === 'series' && doc.series) { - for (const ep of doc.series.episodes) { - ep.content = normalizeContentPayload(ep.content); - } - } - this.document = doc; - this.isDirty = true; - this.lastSavedAt = null; - // Land the editor on the freshly-imported episode (always the last - // entry, since we appended). - const lastIdx = (doc.series?.episodes.length ?? 1) - 1; - this.activeEpisodeIndex = lastIdx; - this.loadedContent = - doc.series?.episodes?.[lastIdx]?.content ?? doc.content; - this.loadTrigger++; - this.#bumpContentVersion(true); - return result.summary; - } catch (error) { - console.error('Failed to import Fountain as episode:', error); - await message(`Could not import Fountain file: ${error}`, { - title: 'Import failed', - kind: 'error', - }); - return null; - } - } - - /** Parse a .fdx (Final Draft) file into a brand-new Film document. - * Mirrors importFountainAsFilm — same dirty/path semantics. (#192) */ - async importFdxAsFilm(path: string): Promise { - try { - const result = await invoke('import_fdx_as_film', { path }); - const doc = result.document; - doc.content = normalizeContentPayload(doc.content); - this.document = doc; - this.currentPath = null; - this.isDirty = true; - this.lastSavedAt = null; - this.activeEpisodeIndex = 0; - this.loadedContent = doc.content; - this.loadTrigger++; - this.#bumpContentVersion(true); - return result.summary; - } catch (error) { - console.error('Failed to import Final Draft file:', error); - await message(`Could not import Final Draft file: ${error}`, { - title: 'Import failed', - kind: 'error', - }); - return null; - } - } - - /** Parse a .fdx file as a single episode and append it to the active - * Series. Mirrors importFountainAsEpisode. (#192) */ - async importFdxAsEpisode(path: string): Promise { - if (!this.document || this.document.type !== 'series') { - await message( - 'Open a Series project first — Final Draft files can only be imported as episodes into an existing series.', - { title: 'No series open', kind: 'info' }, - ); - return null; - } - try { - const result = await invoke('import_fdx_as_episode', { - path, - currentDocument: this.document, - }); - const doc = result.document; - if (doc.type === 'series' && doc.series) { - for (const ep of doc.series.episodes) { - ep.content = normalizeContentPayload(ep.content); - } - } - this.document = doc; - this.isDirty = true; - this.lastSavedAt = null; - const lastIdx = (doc.series?.episodes.length ?? 1) - 1; - this.activeEpisodeIndex = lastIdx; - this.loadedContent = doc.series?.episodes?.[lastIdx]?.content ?? doc.content; - this.loadTrigger++; - this.#bumpContentVersion(true); - return result.summary; - } catch (error) { - console.error('Failed to import Final Draft as episode:', error); - await message(`Could not import Final Draft file: ${error}`, { - title: 'Import failed', - kind: 'error', - }); - return null; - } - } - - /** Save with dialog — if currentPath exists, saves directly; otherwise opens a save dialog. */ - async saveWithDialog(): Promise { - console.log('[saveWithDialog] called'); - if (!this.document) return; - - if (this.currentPath) { - await this.saveDocument(this.currentPath); - } else { - const path = await save({ - filters: [{ name: 'Screenplay', extensions: ['screenplay'] }] - }); - if (!path) return; // User cancelled - await this.saveDocument(path); - } - } - - /** Save As — always opens a file dialog, even if the document has a current path */ - async saveAsDialog(): Promise { - if (!this.document) return; - - const defaultTitle = this.displayTitle; - const path = await save({ - defaultPath: defaultTitle - ? `${defaultTitle}.screenplay` - : 'untitled.screenplay', - filters: [{ name: 'Screenplay', extensions: ['screenplay'] }] - }); - if (!path) return; // User cancelled - await this.saveDocument(path); - } - - /** Get the current font setting slug (e.g. 'noto-sans-malayalam' or 'manjari') */ - get currentFont(): string { - return this.document?.settings.font ?? 'manjari'; - } - - /** Update the font setting and mark the document as dirty. - * Font is a series-wide choice, so we mirror it into every episode's - * settings alongside the top-level doc settings. Without the mirror, - * series exports (which read the first episode's settings) would keep - * rendering in the stale default font. */ - setFont(font: string): void { - if (!this.document) return; - this.document.settings.font = font; - if (this.document.type === 'series' && this.document.series) { - for (const ep of this.document.series.episodes) { - ep.settings.font = font; - } - } - this.isDirty = true; - } - - /** Mark the document as having unsaved changes */ - markDirty(): void { - this.isDirty = true; - this.#scheduleAutosave(); - } - - // ─── Autosave (#118) ────────────────────────────────────────────── - // The writer is the source of truth — never lose more than ~15s of - // typing to a crash, OS sleep, or accidental close. Autosave writes - // to `.autosave` (a sidecar) on a debounced timer; the real - // save deletes the sidecar; on next open we check if a sidecar is - // newer than the file and offer to restore it. - // - // Untitled documents (no currentPath) are skipped — they have nothing - // to autosave alongside. The writer still gets the dirty-state guard - // on close, which is the existing protection for that case. - #autosaveTimer: ReturnType | null = null; - #AUTOSAVE_DELAY_MS = 15_000; - - #scheduleAutosave(): void { - if (this.#autosaveTimer) clearTimeout(this.#autosaveTimer); - if (!this.currentPath || !this.document) return; - this.#autosaveTimer = setTimeout(() => { - this.#autosaveTimer = null; - this.#runAutosave(); - }, this.#AUTOSAVE_DELAY_MS); - } - - async #runAutosave(): Promise { - // Bail if state changed under us between scheduling and firing. - if (!this.currentPath || !this.document || !this.isDirty) return; - try { - await invoke('autosave_screenplay', { - path: this.currentPath, - document: this.document, - }); - } catch (error) { - // Best-effort — log but don't surface a toast for transient I/O failure. - console.warn('Autosave failed:', error); - } - } - - /** Cancel any pending autosave timer (called on close-without-save). */ - cancelPendingAutosave(): void { - if (this.#autosaveTimer) { - clearTimeout(this.#autosaveTimer); - this.#autosaveTimer = null; - } - } - - /** Force an immediate autosave (called from beforeunload / window close - * so a crash mid-typing never loses the most recent edits). */ - async flushAutosave(): Promise { - this.cancelPendingAutosave(); - if (this.isDirty) await this.#runAutosave(); - } - - async #discardAutosave(path: string): Promise { - try { - await invoke('discard_autosave', { path }); - } catch (error) { - console.warn('Failed to discard autosave:', error); - } - } - - /** - * If the document has unsaved changes, prompt the user to Save / Don't Save / Cancel. - * Returns true if it's safe to proceed (saved or discarded), false if cancelled. - */ - async confirmIfDirty(): Promise { - if (!this.isDirty) return true; - - try { - const result: MessageDialogResult = await message( - 'You have unsaved changes. Do you want to save before continuing?', - { - title: 'Unsaved Changes', - kind: 'warning', - buttons: { yes: 'Save', no: "Don't Save", cancel: 'Cancel' }, - } - ); - - if (result === 'Cancel') return false; - if (result === 'Save') await this.saveWithDialog(); - else { - // "Don't Save" — the user explicitly said discard. Drop the - // autosave sidecar so the next open doesn't offer to restore - // changes the user just rejected (#118). - this.cancelPendingAutosave(); - if (this.currentPath) void this.#discardAutosave(this.currentPath); - } - return true; - } catch (error) { - console.error('[confirmIfDirty] dialog error:', error); - return true; - } - } - - /** Update the document's content without marking dirty. - * Called by the editor on every doc-changing transaction to keep the store in sync. - * In series mode, routes the content into the active episode so the editor and - * stored series tree stay in sync without having to duplicate the editor plumbing. */ - setContent(content: unknown): void { - if (!this.document) return; - if (this.isSeries) { - const ep = this.activeEpisode; - if (ep) ep.content = content; - } else { - this.document.content = content; - } - // Debounced bump — Navigator/Cards/Statistics will catch up in ~200ms. - this.#bumpContentVersion(); - } - - /** Title used for save-dialog defaults and window-title formatting. In - * series mode the series title trumps the (usually blank) top-level meta - * title. */ - get displayTitle(): string { - if (!this.document) return ''; - if (this.isSeries) return this.document.series?.title ?? ''; - return this.document.meta.title ?? ''; - } - - /** A film-shaped view of the current working unit, suitable for passing to - * the Rust export pipeline. For a film project this is the document - * itself; for a series it's a shallow film-shaped wrapper around the - * active episode, with the series title prefixed into the meta.title so - * the title page shows both. Returns null if no document is open. */ - get activeExportDocument(): ScreenplayDocument | null { - const doc = this.document; - if (!doc) return null; - if (!this.isSeries) return doc; - const ep = this.activeEpisode; - if (!ep) return doc; - const seriesTitle = doc.series?.title ?? ''; - const epTitle = ep.title.trim(); - const composedTitle = epTitle - ? (seriesTitle ? `${seriesTitle} — Ep ${ep.number}: ${epTitle}` : `Ep ${ep.number}: ${epTitle}`) - : (seriesTitle ? `${seriesTitle} — Ep ${ep.number}` : `Episode ${ep.number}`); - return { - type: 'film', - series: null, - content: ep.content, - meta: { ...ep.meta, title: ep.meta.title || composedTitle }, - settings: ep.settings, - story: ep.story, - scene_cards: ep.scene_cards, - }; - } + document = $state(null); + currentPath = $state(null); + isDirty = $state(false); + + /** Which episode the editor is currently showing, when the document is a + * Series project. Zero for film projects and ignored then. Persists + * across saves within a session but not across files — switching files + * resets to 0. */ + activeEpisodeIndex = $state(0); + + /** Timestamp (ms since epoch) of the last successful save. Null until the + * document has been saved at least once this session. Consumed by the + * status bar to render "Saved 2 min ago" relative time. */ + lastSavedAt = $state(null); + + /** Incremented only on newDocument() and openDocument() — signals the editor + * to reload its ProseMirror state. Not incremented by setContent(). */ + loadTrigger = $state(0); + + /** Snapshot of the document content at load time — only updated by New/Open, + * never by setContent(). The editor $effect reads this instead of document.content + * to avoid re-triggering on every keystroke. */ + loadedContent = $state(null); + + /** Monotonically increasing on every content change (setContent, episode + * switch, new/open). Consumers that only need eventually-consistent views + * of the document (Scene Navigator, Scene Cards, Statistics, etc.) read + * `contentVersionDebounced` below — bumped ~200 ms after the latest + * edit — to skip the per-keystroke recompute storm (#98–#101). */ + contentVersion = $state(0); + + /** Trails `contentVersion` by ~200 ms of idle. Reading this in a $derived + * re-runs once per "burst of typing" rather than once per keystroke. + * Components that need real-time updates (the editor itself) keep reading + * `activeContent` directly. */ + contentVersionDebounced = $state(0); + + /** Internal debounce timer for `contentVersionDebounced`. Module-level + * rather than $state because a setTimeout handle is not reactive data. */ + #debounceTimer: ReturnType | null = null; + #DEBOUNCE_MS = 200; + + /** Bump both content versions. The debounced one trails by DEBOUNCE_MS + * of typing-idle; the immediate one fires synchronously for any consumer + * that wants tightly coupled reactivity. Episode switches and new/open + * flush both immediately (the writer expects an instant view refresh + * when changing context). */ + #bumpContentVersion(immediate: boolean = false): void { + this.contentVersion++; + if (immediate) { + if (this.#debounceTimer) { + clearTimeout(this.#debounceTimer); + this.#debounceTimer = null; + } + this.contentVersionDebounced = this.contentVersion; + return; + } + if (this.#debounceTimer) clearTimeout(this.#debounceTimer); + this.#debounceTimer = setTimeout(() => { + this.contentVersionDebounced = this.contentVersion; + this.#debounceTimer = null; + }, this.#DEBOUNCE_MS); + } + + /** True when the open document is a multi-episode Series. Missing or + * `"film"` on the top-level `type` field both count as film (old files + * never wrote the field). */ + get isSeries(): boolean { + return this.document?.type === 'series'; + } + + /** The episode currently in focus for a Series project; null for films or + * when no document is open. Safe to access without null-checking the + * whole series structure. */ + get activeEpisode(): Episode | null { + if (!this.document || this.document.type !== 'series') return null; + const eps = this.document.series?.episodes ?? []; + if (eps.length === 0) return null; + const i = Math.max(0, Math.min(this.activeEpisodeIndex, eps.length - 1)); + return eps[i]; + } + + /** Meta for the part of the document the editor is currently showing. + * Series → active episode's meta; Film → top-level meta. Returns null when + * no document is open. Consumers that edit should route through the + * corresponding setters (or mutate in-place; svelte-5 state reacts either + * way) so both shapes stay consistent. */ + get activeMeta(): ScreenplayMeta | null { + if (!this.document) return null; + if (this.isSeries) return this.activeEpisode?.meta ?? null; + return this.document.meta; + } + + get activeSettings(): ScreenplaySettings | null { + if (!this.document) return null; + if (this.isSeries) return this.activeEpisode?.settings ?? null; + return this.document.settings; + } + + get activeStory(): ScreenplayStory | null { + if (!this.document) return null; + if (this.isSeries) return this.activeEpisode?.story ?? null; + return this.document.story; + } + + get activeContent(): unknown { + if (!this.document) return null; + if (this.isSeries) return this.activeEpisode?.content ?? null; + return this.document.content; + } + + get activeSceneCards(): SceneCard[] { + if (!this.document) return []; + if (this.isSeries) return this.activeEpisode?.scene_cards ?? []; + return this.document.scene_cards; + } + + /** Replace the active scene_cards array. Needed by flows that rebuild the + * list after a reorder/delete rather than mutating in place. */ + setActiveSceneCards(cards: SceneCard[]): void { + if (!this.document) return; + if (this.isSeries) { + const ep = this.activeEpisode; + if (ep) ep.scene_cards = cards; + } else { + this.document.scene_cards = cards; + } + } + + /** Switch which episode is active in the editor. Bumps `loadTrigger` so + * the editor reloads its ProseMirror state from the new episode's content; + * otherwise the view would keep showing the previous episode's doc. */ + setActiveEpisode(index: number): void { + if (!this.document || this.document.type !== 'series') return; + const eps = this.document.series?.episodes ?? []; + if (eps.length === 0) return; + const clamped = Math.max(0, Math.min(index, eps.length - 1)); + if (clamped === this.activeEpisodeIndex) return; + this.activeEpisodeIndex = clamped; + this.loadedContent = eps[clamped].content; + this.loadTrigger++; + // Episode switch is a context change — refresh derived views immediately, + // not on the typing-debounce. + this.#bumpContentVersion(true); + } + + /** Create a brand-new Series project with one empty episode. Title can + * be edited later via the Scene Navigator's inline rename. */ + async newSeries(seriesTitle: string): Promise { + try { + const doc = await invoke('new_screenplay'); + const ep = this.createEmptyEpisode(1, ''); + doc.type = 'series'; + doc.series = { title: seriesTitle, episodes: [ep] }; + this.document = doc; + this.currentPath = null; + this.isDirty = false; + this.lastSavedAt = null; + this.activeEpisodeIndex = 0; + this.loadedContent = ep.content; + this.loadTrigger++; + this.#bumpContentVersion(true); + } catch (error) { + console.error('Failed to create new series:', error); + } + } + + /** Append a new empty episode to the open series and activate it. */ + async addEpisode(title: string = ''): Promise { + if (!this.document || this.document.type !== 'series' || !this.document.series) return; + const number = this.document.series.episodes.length + 1; + const ep = this.createEmptyEpisode(number, title); + this.document.series.episodes.push(ep); + this.markDirty(); + this.setActiveEpisode(this.document.series.episodes.length - 1); + } + + /** Remove the episode at `index`. Refuses to drop the last remaining + * episode — a series with zero episodes isn't a meaningful state. */ + removeEpisode(index: number): void { + if (!this.document || this.document.type !== 'series' || !this.document.series) return; + const eps = this.document.series.episodes; + if (eps.length <= 1) return; + if (index < 0 || index >= eps.length) return; + eps.splice(index, 1); + this.renumberEpisodes(); + // Keep the active selection valid even if we removed at/before it. + const nextIndex = Math.max(0, Math.min(this.activeEpisodeIndex, eps.length - 1)); + this.activeEpisodeIndex = -1; // force loadTrigger bump even if clamping lands on same index + this.setActiveEpisode(nextIndex); + this.markDirty(); + } + + /** Move an episode up or down in the list; renumbers sequentially. */ + reorderEpisode(from: number, to: number): void { + if (!this.document || this.document.type !== 'series' || !this.document.series) return; + const eps = this.document.series.episodes; + if (from < 0 || from >= eps.length || to < 0 || to >= eps.length || from === to) return; + const moved = eps.splice(from, 1)[0]; + eps.splice(to, 0, moved); + this.renumberEpisodes(); + // If the active episode moved, keep the same Episode object active. + if (from === this.activeEpisodeIndex) this.activeEpisodeIndex = to; + else if (from < this.activeEpisodeIndex && to >= this.activeEpisodeIndex) + this.activeEpisodeIndex--; + else if (from > this.activeEpisodeIndex && to <= this.activeEpisodeIndex) + this.activeEpisodeIndex++; + this.markDirty(); + } + + /** Rename an episode in-place. */ + renameEpisode(index: number, title: string): void { + const eps = this.document?.series?.episodes; + if (!eps || index < 0 || index >= eps.length) return; + eps[index].title = title; + this.markDirty(); + } + + /** Update an episode's lifecycle status (#141). */ + setEpisodeStatus(index: number, status: EpisodeStatus): void { + const eps = this.document?.series?.episodes; + if (!eps || index < 0 || index >= eps.length) return; + eps[index].status = status; + this.markDirty(); + } + + /** Rename the series as a whole. */ + renameSeries(title: string): void { + if (!this.document?.series) return; + this.document.series.title = title; + this.markDirty(); + } + + private renumberEpisodes(): void { + const eps = this.document?.series?.episodes; + if (!eps) return; + for (let i = 0; i < eps.length; i++) eps[i].number = i + 1; + } + + private createEmptyEpisode(number: number, title: string): Episode { + // Episodes inherit the document's current font, default language, + // and input scheme so the series stays typographically coherent — + // falling back to blank defaults only when there is no document yet + // (e.g. creating the very first episode of a brand-new series). + const docSettings = this.document?.settings; + const settings = this.blankSettings(); + if (docSettings) { + settings.font = docSettings.font; + settings.default_language = docSettings.default_language; + settings.input_scheme = docSettings.input_scheme; + } + return { + id: + typeof crypto !== 'undefined' && crypto.randomUUID + ? crypto.randomUUID() + : String(Date.now() + Math.random()), + number, + title, + status: 'outline', + content: { type: 'doc', content: [{ type: 'scene_heading' }] }, + meta: this.blankMeta(), + settings, + story: { idea: '', synopsis: '', treatment: '', narrative: '' }, + scene_cards: [] + }; + } + + private blankMeta(): ScreenplayMeta { + return { + title: '', + author: '', + director: '', + tagline: '', + registration_number: '', + footnote: '', + contact: '', + draft_number: 1, + draft_date: '', + created_at: '', + updated_at: '' + }; + } + + private blankSettings(): ScreenplaySettings { + return { + font: 'manjari', + default_language: 'malayalam', + input_scheme: 'mozhi', + scene_number_start: 1, + show_characters_below_header: false, + editor_font_size: 14 + }; + } + + /** Create a new empty screenplay via the Rust backend. + * Optional `title` is written into meta.title so the title-page + * preview, the OS window title, and the eventual "Save As" filename + * all carry the writer's chosen name from the first frame. */ + async newDocument(title?: string): Promise { + try { + const doc = await invoke('new_screenplay'); + if (title && title.trim()) { + doc.meta.title = title.trim(); + } + this.document = doc; + this.currentPath = null; + this.isDirty = false; + this.lastSavedAt = null; + this.activeEpisodeIndex = 0; + this.loadedContent = doc.content; + this.loadTrigger++; + this.#bumpContentVersion(true); + } catch (error) { + console.error('Failed to create new screenplay:', error); + } + } + + /** Create a new screenplay seeded with parsed content from a paste-to-script + * flow (#122). Same as newDocument() but with the editor preloaded, marked + * dirty so the writer is reminded to save. */ + async newDocumentFromContent(content: unknown): Promise { + try { + const doc = await invoke('new_screenplay'); + doc.content = content; + this.document = doc; + this.currentPath = null; + this.isDirty = true; + this.lastSavedAt = null; + this.activeEpisodeIndex = 0; + this.loadedContent = content; + this.loadTrigger++; + this.#bumpContentVersion(true); + } catch (error) { + console.error('Failed to create screenplay from pasted content:', error); + } + } + + /** Save the current document. If path is provided, save there; otherwise use currentPath. */ + async saveDocument(path?: string): Promise { + const savePath = path ?? this.currentPath; + if (!savePath || !this.document) return; + + try { + await invoke('save_screenplay', { path: savePath, document: this.document }); + this.currentPath = savePath; + this.isDirty = false; + this.lastSavedAt = Date.now(); + // Real save committed — the autosave sidecar is now stale, drop it + // (see #118). Cancel any pending autosave timer so we don't write + // a fresh sidecar moments after deleting the old one. + this.cancelPendingAutosave(); + void this.#discardAutosave(savePath); + + // If no explicit title set, derive it from the filename + if (!this.document.meta.title) { + const filename = savePath.split('/').pop() ?? savePath.split('\\').pop() ?? savePath; + this.document.meta.title = filename.replace(/\.screenplay$/, ''); + } + } catch (error) { + console.error('Failed to save screenplay:', error); + } + } + + /** Open a screenplay file from disk */ + async openDocument(path: string): Promise { + try { + let doc = await invoke('open_screenplay', { path }); + + // Crash-recovery (#118): if a `.autosave` sidecar exists and + // is newer than the file we just loaded, the previous session + // crashed (or quit without saving) with unsaved work. Offer the + // writer a choice — restore the autosave or keep the saved file. + try { + const autosave = await invoke('load_autosave', { path }); + if (autosave) { + const minutesOld = Math.max( + 1, + Math.round((autosave.autosave_time_ms - autosave.original_time_ms) / 60_000) + ); + const result: MessageDialogResult = await message( + `Scriptty found unsaved changes from your last session (about ${minutesOld} min newer than the saved file). Restore them?`, + { + title: 'Recover unsaved changes?', + kind: 'info', + buttons: { yes: 'Restore', no: 'Discard', cancel: 'Decide later' } + } + ); + if (result === 'Restore') { + doc = autosave.document; + // Keep the autosave file in place; the user is now editing + // recovered content, and a save will overwrite both. + } else if (result === 'Discard') { + // Drop the stale sidecar so we don't keep prompting. + void this.#discardAutosave(path); + } + // 'Cancel' (Decide later) — keep the autosave file untouched and + // proceed with the saved version. We'll prompt again next open. + } + } catch (recoverErr) { + // Recovery is best-effort; never block opening the document. + console.warn('Autosave recovery check failed:', recoverErr); + } + + // Normalize every content payload into canonical ProseMirror shape + // before anything else reads it. Slim-format files (series authored + // by hand, Fountain-like episode blocks) can store content as a flat + // [{type,text},...] array — the Editor's own normalizer handles it on + // load, but the Scene Navigator / Scene Cards read content straight + // from the store and would otherwise find no `content.content` to walk. + doc.content = normalizeContentPayload(doc.content); + if (doc.type === 'series' && doc.series) { + for (const ep of doc.series.episodes) { + ep.content = normalizeContentPayload(ep.content); + } + } + this.document = doc; + this.currentPath = path; + this.isDirty = false; + this.lastSavedAt = null; + this.activeEpisodeIndex = 0; + // In series mode, hand the active episode's content to the editor. + // Fall back to the top-level content for film and any malformed series. + const loaded = + doc.type === 'series' ? (doc.series?.episodes?.[0]?.content ?? doc.content) : doc.content; + this.loadedContent = loaded; + this.loadTrigger++; + this.#bumpContentVersion(true); + } catch (error) { + console.error('Failed to open screenplay:', error); + } + } + + /** Parse a .fountain file into a brand-new Film document and load it + * into the store. Goes through the dirty-state guard via the caller. + * Returns the import summary (dropped boneyards, stripped emphasis, + * warnings) so the UI can surface a toast. Returns null if loading + * failed at the Tauri layer (file unreadable / parse error) — in + * that case an error message has already been logged. (#187) */ + async importFountainAsFilm(path: string): Promise { + try { + const result = await invoke('import_fountain_as_film', { path }); + const doc = result.document; + doc.content = normalizeContentPayload(doc.content); + this.document = doc; + // Imported docs are dirty by default — the writer hasn't saved as a + // .screenplay yet, and we want Save to surface the file dialog + // rather than silently writing back to the .fountain path. + this.currentPath = null; + this.isDirty = true; + this.lastSavedAt = null; + this.activeEpisodeIndex = 0; + this.loadedContent = doc.content; + this.loadTrigger++; + this.#bumpContentVersion(true); + return result.summary; + } catch (error) { + console.error('Failed to import Fountain file:', error); + await message(`Could not import Fountain file: ${error}`, { + title: 'Import failed', + kind: 'error' + }); + return null; + } + } + + /** Parse a .fountain file as a single episode and append it to the + * current Series document. Switches the active episode pointer to + * the new one so the editor lands on the imported content. Errors + * out via a native dialog if the current document isn't a series. + * (#187) */ + async importFountainAsEpisode(path: string): Promise { + if (!this.document || this.document.type !== 'series') { + await message( + 'Open a Series project first — Fountain files can only be imported as episodes into an existing series.', + { title: 'No series open', kind: 'info' } + ); + return null; + } + try { + const result = await invoke('import_fountain_as_episode', { + path, + currentDocument: this.document + }); + const doc = result.document; + // Normalize the new episode's content along with the rest. + if (doc.type === 'series' && doc.series) { + for (const ep of doc.series.episodes) { + ep.content = normalizeContentPayload(ep.content); + } + } + this.document = doc; + this.isDirty = true; + this.lastSavedAt = null; + // Land the editor on the freshly-imported episode (always the last + // entry, since we appended). + const lastIdx = (doc.series?.episodes.length ?? 1) - 1; + this.activeEpisodeIndex = lastIdx; + this.loadedContent = doc.series?.episodes?.[lastIdx]?.content ?? doc.content; + this.loadTrigger++; + this.#bumpContentVersion(true); + return result.summary; + } catch (error) { + console.error('Failed to import Fountain as episode:', error); + await message(`Could not import Fountain file: ${error}`, { + title: 'Import failed', + kind: 'error' + }); + return null; + } + } + + /** Parse a .fdx (Final Draft) file into a brand-new Film document. + * Mirrors importFountainAsFilm — same dirty/path semantics. (#192) */ + async importFdxAsFilm(path: string): Promise { + try { + const result = await invoke('import_fdx_as_film', { path }); + const doc = result.document; + doc.content = normalizeContentPayload(doc.content); + this.document = doc; + this.currentPath = null; + this.isDirty = true; + this.lastSavedAt = null; + this.activeEpisodeIndex = 0; + this.loadedContent = doc.content; + this.loadTrigger++; + this.#bumpContentVersion(true); + return result.summary; + } catch (error) { + console.error('Failed to import Final Draft file:', error); + await message(`Could not import Final Draft file: ${error}`, { + title: 'Import failed', + kind: 'error' + }); + return null; + } + } + + /** Parse a .fdx file as a single episode and append it to the active + * Series. Mirrors importFountainAsEpisode. (#192) */ + async importFdxAsEpisode(path: string): Promise { + if (!this.document || this.document.type !== 'series') { + await message( + 'Open a Series project first — Final Draft files can only be imported as episodes into an existing series.', + { title: 'No series open', kind: 'info' } + ); + return null; + } + try { + const result = await invoke('import_fdx_as_episode', { + path, + currentDocument: this.document + }); + const doc = result.document; + if (doc.type === 'series' && doc.series) { + for (const ep of doc.series.episodes) { + ep.content = normalizeContentPayload(ep.content); + } + } + this.document = doc; + this.isDirty = true; + this.lastSavedAt = null; + const lastIdx = (doc.series?.episodes.length ?? 1) - 1; + this.activeEpisodeIndex = lastIdx; + this.loadedContent = doc.series?.episodes?.[lastIdx]?.content ?? doc.content; + this.loadTrigger++; + this.#bumpContentVersion(true); + return result.summary; + } catch (error) { + console.error('Failed to import Final Draft as episode:', error); + await message(`Could not import Final Draft file: ${error}`, { + title: 'Import failed', + kind: 'error' + }); + return null; + } + } + + /** Save with dialog — if currentPath exists, saves directly; otherwise opens a save dialog. */ + async saveWithDialog(): Promise { + console.log('[saveWithDialog] called'); + if (!this.document) return; + + if (this.currentPath) { + await this.saveDocument(this.currentPath); + } else { + const path = await save({ + filters: [{ name: 'Screenplay', extensions: ['screenplay'] }] + }); + if (!path) return; // User cancelled + await this.saveDocument(path); + } + } + + /** Save As — always opens a file dialog, even if the document has a current path */ + async saveAsDialog(): Promise { + if (!this.document) return; + + const defaultTitle = this.displayTitle; + const path = await save({ + defaultPath: defaultTitle ? `${defaultTitle}.screenplay` : 'untitled.screenplay', + filters: [{ name: 'Screenplay', extensions: ['screenplay'] }] + }); + if (!path) return; // User cancelled + await this.saveDocument(path); + } + + /** Get the current font setting slug (e.g. 'noto-sans-malayalam' or 'manjari') */ + get currentFont(): string { + return this.document?.settings.font ?? 'manjari'; + } + + /** Update the font setting and mark the document as dirty. + * Font is a series-wide choice, so we mirror it into every episode's + * settings alongside the top-level doc settings. Without the mirror, + * series exports (which read the first episode's settings) would keep + * rendering in the stale default font. */ + setFont(font: string): void { + if (!this.document) return; + this.document.settings.font = font; + if (this.document.type === 'series' && this.document.series) { + for (const ep of this.document.series.episodes) { + ep.settings.font = font; + } + } + this.isDirty = true; + } + + /** Mark the document as having unsaved changes */ + markDirty(): void { + this.isDirty = true; + this.#scheduleAutosave(); + } + + // ─── Autosave (#118) ────────────────────────────────────────────── + // The writer is the source of truth — never lose more than ~15s of + // typing to a crash, OS sleep, or accidental close. Autosave writes + // to `.autosave` (a sidecar) on a debounced timer; the real + // save deletes the sidecar; on next open we check if a sidecar is + // newer than the file and offer to restore it. + // + // Untitled documents (no currentPath) are skipped — they have nothing + // to autosave alongside. The writer still gets the dirty-state guard + // on close, which is the existing protection for that case. + #autosaveTimer: ReturnType | null = null; + #AUTOSAVE_DELAY_MS = 15_000; + + #scheduleAutosave(): void { + if (this.#autosaveTimer) clearTimeout(this.#autosaveTimer); + if (!this.currentPath || !this.document) return; + this.#autosaveTimer = setTimeout(() => { + this.#autosaveTimer = null; + this.#runAutosave(); + }, this.#AUTOSAVE_DELAY_MS); + } + + async #runAutosave(): Promise { + // Bail if state changed under us between scheduling and firing. + if (!this.currentPath || !this.document || !this.isDirty) return; + try { + await invoke('autosave_screenplay', { + path: this.currentPath, + document: this.document + }); + } catch (error) { + // Best-effort — log but don't surface a toast for transient I/O failure. + console.warn('Autosave failed:', error); + } + } + + /** Cancel any pending autosave timer (called on close-without-save). */ + cancelPendingAutosave(): void { + if (this.#autosaveTimer) { + clearTimeout(this.#autosaveTimer); + this.#autosaveTimer = null; + } + } + + /** Force an immediate autosave (called from beforeunload / window close + * so a crash mid-typing never loses the most recent edits). */ + async flushAutosave(): Promise { + this.cancelPendingAutosave(); + if (this.isDirty) await this.#runAutosave(); + } + + async #discardAutosave(path: string): Promise { + try { + await invoke('discard_autosave', { path }); + } catch (error) { + console.warn('Failed to discard autosave:', error); + } + } + + /** + * If the document has unsaved changes, prompt the user to Save / Don't Save / Cancel. + * Returns true if it's safe to proceed (saved or discarded), false if cancelled. + */ + async confirmIfDirty(): Promise { + if (!this.isDirty) return true; + + try { + const result: MessageDialogResult = await message( + 'You have unsaved changes. Do you want to save before continuing?', + { + title: 'Unsaved Changes', + kind: 'warning', + buttons: { yes: 'Save', no: "Don't Save", cancel: 'Cancel' } + } + ); + + if (result === 'Cancel') return false; + if (result === 'Save') await this.saveWithDialog(); + else { + // "Don't Save" — the user explicitly said discard. Drop the + // autosave sidecar so the next open doesn't offer to restore + // changes the user just rejected (#118). + this.cancelPendingAutosave(); + if (this.currentPath) void this.#discardAutosave(this.currentPath); + } + return true; + } catch (error) { + console.error('[confirmIfDirty] dialog error:', error); + return true; + } + } + + /** Update the document's content without marking dirty. + * Called by the editor on every doc-changing transaction to keep the store in sync. + * In series mode, routes the content into the active episode so the editor and + * stored series tree stay in sync without having to duplicate the editor plumbing. */ + setContent(content: unknown): void { + if (!this.document) return; + if (this.isSeries) { + const ep = this.activeEpisode; + if (ep) ep.content = content; + } else { + this.document.content = content; + } + // Debounced bump — Navigator/Cards/Statistics will catch up in ~200ms. + this.#bumpContentVersion(); + } + + /** Title used for save-dialog defaults and window-title formatting. In + * series mode the series title trumps the (usually blank) top-level meta + * title. */ + get displayTitle(): string { + if (!this.document) return ''; + if (this.isSeries) return this.document.series?.title ?? ''; + return this.document.meta.title ?? ''; + } + + /** A film-shaped view of the current working unit, suitable for passing to + * the Rust export pipeline. For a film project this is the document + * itself; for a series it's a shallow film-shaped wrapper around the + * active episode, with the series title prefixed into the meta.title so + * the title page shows both. Returns null if no document is open. */ + get activeExportDocument(): ScreenplayDocument | null { + const doc = this.document; + if (!doc) return null; + if (!this.isSeries) return doc; + const ep = this.activeEpisode; + if (!ep) return doc; + const seriesTitle = doc.series?.title ?? ''; + const epTitle = ep.title.trim(); + const composedTitle = epTitle + ? seriesTitle + ? `${seriesTitle} — Ep ${ep.number}: ${epTitle}` + : `Ep ${ep.number}: ${epTitle}` + : seriesTitle + ? `${seriesTitle} — Ep ${ep.number}` + : `Episode ${ep.number}`; + return { + type: 'film', + series: null, + content: ep.content, + meta: { ...ep.meta, title: ep.meta.title || composedTitle }, + settings: ep.settings, + story: ep.story, + scene_cards: ep.scene_cards + }; + } } export const documentStore = new DocumentStore(); diff --git a/src/lib/stores/editorStore.svelte.ts b/src/lib/stores/editorStore.svelte.ts index 116cb8d..dff3bb2 100644 --- a/src/lib/stores/editorStore.svelte.ts +++ b/src/lib/stores/editorStore.svelte.ts @@ -7,25 +7,24 @@ import type { EditorView } from 'prosemirror-view'; /** Which inline marks are currently active at the cursor position */ interface MarkState { - bold: boolean; - italic: boolean; - underline: boolean; + bold: boolean; + italic: boolean; + underline: boolean; } class EditorStore { - view = $state(null); + view = $state(null); - // Reactive mark state — updated by the Editor component on every selection change - markState = $state({ bold: false, italic: false, underline: false }); + // Reactive mark state — updated by the Editor component on every selection change + markState = $state({ bold: false, italic: false, underline: false }); - // Current element type at cursor (e.g. 'SCENE HEADING', 'ACTION') - currentElement = $state('SCENE HEADING'); - - // 0-based index of the scene containing the cursor, or -1 if the cursor - // sits before the first scene_heading. The outline peek strip reads this - // to highlight the active segment. - currentSceneIndex = $state(-1); + // Current element type at cursor (e.g. 'SCENE HEADING', 'ACTION') + currentElement = $state('SCENE HEADING'); + // 0-based index of the scene containing the cursor, or -1 if the cursor + // sits before the first scene_heading. The outline peek strip reads this + // to highlight the active segment. + currentSceneIndex = $state(-1); } export const editorStore = new EditorStore(); diff --git a/src/lib/stores/themeStore.svelte.ts b/src/lib/stores/themeStore.svelte.ts index 8664295..f661718 100644 --- a/src/lib/stores/themeStore.svelte.ts +++ b/src/lib/stores/themeStore.svelte.ts @@ -5,33 +5,33 @@ const STORAGE_KEY = 'scriptty-theme'; type Theme = 'dark' | 'light'; function getInitialTheme(): Theme { - if (typeof window === 'undefined') return 'dark'; - const stored = localStorage.getItem(STORAGE_KEY); - if (stored === 'dark' || stored === 'light') return stored; - return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + if (typeof window === 'undefined') return 'dark'; + const stored = localStorage.getItem(STORAGE_KEY); + if (stored === 'dark' || stored === 'light') return stored; + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } class ThemeStore { - current = $state('dark'); - - init() { - this.current = getInitialTheme(); - this.apply(); - } - - toggle() { - this.current = this.current === 'dark' ? 'light' : 'dark'; - localStorage.setItem(STORAGE_KEY, this.current); - this.apply(); - } - - get isDark(): boolean { - return this.current === 'dark'; - } - - private apply() { - document.documentElement.setAttribute('data-theme', this.current); - } + current = $state('dark'); + + init() { + this.current = getInitialTheme(); + this.apply(); + } + + toggle() { + this.current = this.current === 'dark' ? 'light' : 'dark'; + localStorage.setItem(STORAGE_KEY, this.current); + this.apply(); + } + + get isDark(): boolean { + return this.current === 'dark'; + } + + private apply() { + document.documentElement.setAttribute('data-theme', this.current); + } } export const themeStore = new ThemeStore(); diff --git a/src/lib/stores/updateStore.svelte.ts b/src/lib/stores/updateStore.svelte.ts index 781906f..495777a 100644 --- a/src/lib/stores/updateStore.svelte.ts +++ b/src/lib/stores/updateStore.svelte.ts @@ -76,8 +76,12 @@ export const updateStore = new UpdateStore(); */ function compareVersions(a: string, b: string): number { const strip = (v: string) => v.replace(/^v/, '').split(/[-+]/)[0]; - const pa = strip(a).split('.').map((n) => Number(n) || 0); - const pb = strip(b).split('.').map((n) => Number(n) || 0); + const pa = strip(a) + .split('.') + .map((n) => Number(n) || 0); + const pb = strip(b) + .split('.') + .map((n) => Number(n) || 0); const len = Math.max(pa.length, pb.length); for (let i = 0; i < len; i++) { const x = pa[i] ?? 0; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 9c7b737..5fe7f84 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,175 +1,177 @@ {@render children()} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 72e4ae2..0dd929d 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,845 +1,1168 @@ {#if !documentStore.document} - + {:else} -
- { activeView = v; }} - onShowExport={() => { showExport = true; }} - onShowMetadata={() => { showMetadata = true; }} - onShowStatistics={() => { showStatistics = true; }} - /> -
- - - {#if activeView === 'cards'} - -
{ - // Click-outside-to-close the sidebar in Cards view. The - // sidebar is overlay-positioned, so clicks on the cards - // canvas reach this host. We close only when the panel is - // open and the click didn't land inside it. - if (!panelOpen) return; - const target = e.target as HTMLElement | null; - if (target?.closest('.left-panel')) return; - closePanel(); - }} - > - -
- {/if} - {#if activeView === 'story'} - - {/if} -
- -
-
- {#if showOutlinePeek && activeView === 'writing'} - - {/if} - { showCommandPalette = true; }} - onOpenSettings={() => { showSettings = true; }} - onShowHelp={() => { showHelp = true; }} - > - {#snippet rightContent()} - {#if activeView === 'writing'} - {editorStore.currentElement} - {:else if activeView === 'story'} - {storyWordCount()} words - {/if} - {/snippet} - -
+ + {#if activeView === 'cards'} + +
{ + // Click-outside-to-close the sidebar in Cards view. The + // sidebar is overlay-positioned, so clicks on the cards + // canvas reach this host. We close only when the panel is + // open and the click didn't land inside it. + if (!panelOpen) return; + const target = e.target as HTMLElement | null; + if (target?.closest('.left-panel')) return; + closePanel(); + }} + > + +
+ {/if} + {#if activeView === 'story'} + + {/if} +
+ +
+ + {#if showOutlinePeek && activeView === 'writing'} + + {/if} + { + showCommandPalette = true; + }} + onOpenSettings={() => { + showSettings = true; + }} + onShowHelp={() => { + showHelp = true; + }} + > + {#snippet rightContent()} + {#if activeView === 'writing'} + {editorStore.currentElement} + {:else if activeView === 'story'} + {storyWordCount()} words + {/if} + {/snippet} + + {/if} - { showAbout = true; }} /> + { + showAbout = true; + }} +/> - { showMetadata = true; }} /> + { + showMetadata = true; + }} +/> - + pickAndImport(fmt, asEpisode)} + bind:open={showImportWizard} + onStart={(fmt, asEpisode) => pickAndImport(fmt, asEpisode)} />