From 0290ca9fff404fa2190b0ce60d3a3ace1d740c38 Mon Sep 17 00:00:00 2001 From: Hrishi Date: Sun, 3 May 2026 12:01:50 +0530 Subject: [PATCH 1/5] ci: cross-platform matrix, full build verify, fmt+lint enforcement --- .github/workflows/ci.yml | 67 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 62 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f2bdb3..46de598 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,30 +12,45 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +# Least-privilege: nothing in this workflow needs more than read access. +permissions: + contents: read + jobs: rust: - name: Rust — clippy & tests - runs-on: ubuntu-22.04 + name: Rust — fmt, clippy & tests (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, macos-latest, windows-latest] steps: - uses: actions/checkout@v5 - name: Install Rust stable uses: dtolnay/rust-toolchain@stable with: - components: clippy + components: clippy, rustfmt - name: Cache Rust dependencies uses: Swatinem/rust-cache@v2 with: workspaces: src-tauri + key: ${{ matrix.os }} - # Tauri lib pulls webkit at build time even for `cargo check` / `cargo test`, + # Tauri pulls webkit at build time even for `cargo check` / `cargo test`, # so we install the same Linux deps the release job uses. - name: Install Linux dependencies + if: matrix.os == 'ubuntu-22.04' run: | sudo apt-get update sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + - name: cargo fmt --check + working-directory: src-tauri + run: cargo fmt --all -- --check + - name: cargo clippy (lib + tests, deny warnings) working-directory: src-tauri run: cargo clippy --lib --tests -- -D warnings @@ -45,8 +60,9 @@ jobs: run: cargo test --lib frontend: - name: Frontend — svelte-check + name: Frontend — svelte-check & lint runs-on: ubuntu-22.04 + timeout-minutes: 15 steps: - uses: actions/checkout@v5 @@ -65,3 +81,44 @@ jobs: # ("zero warnings on cargo clippy and svelte-check"). - name: svelte-check (via npm run check) run: npm run check -- --fail-on-warnings + + # `npm run lint` = `prettier --check . && eslint .` (per package.json) + - name: Lint (prettier + eslint) + run: npm run lint + + build: + name: Build — full Tauri app (Linux, debug) + runs-on: ubuntu-22.04 + timeout-minutes: 45 + needs: [rust, frontend] + steps: + - uses: actions/checkout@v5 + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri + key: build-linux-debug + + - name: Install Linux dependencies + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: 22 + cache: npm + + - name: Install frontend dependencies + run: npm ci + + # End-to-end build verification: frontend → cargo → tauri-cli bundling. + # Catches platform-specific build breakage at PR time instead of at tag time. + # `--debug` skips heavy LTO/strip but exercises the full pipeline. + - name: cargo tauri build --debug + run: npm run tauri -- build --debug From 20f8baa00994298363c6b45dee8d3e65b44cf538 Mon Sep 17 00:00:00 2001 From: stultus Date: Sun, 3 May 2026 12:10:11 +0530 Subject: [PATCH 2/5] style: apply cargo fmt and prettier --write across codebase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First pass before the new fmt/lint CI gates land. cargo fmt --all on src-tauri reformatted 11 Rust files (mostly long argument lists and indentation). prettier --write . reformatted ~60 frontend/docs files (Svelte, TypeScript, JSON, Markdown). One Svelte file (HelpModal) was unstable in a single prettier pass and needed a second --write. eslint debt is not addressed here — 59 errors remain across 8 rule categories, including svelte/prefer-svelte-reactivity (Svelte 5 migration) and svelte/no-at-html-tags (security review needed). Those need real code changes and will be tracked separately. --- .claude/CLAUDE.md | 123 +- .claude/PROGRESS.md | 40 + .claude/agents/pdf-export.md | 25 +- .claude/agents/rust-backend.md | 8 + .claude/agents/svelte-frontend.md | 25 +- .claude/settings.json | 48 +- .claude/skills/ui-design/SKILL.md | 256 +- .github/dependabot.yml | 24 +- README.md | 1 + REQUIREMENTS.md | 28 +- SCREENPLAY_FORMAT.md | 337 +- SECURITY.md | 2 + docs/downloads.json | 18 +- docs/index.html | 2969 +++++++----- src-tauri/build.rs | 2 +- src-tauri/capabilities/default.json | 58 +- src-tauri/src/commands/export.rs | 50 +- src-tauri/src/commands/file.rs | 13 +- src-tauri/src/commands/import.rs | 4 +- src-tauri/src/lib.rs | 670 ++- src-tauri/src/main.rs | 2 +- src-tauri/src/screenplay/document.rs | 10 +- src-tauri/src/screenplay/fdx_import.rs | 34 +- src-tauri/src/screenplay/fountain.rs | 22 +- src-tauri/src/screenplay/fountain_import.rs | 68 +- src-tauri/src/screenplay/pdf.rs | 260 +- src-tauri/tauri.conf.json | 86 +- src/lib/components/AboutModal.svelte | 690 +-- src/lib/components/CommandPalette.svelte | 555 +-- src/lib/components/DatePicker.svelte | 1468 +++--- src/lib/components/Editor.svelte | 2343 ++++----- src/lib/components/EpisodeCardsView.svelte | 2147 +++++---- src/lib/components/ExportModal.svelte | 4187 ++++++++-------- src/lib/components/FindReplaceBar.svelte | 626 +-- src/lib/components/FormatBubble.svelte | 518 +- src/lib/components/HelpModal.svelte | 1971 +++++--- src/lib/components/ImportSummaryToast.svelte | 423 +- src/lib/components/ImportWizardModal.svelte | 857 ++-- src/lib/components/LeftPanel.svelte | 72 +- src/lib/components/MetadataModal.svelte | 1829 +++---- src/lib/components/NewProjectDialog.svelte | 611 +-- src/lib/components/OutlinePeek.svelte | 409 +- src/lib/components/PasteScriptDialog.svelte | 753 +-- src/lib/components/SceneCardsView.svelte | 4285 +++++++++-------- src/lib/components/SceneNavigator.svelte | 1899 ++++---- src/lib/components/SeriesEpisodeList.svelte | 1552 +++--- src/lib/components/SettingsModal.svelte | 1373 +++--- src/lib/components/StatisticsModal.svelte | 4512 ++++++++++-------- src/lib/components/StatusBar.svelte | 558 ++- src/lib/components/StoryModeView.svelte | 721 +-- src/lib/components/TitleBar.svelte | 1690 ++++--- src/lib/components/UpdateToast.svelte | 332 +- src/lib/components/WelcomeScreen.svelte | 978 ++-- src/lib/editor/autoUppercase.ts | 70 +- src/lib/editor/characterAutocomplete.ts | 511 +- src/lib/editor/findReplace.ts | 567 +-- src/lib/editor/input/InputModeManager.ts | 192 +- src/lib/editor/input/inscript1.ts | 134 +- src/lib/editor/input/inscript2.ts | 158 +- src/lib/editor/input/mozhi.ts | 634 +-- src/lib/editor/keymap.ts | 9 +- src/lib/editor/parsePastedScript.ts | 306 +- src/lib/editor/sceneTimeOfDay.ts | 7 +- src/lib/editor/schema.ts | 11 +- src/lib/editor/smartQuotes.ts | 102 +- src/lib/stores/documentStore.svelte.ts | 1797 +++---- src/lib/stores/editorStore.svelte.ts | 25 +- src/lib/stores/themeStore.svelte.ts | 48 +- src/lib/stores/updateStore.svelte.ts | 8 +- src/routes/+layout.svelte | 554 +-- src/routes/+page.svelte | 1953 ++++---- 71 files changed, 26557 insertions(+), 23071 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index c617635..f179db2 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 | --- @@ -139,22 +139,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 @@ -207,35 +207,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": [] } ``` @@ -263,6 +263,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. @@ -323,7 +324,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 @@ -412,10 +413,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`: @@ -506,6 +507,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 @@ -523,6 +525,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 6b1c085..c256034 100644 --- a/.claude/PROGRESS.md +++ b/.claude/PROGRESS.md @@ -3,6 +3,7 @@ ## Status: v0.8.0 shipped — Production planning, editorial-grade PDFs, autosave, paste-to-script Highlights since v0.7.0: + - **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 (small tracked-caps eyebrow with flanking rules, dominant film title, asterism divider, Courier credits). Per-section page numbering. Transition widow control. Full-width single-column scene cards with date + location-group surfaced. Compact card-view export toggle. 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; click an episode to drill into its scenes. IDE-style episode explorer in the sidebar with per-episode status (Outline / Draft / Revision / Final). @@ -15,6 +16,7 @@ Highlights since v0.7.0: ## 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 @@ -25,6 +27,7 @@ Highlights since v0.7.0: - [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 @@ -36,6 +39,7 @@ Highlights since v0.7.0: - [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 @@ -44,6 +48,7 @@ Highlights since v0.7.0: - [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 @@ -53,22 +58,26 @@ Highlights since v0.7.0: - [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 @@ -78,6 +87,7 @@ Highlights since v0.7.0: - [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 @@ -92,6 +102,7 @@ Highlights since v0.7.0: ## 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 @@ -99,6 +110,7 @@ Highlights since v0.7.0: - [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 @@ -106,6 +118,7 @@ Highlights since v0.7.0: - [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 @@ -115,6 +128,7 @@ Highlights since v0.7.0: - [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 @@ -125,6 +139,7 @@ Highlights since v0.7.0: - [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` @@ -136,12 +151,14 @@ Highlights since v0.7.0: ## 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 `>` @@ -149,6 +166,7 @@ Highlights since v0.7.0: - [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) @@ -156,6 +174,7 @@ Highlights since v0.7.0: - [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 @@ -163,6 +182,7 @@ Highlights since v0.7.0: - [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 @@ -171,6 +191,7 @@ Highlights since v0.7.0: - [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 @@ -178,6 +199,7 @@ Highlights since v0.7.0: - [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 @@ -189,6 +211,7 @@ Highlights since v0.7.0: ## 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) @@ -198,6 +221,7 @@ Highlights since v0.7.0: - [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 @@ -210,6 +234,7 @@ Highlights since v0.7.0: - [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 @@ -219,10 +244,12 @@ Highlights since v0.7.0: - [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 @@ -232,22 +259,26 @@ Highlights since v0.7.0: ## 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 @@ -257,17 +288,20 @@ Highlights since v0.7.0: ## 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) @@ -278,16 +312,19 @@ Highlights since v0.7.0: - [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`, @@ -301,6 +338,7 @@ Highlights since v0.7.0: - [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) @@ -322,6 +360,7 @@ Highlights since v0.7.0: - [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 @@ -332,6 +371,7 @@ Highlights since v0.7.0: ## 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/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)} /> From 4b831c4515bb195878d2a0ee892b0bf9f2dc7c25 Mon Sep 17 00:00:00 2001 From: stultus Date: Sun, 3 May 2026 12:11:58 +0530 Subject: [PATCH 3/5] ci: gate on prettier --check, keep eslint visible but non-blocking Splitting `npm run lint` into two steps lets the cross-platform CI land green while we clear 59 existing eslint errors (Svelte 5 runes migration, unused vars, missing each-block keys, {@html} review). Flip continue-on-error to false once the debt is paid. --- .github/workflows/ci.yml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46de598..5ce1823 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,9 +82,15 @@ jobs: - name: svelte-check (via npm run check) run: npm run check -- --fail-on-warnings - # `npm run lint` = `prettier --check . && eslint .` (per package.json) - - name: Lint (prettier + eslint) - run: npm run lint + # Split out from `npm run lint` so prettier gates while eslint stays + # visible-but-non-blocking until existing lint debt is paid down. + # See repo issues for the eslint cleanup tracker. + - name: Prettier — check formatting + run: npx prettier --check . + + - name: ESLint (non-blocking until debt is cleared) + run: npx eslint . + continue-on-error: true build: name: Build — full Tauri app (Linux, debug) From d62ff93a33c37dc97c72953a68616c98a03873f9 Mon Sep 17 00:00:00 2001 From: stultus Date: Sun, 3 May 2026 12:17:51 +0530 Subject: [PATCH 4/5] style: stabilize PROGRESS.md (prettier non-idempotent on first pass) The previous merge resolution ran prettier once on this file, but prettier needs two passes to reach a fixed point on it (likely a plugin-svelte / markdown-table interaction). Without the second pass, prettier --check fails CI even though prettier --write was just run. --- .claude/PROGRESS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude/PROGRESS.md b/.claude/PROGRESS.md index 66d6840..2124dfd 100644 --- a/.claude/PROGRESS.md +++ b/.claude/PROGRESS.md @@ -431,8 +431,8 @@ Highlights from v0.8.0 (still relevant): ### 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 From 51ba9dac3ca75305a9093c6399b3fa9ad726700c Mon Sep 17 00:00:00 2001 From: stultus Date: Sun, 3 May 2026 13:15:10 +0530 Subject: [PATCH 5/5] ci: skip rpm bundling and reduce build timeout Last CI run hung for 39 minutes inside Tauri's rpm bundler before the 45-minute timeout killed it. Cargo compile took only 3m17s and the .deb bundle finished in 13s; the rpm step never made progress. Pass --bundles deb,appimage to match the Linux targets we actually ship in release.yml (no rpm in the release matrix). With rpm out of the way, the job should finish well under the new 25-minute cap. --- .github/workflows/ci.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ce1823..0339ab7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -95,7 +95,7 @@ jobs: build: name: Build — full Tauri app (Linux, debug) runs-on: ubuntu-22.04 - timeout-minutes: 45 + timeout-minutes: 25 needs: [rust, frontend] steps: - uses: actions/checkout@v5 @@ -126,5 +126,8 @@ jobs: # End-to-end build verification: frontend → cargo → tauri-cli bundling. # Catches platform-specific build breakage at PR time instead of at tag time. # `--debug` skips heavy LTO/strip but exercises the full pipeline. - - name: cargo tauri build --debug - run: npm run tauri -- build --debug + # `--bundles deb,appimage` matches the Linux targets we actually ship in + # release.yml. RPM bundling hangs indefinitely on the GitHub runner + # (rpmbuild network/entropy issue) and we don't ship .rpm, so skip it. + - name: tauri build --debug (deb + appimage only) + run: npm run tauri -- build --debug --bundles deb,appimage