diff --git a/components/board/BoardCanvas.tsx b/components/board/BoardCanvas.tsx index 2b53e5f4..d7d612b7 100644 --- a/components/board/BoardCanvas.tsx +++ b/components/board/BoardCanvas.tsx @@ -48,6 +48,8 @@ const BoardCanvas =({ isVisible, docId }: { isVisible: boolean; docId: string }) useContext(ProjectContext); const { updateContextMenu } = useContext(UserContext); const t = useTranslations("board"); + // Used only to name the default lanes when the board seeds them (below). + const tTimeline = useTranslations("timeline"); const projectState = repository?.getState(); const containerRef = useRef(null); const canvasRef = useRef(null); @@ -962,6 +964,20 @@ const BoardCanvas =({ isVisible, docId }: { isVisible: boolean; docId: string }) return out; }, [timelineLayers]); + /** + * Lanes to offer in the "Send to timeline" submenu. The default lanes are + * otherwise only seeded when the Timeline panel is first opened, so a + * board-first user got an empty list (and a flat menu item) until they'd + * either opened the timeline or sent a card once — `appendTimelineClip` + * seeds too. Seeding here as well keeps the lanes listed on the very first + * right-click. Idempotent, and a no-op on a read-only project. + */ + const resolveTimelineLayers = useCallback(() => { + if (orderedLayers.length > 0) return orderedLayers; + const seeded = repository?.ensureTimelineLayers(2, (i) => `${tTimeline("layer")} ${i + 1}`) ?? []; + return seeded.map((layer) => ({ layer, depth: 0 })); + }, [orderedLayers, repository, tTimeline]); + // Delete arrow const handleDeleteArrow = useCallback( (id: string) => { @@ -975,6 +991,7 @@ const BoardCanvas =({ isVisible, docId }: { isVisible: boolean; docId: string }) // Open the shared context-menu host for a card. const handleCardContextMenu = useCallback( (e: React.MouseEvent, card: BoardCardData) => { + const layers = resolveTimelineLayers(); updateContextMenu({ position: { x: e.clientX, y: e.clientY }, content: ( @@ -996,9 +1013,9 @@ const BoardCanvas =({ isVisible, docId }: { isVisible: boolean; docId: string }) action={() => handleDuplicateCard(card)} /> {(card.type ?? "text") === "text" && - (orderedLayers.length > 0 ? ( + (layers.length > 0 ? ( - {orderedLayers.map(({ layer, depth }) => ( + {layers.map(({ layer, depth }) => ( { onProgress: (p) => { console.log("progress: ", p);setProgress(p) }, }; - const adapter = getAdapterByExtension(format); + const adapter = getExportAdapter(format); if (!adapter) { console.error("Unsupported file type"); return; @@ -202,6 +197,9 @@ const ExportProject = () => { pageSelection, }; await adapter.export(ydoc, pdfOptions as BaseExportOptions); + } else if (format === ExportFormat.TEXT) { + const textOptions: TextExportOptions = { ...baseOptions, includeTitlePage }; + await adapter.export(ydoc, textOptions as BaseExportOptions); } else if (format === ExportFormat.SCRIPTIO) { const scriptioOptions: ScriptioExportOptions = { ...baseOptions, @@ -221,6 +219,7 @@ const ExportProject = () => { { value: ExportFormat.PDF, label: t("formatOptions.pdf") }, { value: ExportFormat.FOUNTAIN, label: t("formatOptions.fountain") }, { value: ExportFormat.FDX, label: t("formatOptions.fdx") }, + { value: ExportFormat.TEXT, label: t("formatOptions.text") }, { value: ExportFormat.SCRIPTIO, label: t("formatOptions.scriptio") }, ]; @@ -289,6 +288,7 @@ const ExportProject = () => { {format === ExportFormat.PDF && t("formatHelp.pdf")} {format === ExportFormat.FOUNTAIN && t("formatHelp.fountain")} {format === ExportFormat.FDX && t("formatHelp.fdx")} + {format === ExportFormat.TEXT && t("formatHelp.text")} {format === ExportFormat.SCRIPTIO && t("formatHelp.scriptio")}

@@ -309,8 +309,8 @@ const ExportProject = () => { {t("notes")} - {/* Title page is a PDF-only concept. */} - {format === ExportFormat.PDF && ( + {/* Only the formats that lay out a title page of their own. */} + {(format === ExportFormat.PDF || format === ExportFormat.TEXT) && (
setIncludeTitlePage(!includeTitlePage)} diff --git a/components/editor/DocumentEditorPanel.tsx b/components/editor/DocumentEditorPanel.tsx index c02b3583..1922bef9 100644 --- a/components/editor/DocumentEditorPanel.tsx +++ b/components/editor/DocumentEditorPanel.tsx @@ -27,6 +27,8 @@ import { useDocumentComments } from "@src/lib/editor/use-document-comments"; import { getNodeIdAtPos, transactionDeletesNode } from "@src/lib/screenplay/comment-anchors"; import { useDocumentEditor } from "@src/lib/editor/use-document-editor"; import { useViewModeScrollAnchor } from "@src/lib/editor/use-view-mode-scroll-anchor"; +import { useKeyboardCaretVisibility } from "@src/lib/editor/use-keyboard-caret-visibility"; +import { captureZoomAnchor, settleZoomAnchor } from "@src/lib/editor/zoom-scroll-anchor"; import { centerCaretInView, focusEditorAtCoords } from "@src/lib/editor/focus-in-viewport"; import { getSpellErrorAt } from "@src/lib/spellcheck/spellcheck-extension"; import type { SuggestionData } from "@components/editor/SuggestionMenu"; @@ -57,9 +59,18 @@ const CHROME_HIDE_RANGE = 220; // ---- Desktop editor zoom ---- // Ctrl/⌘ +, Ctrl/⌘ -, Ctrl/⌘ 0 and Ctrl+wheel (also a trackpad pinch, which the // browser delivers as a ctrlKey wheel on both platforms) scale the page in the -// view. Bounds are clamped; kept modest so text stays legible and the WebKit -// minimum-font-size clamp isn't hit at the low end. -const MIN_ZOOM = 0.5; +// view. Bounds are clamped so text stays legible. +// +// The floor also keeps WebKit's minimum-font-size clamp out of reach. WebKit +// multiplies computed font sizes by `zoom` and then floors the result at a +// rendered 9px (minimumLogicalFontSize; see the paged-mode rule in +// EditorPanel.module.css). The screenplay body is 12pt = 16px, so anything below +// 9/16 = 0.5625 gets floored — and a floored font wraps text differently from +// the canonical layout the pagination is measured against, which would put the +// page breaks shown on screen out of step with the document. 0.6 lands at 9.6px +// rendered and clears it. Levels stored by an older build are re-clamped on +// load, so a saved 0.5 comes back as 0.6. +const MIN_ZOOM = 0.6; const MAX_ZOOM = 3; // Steps are multiplicative (a constant ratio), not additive: a fixed +0.2 would // be 20% at 1× but only ~7% at 3×, so zooming-in felt progressively slower. A @@ -352,6 +363,16 @@ const DocumentEditorPanel = ({ onBeforeChange: onBeforeEndlessScrollChange, }); + // ---- Keyboard-safe writing area (phone) ---- + // Reserve scroll room for the on-screen keyboard + format toolbar and keep the + // caret above them, so writing at the very end of the script isn't done blind + // behind the keyboard — see the hook for why neither half is automatic. + useKeyboardCaretVisibility({ + container: containerEl, + editor, + enabled: isPhone && mobileEditMode, + }); + // ---- Desktop editor zoom ---- // Scale the zoom level by a ratio (>1 in, <1 out) so each step feels the same // size at any level. Rounds to whole percents so repeated wheel/keys don't @@ -361,9 +382,10 @@ const DocumentEditorPanel = ({ }, []); // Push the level into the CSS variable the ProseMirror `zoom` reads, and keep - // the page growing from the viewport centre rather than pinning to the left as - // it scales past the view width. Runs before paint so the reposition is not - // seen as a jump. Phone owns its own scaling (paged transform / endless + // the line the reader was on under the viewport centre as the page grows or + // shrinks around it (see zoom-scroll-anchor for why that is measured rather + // than computed from the zoom ratio). Runs before paint so the reposition is + // not seen as a jump. Phone owns its own scaling (paged transform / endless // reflow), so this stays desktop-only — clear the variable there. const prevZoomRef = useRef(1); const didInitZoomRef = useRef(false); @@ -373,12 +395,16 @@ const DocumentEditorPanel = ({ container?.style.removeProperty("--editor-user-zoom"); return; } - const prev = prevZoomRef.current; - // Capture the pre-zoom scroll BEFORE rescaling: applying the new zoom can - // clamp scrollLeft/Top (when zooming out shrinks the scrollable area), - // which would corrupt the centre we're trying to preserve. - const centreX = container.scrollLeft + container.clientWidth / 2; - const centreY = container.scrollTop + container.clientHeight / 2; + // Record what the reader is looking at BEFORE rescaling, while the + // outgoing layout is still measurable — the rescale itself can also clamp + // scrollTop/Left (zooming out shrinks the scrollable area), which would + // corrupt any position captured afterwards. Nothing to preserve on the + // first application (the document opens at the top), nor when this effect + // re-runs for a reason other than the level changing. + const anchor = + didInitZoomRef.current && prevZoomRef.current !== zoom + ? captureZoomAnchor(container, editor?.view?.dom) + : null; container.style.setProperty("--editor-user-zoom", `${zoom}`); // First application (e.g. a zoom level restored from a previous session): @@ -395,15 +421,12 @@ const DocumentEditorPanel = ({ return; } - // Interactive change: keep whatever sat under the viewport centre put, so - // the page scales symmetrically around the middle of the view. - if (prev !== zoom) { - const factor = zoom / prev; - container.scrollLeft = centreX * factor - container.clientWidth / 2; - container.scrollTop = centreY * factor - container.clientHeight / 2; - } prevZoomRef.current = zoom; - }, [containerEl, zoom, isPhone]); + if (!anchor) return; + // Scroll the anchored line back under the viewport centre, re-checking for + // a few frames while the rescaled layout settles. + return settleZoomAnchor(container, editor?.view?.dom, anchor); + }, [containerEl, zoom, isPhone, editor]); // Persist the level and nudge resize-driven layout (e.g. the comment gutter, // which only recomputes its icon coordinates on edits / resizes). @@ -690,8 +713,8 @@ const DocumentEditorPanel = ({ editor.setOptions({ editorProps: { // Re-apply the base contenteditable attributes: setOptions replaces - // editorProps wholesale, so without this the autocorrect/predictive - // suppression set at mount would be dropped for screenplay editors. + // editorProps wholesale, so without this the text-input traits set at + // mount (autocorrect, spellcheck) would be dropped for screenplay editors. attributes: EDITOR_INPUT_ATTRIBUTES, handleKeyDown(view: EditorView, event: KeyboardEvent) { const selection = view.state.selection; diff --git a/components/editor/EditorPanel.module.css b/components/editor/EditorPanel.module.css index f06c9bc6..8a21ba4a 100644 --- a/components/editor/EditorPanel.module.css +++ b/components/editor/EditorPanel.module.css @@ -269,7 +269,15 @@ .editor_wrapper { max-width: none; - padding-bottom: 40%; + /* A fixed tail so the last page doesn't butt against the viewport edge, + * plus whatever the on-screen keyboard and the format toolbar riding on it + * currently cover (--keyboard-inset, set on the scroll container by + * useKeyboardCaretVisibility and inherited here; absent when no keyboard is + * up). Without that second term the end of the script has nothing below it + * to scroll up: the shell is pinned to the layout viewport, so the keyboard + * simply sits over the last lines and they can never be brought into view + * while writing them. */ + padding-bottom: calc(40% + var(--keyboard-inset, 0px)); } /* ENDLESS mode (default on phone): reflow the screenplay into the viewport at diff --git a/components/projects/ProjectPageContainer.tsx b/components/projects/ProjectPageContainer.tsx index 75f1e775..7fa80ac3 100644 --- a/components/projects/ProjectPageContainer.tsx +++ b/components/projects/ProjectPageContainer.tsx @@ -9,7 +9,8 @@ import { ExtendedProjectMembershipPayload, } from "@src/lib/utils/hooks"; import { join } from "@src/lib/utils/misc"; -import { importFileAsProject, getSupportedImportExtensions } from "@src/lib/import/import-project"; +import { importFileAsProject } from "@src/lib/import/import-project"; +import { getSupportedImportExtensions } from "@src/lib/adapters/registry"; import { useAppNavigation } from "@src/lib/utils/navigation"; import { FileDown, Plus, X } from "lucide-react"; import { useTranslations } from "next-intl"; diff --git a/components/utils/ContextMenu.module.css b/components/utils/ContextMenu.module.css index 3f55f356..19c2de14 100644 --- a/components/utils/ContextMenu.module.css +++ b/components/utils/ContextMenu.module.css @@ -2,9 +2,15 @@ display: flex; flex-direction: column; width: 220px; + /* Never taller/wider than the viewport — long menus scroll instead of + overflowing off-screen (the JS clamps the top/left to match). */ + max-width: calc(100vw - 16px); + max-height: calc(100vh - 16px); z-index: 1000; position: fixed; + /* Hidden until the component has measured and placed it (before paint). */ + visibility: hidden; padding-block: 6px; /* Horizontal padding so hovered items inset from the menu's left/right edges. */ padding-inline: 6px; @@ -64,9 +70,9 @@ color: var(--secondary-text); } -/* Flyout panel — reuses .menu, but capped in height so long layer lists scroll. */ +/* Flyout panel — reuses .menu, but capped shorter so long layer lists scroll. */ .submenu_panel { - max-height: 320px; + max-height: min(320px, calc(100vh - 16px)); } .menu_separator { diff --git a/components/utils/ContextMenu.tsx b/components/utils/ContextMenu.tsx index 760a611f..e48925ca 100644 --- a/components/utils/ContextMenu.tsx +++ b/components/utils/ContextMenu.tsx @@ -1,6 +1,6 @@ "use client"; -import { ReactNode, useRef, useState } from "react"; +import { ReactNode, useCallback, useRef, useState } from "react"; import { ChevronRight, LucideIcon } from "lucide-react"; import { join } from "@src/lib/utils/misc"; @@ -19,6 +19,13 @@ interface ContextMenuProps { children: ReactNode; } +/** Gap kept between a menu and the viewport edges. */ +const VIEWPORT_MARGIN = 8; + +/** Start offset that keeps an element of `size` inside `viewport` on one axis. */ +const clampToViewport = (start: number, size: number, viewport: number) => + Math.max(VIEWPORT_MARGIN, Math.min(start, viewport - VIEWPORT_MARGIN - size)); + /** * Positioned context-menu surface. Renders a fixed panel at `position` and tags * it with `data-context-menu` so canvas / outside-click handlers can detect @@ -28,17 +35,45 @@ interface ContextMenuProps { * (see editor/sidebar/ContextMenu), which owns the open state, so only one menu * is ever rendered. */ -export const ContextMenu = ({ position, className, children }: ContextMenuProps) => ( -
e.preventDefault()} - > - {children} -
-); +export const ContextMenu = ({ position, className, children }: ContextMenuProps) => { + // `position` is the pointer, which can sit anywhere — including close enough + // to the right/bottom edge that the menu would hang off-screen. Clamp from + // the measured box in a ref callback (before paint, so the menu is never + // painted overflowing), and keep watching: menus can grow after opening, + // e.g. when spellcheck suggestions arrive. + const place = useCallback( + (menu: HTMLDivElement | null) => { + if (!menu) return; + const apply = () => { + const { width, height } = menu.getBoundingClientRect(); + menu.style.left = `${clampToViewport(position.x, width, window.innerWidth)}px`; + menu.style.top = `${clampToViewport(position.y, height, window.innerHeight)}px`; + menu.style.visibility = "visible"; + }; + apply(); + const observer = new ResizeObserver(apply); + observer.observe(menu); + window.addEventListener("resize", apply); + return () => { + observer.disconnect(); + window.removeEventListener("resize", apply); + }; + }, + [position.x, position.y], + ); + + return ( +
e.preventDefault()} + > + {children} +
+ ); +}; interface ContextMenuItemProps { text: string; @@ -70,8 +105,9 @@ interface ContextMenuSubmenuProps { children: ReactNode; } -/** Width the flyout is laid out at (matches `.menu`); used for edge-flip math. */ -const SUBMENU_WIDTH = 220; +/** `.menu`'s vertical padding — offsets the flyout so its first item lines up + * with the parent item rather than sitting a padding-height lower. */ +const MENU_PADDING = 6; /** * A menu item that reveals a nested flyout of items on hover. The flyout is a @@ -82,31 +118,40 @@ const SUBMENU_WIDTH = 220; */ export const ContextMenuSubmenu = ({ text, icon: Icon, children }: ContextMenuSubmenuProps) => { const itemRef = useRef(null); - const [pos, setPos] = useState<{ top: number; left: number } | null>(null); - - const open = () => { - const el = itemRef.current; - if (!el) return; - const r = el.getBoundingClientRect(); - const left = r.right + SUBMENU_WIDTH > window.innerWidth ? r.left - SUBMENU_WIDTH : r.right; - // Keep the flyout on-screen vertically when it opens near the bottom. - const top = Math.max(8, Math.min(r.top, window.innerHeight - 320)); - setPos({ top, left }); - }; + const [open, setOpen] = useState(false); + + // Place the flyout from its *measured* box — clamping against a hard-coded + // size instead made it drift away from the parent item whenever the guess + // was taller than the panel really is. Done in a ref callback so it runs + // once the panel is in the DOM but before paint: the panel starts + // `visibility: hidden` (see the stylesheet) and is revealed once placed. + const placePanel = useCallback((panel: HTMLDivElement | null) => { + const item = itemRef.current; + if (!panel || !item) return; + const anchor = item.getBoundingClientRect(); + const { width, height } = panel.getBoundingClientRect(); + // Open to the item's right, flipping to its left when that would run off + // the viewport; both sides are clamped so the flyout stays on-screen. + const preferredLeft = + anchor.right + width > window.innerWidth - VIEWPORT_MARGIN ? anchor.left - width : anchor.right; + panel.style.left = `${clampToViewport(preferredLeft, width, window.innerWidth)}px`; + panel.style.top = `${clampToViewport(anchor.top - MENU_PADDING, height, window.innerHeight)}px`; + panel.style.visibility = "visible"; + }, []); return ( -
setPos(null)}> +
setOpen(true)} + onMouseLeave={() => setOpen(false)} + >
{Icon && }

{text}

- {pos && ( -
+ {open && ( +
{children}
)} diff --git a/messages/de.json b/messages/de.json index 6be287c1..5369cfda 100644 --- a/messages/de.json +++ b/messages/de.json @@ -416,12 +416,14 @@ "pdf": "PDF-Dokument (.pdf)", "fountain": "Fountain (.fountain)", "fdx": "Final Draft (.fdx)", + "text": "Formatierter Text (.txt)", "scriptio": "Scriptio (.scriptio)" }, "formatHelp": { "pdf": "Branchenstandard-Format. Ideal zum Teilen und Drucken.", "fountain": "Einfaches Textformat basierend auf Markdown, ideal für Kompatibilität.", "fdx": "Kompatibel mit der Branchensoftware Final Draft.", + "text": "Reiner Text mit den Standardrändern eines Drehbuchs, überall lesbar.", "scriptio": "Scriptios eigenes Format, um Ihr Projekt lokal zu speichern" }, "includeNotes": "Notizen einschließen", diff --git a/messages/en.json b/messages/en.json index 2a5ca454..81a8e890 100644 --- a/messages/en.json +++ b/messages/en.json @@ -415,12 +415,14 @@ "pdf": "PDF Document (.pdf)", "fountain": "Fountain (.fountain)", "fdx": "Final Draft (.fdx)", + "text": "Formatted Text (.txt)", "scriptio": "Scriptio (.scriptio)" }, "formatHelp": { "pdf": "Standard industry format. Best for sharing and printing.", "fountain": "Plain text format based on markdown, great for compatibility.", "fdx": "Compatible with Final Draft industry software.", + "text": "Plain text laid out with standard screenplay margins, readable anywhere.", "scriptio": "Scriptio own format, to keep your project local" }, "includeNotes": "Include Notes", diff --git a/messages/es.json b/messages/es.json index 71747403..ec3948a6 100644 --- a/messages/es.json +++ b/messages/es.json @@ -415,12 +415,14 @@ "pdf": "Documento PDF (.pdf)", "fountain": "Fountain (.fountain)", "fdx": "Final Draft (.fdx)", + "text": "Texto con formato (.txt)", "scriptio": "Scriptio (.scriptio)" }, "formatHelp": { "pdf": "Formato estándar de la industria. Ideal para compartir e imprimir.", "fountain": "Formato de texto plano basado en Markdown, ideal para compatibilidad.", "fdx": "Compatible con el software de la industria Final Draft.", + "text": "Texto plano con los márgenes estándar de un guion, legible en cualquier parte.", "scriptio": "Formato propio de Scriptio, para mantener el proyecto en local" }, "includeNotes": "Incluir notas", diff --git a/messages/fr.json b/messages/fr.json index 348b7748..e188c88e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -416,12 +416,14 @@ "pdf": "Document PDF (.pdf)", "fountain": "Fountain (.fountain)", "fdx": "Final Draft (.fdx)", + "text": "Texte formaté (.txt)", "scriptio": "Scriptio (.scriptio)" }, "formatHelp": { "pdf": "Format standard de l'industrie. Idéal pour le partage et l'impression.", "fountain": "Format texte brut basé sur Markdown, idéal pour la compatibilité.", "fdx": "Compatible avec le logiciel professionnel Final Draft.", + "text": "Texte brut mis en page avec les marges standard d'un scénario, lisible partout.", "scriptio": "Format natif de Scriptio, pour conserver votre projet en local" }, "includeNotes": "Inclure les notes", diff --git a/messages/ja.json b/messages/ja.json index c06faac3..95e58b57 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -415,12 +415,14 @@ "pdf": "PDF ドキュメント (.pdf)", "fountain": "Fountain (.fountain)", "fdx": "Final Draft (.fdx)", + "text": "整形テキスト (.txt)", "scriptio": "Scriptio (.scriptio)" }, "formatHelp": { "pdf": "業界標準フォーマット。共有や印刷に最適です。", "fountain": "Markdownベースのテキスト形式。互換性に優れています。", "fdx": "プロ向けソフト Final Draft と互換性があります。", + "text": "脚本の標準的な余白で整形したプレーンテキスト。どこでも読めます。", "scriptio": "Scriptio 独自形式。ローカル保存に最適です。" }, "includeNotes": "メモを含める", diff --git a/messages/ko.json b/messages/ko.json index 71046b13..d7189612 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -415,12 +415,14 @@ "pdf": "PDF 문서 (.pdf)", "fountain": "Fountain (.fountain)", "fdx": "Final Draft (.fdx)", + "text": "서식 있는 텍스트 (.txt)", "scriptio": "Scriptio (.scriptio)" }, "formatHelp": { "pdf": "표준 형식. 공유 및 인쇄에 적합합니다.", "fountain": "Markdown 기반 텍스트 형식. 호환성이 좋습니다.", "fdx": "전문 소프트웨어인 Final Draft와 호환됩니다.", + "text": "표준 시나리오 여백으로 정렬한 일반 텍스트. 어디서나 읽을 수 있습니다.", "scriptio": "Scriptio 기본 형식. 로컬 보관용으로 적합합니다." }, "includeNotes": "노트 포함", diff --git a/messages/pl.json b/messages/pl.json index 1f390fe6..a8ab479b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -415,12 +415,14 @@ "pdf": "Dokument PDF (.pdf)", "fountain": "Fountain (.fountain)", "fdx": "Final Draft (.fdx)", + "text": "Sformatowany tekst (.txt)", "scriptio": "Scriptio (.scriptio)" }, "formatHelp": { "pdf": "Standardowy format. Idealny do udostępniania i drukowania.", "fountain": "Prosty format tekstowy oparty na Markdown, idealny dla kompatybilności.", "fdx": "Zgodny z oprogramowaniem branżowym Final Draft.", + "text": "Zwykły tekst ze standardowymi marginesami scenariusza, czytelny wszędzie.", "scriptio": "Natywny format Scriptio, do przechowywania projektu lokalnie" }, "includeNotes": "Uwzględnij notatki", diff --git a/messages/zh.json b/messages/zh.json index 60ec2e39..c85bea6f 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -415,12 +415,14 @@ "pdf": "PDF 文档 (.pdf)", "fountain": "Fountain (.fountain)", "fdx": "Final Draft (.fdx)", + "text": "格式化文本 (.txt)", "scriptio": "Scriptio (.scriptio)" }, "formatHelp": { "pdf": "行业标准格式。", "fountain": "基于 Markdown 的文本格式。", "fdx": "兼容 Final Draft。", + "text": "按剧本标准页边距排版的纯文本,随处可读。", "scriptio": "Scriptio 原生格式。" }, "includeNotes": "包含备注", diff --git a/src/lib/adapters/fadein/fadein-adapter.ts b/src/lib/adapters/fadein/fadein-adapter.ts index aca22cbc..96f9bc53 100644 --- a/src/lib/adapters/fadein/fadein-adapter.ts +++ b/src/lib/adapters/fadein/fadein-adapter.ts @@ -131,7 +131,9 @@ function paragraphsToTitlePage(paras: OSFParagraph[]): JSONContent[] { export class FadeInAdapter extends ProjectAdapter { label = "FadeIn"; - extension = "fadein"; + // Import-only: `convertTo` below rejects, so the export UI never offers it. + exportTarget = null; + importExtensions = ["fadein"]; convertTo(): Promise { return Promise.reject(new Error("Export to FadeIn is not supported")); diff --git a/src/lib/adapters/fdx/finaldraft-adapter.ts b/src/lib/adapters/fdx/finaldraft-adapter.ts index 699bc278..45a10703 100644 --- a/src/lib/adapters/fdx/finaldraft-adapter.ts +++ b/src/lib/adapters/fdx/finaldraft-adapter.ts @@ -1,4 +1,5 @@ import { BaseExportOptions, ProjectAdapter } from "../screenplay-adapter"; +import { ExportFormat } from "@src/lib/utils/enums"; import { XMLBuilder, XMLParser } from "@node_modules/fast-xml-parser/src/fxp"; import { getNodeFlattenContent } from "@src/lib/screenplay/screenplay"; import { ProjectData, ProjectState, screenplayOf } from "@src/lib/project/project-state"; @@ -46,7 +47,8 @@ const FDX_STYLE_REVERSE: Record = Object.fromEntries( export class FinalDraftAdapter extends ProjectAdapter { label = "Final Draft"; - extension = "fdx"; + exportTarget = { format: ExportFormat.FDX, extension: "fdx" }; + importExtensions = ["fdx"]; convertTo(project: ProjectState, options: BaseExportOptions): Promise { const paragraphNodes: FDXParagraphNode[] = []; diff --git a/src/lib/adapters/fountain/fountain-adapter.ts b/src/lib/adapters/fountain/fountain-adapter.ts index b60f4f8e..132596b3 100644 --- a/src/lib/adapters/fountain/fountain-adapter.ts +++ b/src/lib/adapters/fountain/fountain-adapter.ts @@ -1,4 +1,5 @@ import { BaseExportOptions, ProjectAdapter } from "../screenplay-adapter"; +import { ExportFormat } from "@src/lib/utils/enums"; import fountain from "./fountain_parser"; import { generateJSON, JSONContent } from "@tiptap/react"; @@ -8,7 +9,14 @@ import { ProjectData, ProjectState, screenplayOf, titlepageOf } from "@src/lib/p export class FountainAdapter extends ProjectAdapter { label = "Fountain Script"; - extension = "fountain"; + exportTarget = { format: ExportFormat.FOUNTAIN, extension: "fountain" }; + + // Fountain is plain text with light markup, and `.txt` is where it usually + // ends up — every writer's "screenplay.txt" is either Fountain or close + // enough that the parser's fallbacks read it sensibly. So this adapter owns + // both extensions on import. (Formatted-text export also writes `.txt`, but + // that is a rendering, not a source format — it exports under its own id.) + importExtensions = ["fountain", "txt"]; /** * Resolve a title page format node to its Fountain key and display value. diff --git a/src/lib/adapters/pdf/pdf-adapter.ts b/src/lib/adapters/pdf/pdf-adapter.ts index cdf64c1b..207b582c 100644 --- a/src/lib/adapters/pdf/pdf-adapter.ts +++ b/src/lib/adapters/pdf/pdf-adapter.ts @@ -1,7 +1,7 @@ import { BaseExportOptions, ProjectAdapter } from "../screenplay-adapter"; import { ProjectData, ProjectState } from "@src/lib/project/project-state"; -import { PageFormat } from "@src/lib/utils/enums"; +import { ExportFormat, PageFormat } from "@src/lib/utils/enums"; import { getFontForCodePoint, ScriptFont } from "./pdf-utils"; import type { TextRun } from "./pdf.worker"; import { BASE_URL } from "@src/lib/utils/constants"; @@ -109,7 +109,11 @@ const getMarksFromComputedStyle = (textNode: Text): { bold: boolean; italic: boo export class PDFAdapter extends ProjectAdapter { label = "PDF"; - extension = "pdf"; + exportTarget = { format: ExportFormat.PDF, extension: "pdf" }; + + // Export-only: a PDF carries no recoverable screenplay structure, so nothing + // routes `.pdf` files here (`convertFrom` throws). + importExtensions = []; async convertTo(_project: ProjectState, options: PDFExportOptions): Promise { const editorEl = options.editorElement; @@ -126,15 +130,50 @@ export class PDFAdapter extends ProjectAdapter { // ── Collect all visual lines from the browser DOM ─────────────────── const titlePageEl = options.titlePageElement; - const titlePageLines = titlePageEl ? this.collectLines(titlePageEl, options) : []; - const titlePageLeftPx = titlePageEl ? this.getPageLeftPx(titlePageEl) : 0; + // Every coordinate below comes from the live DOM, so the whole geometry + // pass runs with the editor's on-screen scaling pinned to 1× — see + // `withCanonicalScale`. Keeping it in a single closure means the layout + // is neutralised (and restored) exactly once per export. + const measured = this.withCanonicalScale([editorEl, titlePageEl], () => { + // Header/footer columns are laid out within the configured page + // margins: the editor's `.pagination-header-area` / + // `.pagination-footer-area` are padded by + // --page-margin-left/right. Read those margins (px) off the editor + // DOM and convert to PDF points so the export reproduces the same + // horizontal bounds instead of a hard-coded 1-inch margin. + const editorStyle = getComputedStyle(editorEl); + const readMarginPt = (name: string, fallbackPx: number): number => { + const px = parseFloat(editorStyle.getPropertyValue(name)); + return (Number.isFinite(px) ? px : fallbackPx) * PX_TO_PT; + }; + + // Footer of the final page: it has no trailing page-break sentinel + // to carry it, so it is read from the dedicated last-page widget. + // Page filtering below re-derives it from the last surviving page. + const lastPageWidget = editorEl.querySelector(".pagination-last-page") as HTMLElement | null; + // Header of the (otherwise unnumbered) first page, read from the + // first-page pagination widget. Blank unless "Show first page + // header" is on, in which case its spans carry the expanded + // templates. + const firstPageWidget = editorEl.querySelector(".pagination-first-page") as HTMLElement | null; + + return { + titlePageLines: titlePageEl ? this.collectLines(titlePageEl, options) : [], + titlePageLeftPx: titlePageEl ? this.getPageLeftPx(titlePageEl) : 0, + screenplayLines: this.collectLines(editorEl, options), + screenplayLeftPx: this.getPageLeftPx(editorEl), + screenplayLastFooter: lastPageWidget ? this.extractFooter(lastPageWidget) : undefined, + screenplayFirstHeader: firstPageWidget ? this.extractHeader(firstPageWidget) : undefined, + pageMarginLeft: readMarginPt("--page-margin-left", 96), + pageMarginRight: readMarginPt("--page-margin-right", 96), + }; + }); + + const { titlePageLines, titlePageLeftPx, screenplayLeftPx, screenplayFirstHeader, pageMarginLeft, pageMarginRight } = + measured; + let screenplayLines = measured.screenplayLines; + let screenplayLastFooter = measured.screenplayLastFooter; - let screenplayLines = this.collectLines(editorEl, options); - // Footer of the final page: it has no trailing page-break sentinel to - // carry it, so it is read from the dedicated last-page widget. Page - // filtering below re-derives it from the last surviving page. - const lastPageWidget = editorEl.querySelector(".pagination-last-page") as HTMLElement | null; - let screenplayLastFooter = lastPageWidget ? this.extractFooter(lastPageWidget) : undefined; // Page selection: keep only the chosen pages (by range or by revision). const sel = options.pageSelection; if (sel?.mode === "ranges" && sel.ranges.length > 0) { @@ -153,27 +192,6 @@ export class PDFAdapter extends ProjectAdapter { screenplayLastFooter = kept.lastFooter; } this.applyRevisionStyling(screenplayLines, options.revisionExport ?? "colored"); - const screenplayLeftPx = this.getPageLeftPx(editorEl); - - // Header/footer columns are laid out within the configured page margins: - // the editor's `.pagination-header-area`/`.pagination-footer-area` are - // padded by --page-margin-left/right. Read those margins (px) off the - // editor DOM and convert to PDF points so the export reproduces the same - // horizontal bounds instead of a hard-coded 1-inch margin. - const editorStyle = getComputedStyle(editorEl); - const pxToPt = 72 / 96; - const readMarginPt = (name: string, fallbackPx: number): number => { - const px = parseFloat(editorStyle.getPropertyValue(name)); - return (Number.isFinite(px) ? px : fallbackPx) * pxToPt; - }; - const pageMarginLeft = readMarginPt("--page-margin-left", 96); - const pageMarginRight = readMarginPt("--page-margin-right", 96); - - // Header of the (otherwise unnumbered) first page, read from the - // first-page pagination widget. Blank unless "Show first page header" - // is on, in which case its spans carry the expanded templates. - const firstPageWidget = editorEl.querySelector(".pagination-first-page") as HTMLElement | null; - const screenplayFirstHeader = firstPageWidget ? this.extractHeader(firstPageWidget) : undefined; return new Promise((resolve, reject) => { const worker = new Worker(new URL("./pdf.worker.ts", import.meta.url)); @@ -224,6 +242,79 @@ export class PDFAdapter extends ProjectAdapter { throw new Error("Method not implemented."); } + // ── Canonical (unscaled) measurement ──────────────────────────────────── + + /** + * Run `measure` with the editors' display-only scaling pinned to 1×, so + * every DOM coordinate it reads is the canonical page geometry. + * + * The editor is scaled on screen by two independent mechanisms, both of + * which feed straight into `getBoundingClientRect()` / + * `Range.getClientRects()` — the only source of coordinates in this + * exporter: + * - desktop user zoom: `zoom: var(--editor-user-zoom)`, + * - phone paged fit-to-width: `transform: scale(var(--editor-zoom))`. + * Left in place, a 150% zoom stretches every X offset and line gap by 1.5 + * while the PDF still draws a fixed 12pt font, so the exported layout would + * depend on how the user happened to be viewing the script. + * + * Pinning the layout is preferred over dividing the measurements by the + * scale factor: `zoom` actually re-lays the page out at scaled font sizes + * (a division cannot undo that), and engines disagree on whether + * `getComputedStyle` lengths are zoom-adjusted, which this pass also reads. + * Both overrides are set `!important` so no stylesheet rule can outvote + * them, and the original inline declarations are restored afterwards. + * + * Nothing here yields to the event loop, so the browser never paints the + * unscaled state — the export is invisible to the user. Only scroll offsets + * need explicit restoring: dropping `zoom` shrinks the layout box, which + * clamps the scroll position of every scrollable ancestor. + */ + private withCanonicalScale(elements: (HTMLElement | undefined)[], measure: () => T): T { + const targets = elements.filter((el): el is HTMLElement => !!el); + const savedStyles = targets.map((el) => ({ + el, + zoom: el.style.getPropertyValue("zoom"), + zoomPriority: el.style.getPropertyPriority("zoom"), + transform: el.style.getPropertyValue("transform"), + transformPriority: el.style.getPropertyPriority("transform"), + })); + + const savedScroll = new Map(); + for (const el of targets) { + let node: Element | null = el; + while (node) { + if (!savedScroll.has(node)) savedScroll.set(node, { top: node.scrollTop, left: node.scrollLeft }); + node = node.parentElement; + } + } + + try { + for (const el of targets) { + el.style.setProperty("zoom", "1", "important"); + el.style.setProperty("transform", "none", "important"); + } + // No explicit reflow needed: the first geometry read inside + // `measure` flushes the pending layout for us. + return measure(); + } finally { + for (const saved of savedStyles) { + const restore = (name: string, value: string, priority: string) => { + if (value) saved.el.style.setProperty(name, value, priority); + else saved.el.style.removeProperty(name); + }; + restore("zoom", saved.zoom, saved.zoomPriority); + restore("transform", saved.transform, saved.transformPriority); + } + // Assigning scroll offsets flushes the restored layout first, so + // these land against the scaled extents they were taken from. + for (const [node, pos] of savedScroll) { + node.scrollTop = pos.top; + node.scrollLeft = pos.left; + } + } + } + // ── DOM → VisualLine[] ─────────────────────────────────────────────────── /** diff --git a/src/lib/adapters/registry.ts b/src/lib/adapters/registry.ts index 9e3edae4..4e601cc2 100644 --- a/src/lib/adapters/registry.ts +++ b/src/lib/adapters/registry.ts @@ -4,30 +4,59 @@ import { FountainAdapter } from "./fountain/fountain-adapter"; import { PDFAdapter } from "./pdf/pdf-adapter"; import { ProjectAdapter } from "./screenplay-adapter"; import { ScriptioAdapter } from "./scriptio/scriptio-adapter"; +import { FormattedTextAdapter } from "./text/text-adapter"; import { WriterSoloAdapter } from "./writersolo/writersolo-adapter"; +import { ExportFormat } from "@src/lib/utils/enums"; -const adapterMap = new Map(); -const registeredAdapters: ProjectAdapter[] = [ +/** + * Every file-format adapter, in the order their extensions are offered in file + * dialogs. Reading and writing are keyed differently — a file extension for + * import, an {@link ExportFormat} id for export — because the two are not always + * the same string: `.txt` is written by formatted-text export but read as + * Fountain. Each adapter declares both sides (`importExtensions`, + * `exportTarget`), so this list is the only place an adapter is registered. + */ +const adapters: ProjectAdapter[] = [ new FountainAdapter(), new FinalDraftAdapter(), - new PDFAdapter(), new ScriptioAdapter(), new FadeInAdapter(), new WriterSoloAdapter(), + new PDFAdapter(), + new FormattedTextAdapter(), ]; -registeredAdapters.forEach((adapter) => { - adapterMap.set(adapter.extension.toLowerCase(), adapter); -}); +/** Extension (lower-case, no dot) → adapter that reads it. */ +const importAdapters = new Map(); + +/** Export format id → adapter that writes it. */ +const exportAdapters = new Map(); -// `.txt` files commonly contain plain Fountain markup, so treat them as Fountain. -adapterMap.set("txt", new FountainAdapter()); +for (const adapter of adapters) { + // Two adapters claiming the same extension would make import routing depend + // on registration order; registry.test.ts asserts that never happens. + for (const extension of adapter.importExtensions) { + importAdapters.set(extension.toLowerCase(), adapter); + } + if (adapter.exportTarget) exportAdapters.set(adapter.exportTarget.format, adapter); +} -export const getAdapterByFilename = (filename: string): ProjectAdapter | undefined => { +/** Comma-separated `accept` list for a file input, e.g. ".fountain,.txt,.fdx". */ +const SUPPORTED_IMPORT_EXTENSIONS = [...importAdapters.keys()].map((extension) => `.${extension}`).join(","); + +/** Adapter that can READ `filename`, by its extension, or undefined if none can. */ +export const getImportAdapterByFilename = (filename: string): ProjectAdapter | undefined => { const extension = filename.split(".").pop()?.toLowerCase(); - return getAdapterByExtension(extension); + return extension ? importAdapters.get(extension) : undefined; }; -export const getAdapterByExtension = (extension: string | undefined): ProjectAdapter | undefined => { - return extension ? adapterMap.get(extension) : undefined; +/** Adapter that WRITES `format`, or undefined when no adapter answers to it. */ +export const getExportAdapter = (format: ExportFormat): ProjectAdapter | undefined => { + return exportAdapters.get(format); }; + +/** Extensions the import file pickers accept — every readable format, derived. */ +export const getSupportedImportExtensions = (): string => SUPPORTED_IMPORT_EXTENSIONS; + +/** Registered adapters, for tests and for UI that lists supported formats. */ +export const getRegisteredAdapters = (): readonly ProjectAdapter[] => adapters; diff --git a/src/lib/adapters/screenplay-adapter.ts b/src/lib/adapters/screenplay-adapter.ts index 9c74bc55..eec4c262 100644 --- a/src/lib/adapters/screenplay-adapter.ts +++ b/src/lib/adapters/screenplay-adapter.ts @@ -4,6 +4,7 @@ import { replaceScreenplay } from "../screenplay/editor"; import { Editor } from "@tiptap/react"; import { LayoutData, ProjectData, ProjectMetadata, ProjectState } from "../project/project-state"; import { ProjectRepository } from "../project/project-repository"; +import { ExportFormat } from "../utils/enums"; export type BaseExportOptions = { title: string; @@ -15,21 +16,56 @@ export type BaseExportOptions = { onProgress?: (progress: number) => void; }; +/** + * What an adapter WRITES: the id the export UI asks for, and the extension of + * the file that comes out. Paired in one value because a format either has both + * or neither — an id without an extension would name the file `Script.undefined`. + * + * The two are usually the same string but must not be assumed to be: formatted + * text answers to `TEXT` while writing `.txt`, because `.txt` on import belongs + * to Fountain (see `FountainAdapter.importExtensions`). + */ +export type ExportTarget = { + /** Id the export UI asks for. Type-checked against the enum it offers. */ + format: ExportFormat; + /** Suffix of the written file, lower-case and without the dot, e.g. "fdx". */ + extension: string; +}; + export abstract class ProjectAdapter { - abstract label: string; // e.g., "Final Draft (.fdx)" - abstract extension: string; // e.g., "fdx" + /** Human-readable format name, e.g. "Final Draft". Shown in the save dialog. */ + abstract label: string; + + /** How this format is written, or `null` when it can only be read. */ + abstract exportTarget: ExportTarget | null; + + /** + * File extensions this adapter can READ, lower-case and without the dot, or + * `[]` when the format can only be written. + * + * Declared per adapter with no default, so reading and writing are stated + * independently: most formats own their extension in both directions, but + * Fountain also claims `.txt`, and `.txt` is written by a different adapter + * than the one that reads it. + */ + abstract importExtensions: string[]; abstract convertTo(project: ProjectState, options: TExportOptions): Promise; abstract convertFrom(rawContent: ArrayBuffer): Partial; public async export(project: ProjectState, options: TExportOptions): Promise { + // Import-only formats have no target and no working `convertTo`; fail + // with something readable rather than on a missing extension later. + const target = this.exportTarget; + if (!target) throw new Error(`${this.label} cannot be exported`); + try { const blob = await this.convertTo(project, options); if (isTauri()) { - await this.exportDesktop(blob, options); + await this.exportDesktop(blob, options, target); } else { - FileSaver.saveAs(blob, `${options.title}.${this.extension}`); + FileSaver.saveAs(blob, `${options.title}.${target.extension}`); } } catch (error) { console.error(`Failed to export to ${this.label}`, error); @@ -37,14 +73,14 @@ export abstract class ProjectAdapter { + private async exportDesktop(blob: Blob, options: TExportOptions, target: ExportTarget): Promise { const { save } = await import("@tauri-apps/plugin-dialog"); const { writeFile } = await import("@tauri-apps/plugin-fs"); const { revealItemInDir } = await import("@tauri-apps/plugin-opener"); const filePath = await save({ - defaultPath: `${options.title}.${this.extension}`, - filters: [{ name: this.label, extensions: [this.extension] }], + defaultPath: `${options.title}.${target.extension}`, + filters: [{ name: this.label, extensions: [target.extension] }], }); if (!filePath) return; diff --git a/src/lib/adapters/scriptio/scriptio-adapter.ts b/src/lib/adapters/scriptio/scriptio-adapter.ts index 9d194e8c..87f51da9 100644 --- a/src/lib/adapters/scriptio/scriptio-adapter.ts +++ b/src/lib/adapters/scriptio/scriptio-adapter.ts @@ -1,5 +1,6 @@ import { ProjectData, ProjectState, applyProjectData, clearProjectData, projectDataOf } from "@src/lib/project/project-state"; import { BaseExportOptions, ProjectAdapter } from "../screenplay-adapter"; +import { ExportFormat } from "@src/lib/utils/enums"; import { replaceScreenplay } from "../../screenplay/editor"; import { Editor } from "@tiptap/react"; import { ProjectRepository } from "../../project/project-repository"; @@ -272,7 +273,8 @@ export async function restoreScriptioAssets( export class ScriptioAdapter extends ProjectAdapter { label = "Scriptio"; - extension = "scriptio"; + exportTarget = { format: ExportFormat.SCRIPTIO, extension: "scriptio" }; + importExtensions = ["scriptio"]; async convertTo(project: ProjectState, options: ScriptioExportOptions): Promise { const readable = options.readable ?? false; diff --git a/src/lib/adapters/text/text-adapter.ts b/src/lib/adapters/text/text-adapter.ts new file mode 100644 index 00000000..6b157848 --- /dev/null +++ b/src/lib/adapters/text/text-adapter.ts @@ -0,0 +1,337 @@ +import { BaseExportOptions, ProjectAdapter } from "../screenplay-adapter"; +import { ExportFormat } from "@src/lib/utils/enums"; +import { ProjectData, ProjectState, screenplayOf, titlepageOf } from "@src/lib/project/project-state"; +import type { JSONContent } from "@tiptap/core"; + +// ─── Formatted text (.txt) ─────────────────────────────────────────────────── +// +// A plain-text rendering of the screenplay: no markup, no metadata, just the +// script laid out with spaces so it reads like a script in any text viewer. +// +// The layout is FIXED — it deliberately ignores the project's own margin / +// page-format settings. Column positions are the standard US screenplay +// measures at 12pt Courier (10 characters per inch), shifted so the action +// margin (1.5" from the paper edge) sits at column 0: a .txt file has no +// paper, so indenting every single line by a further 15 spaces would only +// waste width. Relative measures are preserved: +// +// col 0 action / scene heading / section (1.5") +// col 10 dialogue (2.5") +// col 15 parenthetical (3.0") +// col 22 character cue (3.7") +// col 60 right edge (transitions are flushed here) (7.5") + +export type TextExportOptions = BaseExportOptions & { + /** Render the title page ahead of the script. Defaults to true when omitted. */ + includeTitlePage?: boolean; +}; + +/** Width of the text column, in characters (7.5" − 1.5" at 10 cpi). */ +const PAGE_WIDTH = 60; + +type Layout = { + indent: number; + width: number; + /** Flush the block against the right edge of the page instead of the indent. */ + rightAlign?: boolean; +}; + +const LAYOUTS: Record = { + scene: { indent: 0, width: PAGE_WIDTH }, + action: { indent: 0, width: PAGE_WIDTH }, + section: { indent: 0, width: PAGE_WIDTH }, + note: { indent: 0, width: PAGE_WIDTH }, + dialogue: { indent: 10, width: 35 }, + parenthetical: { indent: 15, width: 25 }, + character: { indent: 22, width: 38 }, + transition: { indent: 0, width: PAGE_WIDTH, rightAlign: true }, +}; + +/** Gutter between the two columns of a dual dialogue, in characters. */ +const DUAL_GAP = 2; +/** Width of one dual-dialogue column — two columns plus the gutter fill the page. */ +const DUAL_WIDTH = (PAGE_WIDTH - DUAL_GAP) / 2; + +/** Same three block types as a normal speech, squeezed into one narrow column. */ +const DUAL_LAYOUTS: Record = { + character: { indent: 8, width: DUAL_WIDTH - 8 }, + parenthetical: { indent: 4, width: DUAL_WIDTH - 6 }, + dialogue: { indent: 1, width: DUAL_WIDTH - 2 }, +}; + +/** + * Blocks that continue the speech started by a character cue. They follow the + * previous block immediately, with no blank line in between. + */ +const SPEECH_BLOCKS = new Set(["dialogue", "parenthetical"]); + +/** Windows-friendly line ending — the file is meant to be opened in any viewer. */ +const EOL = "\r\n"; + +/** Form feed: the conventional plain-text page break, honoured by printers. */ +const PAGE_BREAK = "\f"; + +const flatten = (content: JSONContent[] | undefined): string => + (content ?? []).map((child) => child.text ?? "").join(""); + +/** + * Break `text` into lines of at most `width` characters on word boundaries. + * Words longer than the column (a URL, a long unspaced string) are hard-split + * rather than allowed to overflow the margin. Returns [] for blank text. + */ +const wrap = (text: string, width: number): string[] => { + const words = text.split(/\s+/).filter(Boolean); + const lines: string[] = []; + let line = ""; + + for (let word of words) { + while (word.length > width) { + if (line) { + lines.push(line); + line = ""; + } + lines.push(word.slice(0, width)); + word = word.slice(width); + } + + if (!line) line = word; + else if (line.length + 1 + word.length <= width) line += " " + word; + else { + lines.push(line); + line = word; + } + } + + if (line) lines.push(line); + return lines; +}; + +/** Wrap `text` and pad every resulting line to its column position. */ +const layoutText = (text: string, layout: Layout): string[] => + wrap(text, layout.width).map((line) => + layout.rightAlign + ? " ".repeat(Math.max(0, PAGE_WIDTH - line.length)) + line + : " ".repeat(layout.indent) + line, + ); + +/** The text of a block as it is printed: uppercased cues, wrapped parentheticals. */ +const printableText = (type: string, text: string): string => { + switch (type) { + case "scene": + case "character": + case "section": + return text.toUpperCase(); + case "transition": + return text.toUpperCase().endsWith(":") ? text.toUpperCase() : text.toUpperCase() + ":"; + case "parenthetical": + return text.startsWith("(") && text.endsWith(")") ? text : `(${text})`; + case "note": + return `[[${text}]]`; + default: + return text; + } +}; + +/** The character cue a dual-dialogue column belongs to, or "" when it has none. */ +const columnCharacter = (column: JSONContent): string => { + const cue = (column.content ?? []).find((node) => node.attrs?.class === "character"); + return cue ? flatten(cue.content) : ""; +}; + +/** Pad `line` to `width` characters so the next column starts at a fixed offset. */ +const padTo = (line: string, width: number): string => + line.length >= width ? line : line + " ".repeat(width - line.length); + +export class FormattedTextAdapter extends ProjectAdapter { + label = "Formatted Text"; + exportTarget = { format: ExportFormat.TEXT, extension: "txt" }; + + // Export-only. The file it writes is a rendering of the script, not a source + // format — the indentation is ambiguous enough that reading it back would be + // guesswork — so `.txt` on import belongs to Fountain instead. + importExtensions = []; + + convertTo(project: ProjectState, options: TextExportOptions): Promise { + const lines: string[] = []; + + const titlePage = (options.includeTitlePage ?? true) ? this.buildTitlePage(project, options) : []; + if (titlePage.length > 0) { + lines.push(...titlePage, PAGE_BREAK); + } + + const nodes = screenplayOf(project); + const characters = options.characters; + // Type of the previous printed block — drives the blank line between + // blocks (a speech stays glued together, a scene heading gets air). + let previousType: string | undefined; + + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + const type: string = node.attrs?.class ?? node.type; + + if (type === "note" && !options.includeNotes) continue; + + let blockLines: string[]; + + if (node.type === "dual_dialogue") { + blockLines = this.layoutDualDialogue(node, options); + } else { + const text = flatten(node.content); + // Skip empty blocks: spacing between blocks is derived from + // their types, so a blank paragraph would only add noise. + if (!text.trim()) continue; + + // Don't export unselected characters — the cue and everything + // it says (parentheticals and dialogue) are dropped together. + if (type === "character" && characters && !characters.includes(text)) { + let j = i + 1; + while (j < nodes.length && SPEECH_BLOCKS.has(nodes[j].attrs?.class)) j++; + i = j - 1; + continue; + } + + const layout = LAYOUTS[type] ?? LAYOUTS.action; + blockLines = layoutText(printableText(type, text), layout); + } + + if (blockLines.length === 0) continue; + + if (node.attrs?.pageBreak) { + lines.push(PAGE_BREAK); + } else if (previousType !== undefined) { + lines.push(...this.separator(previousType, type)); + } + + lines.push(...blockLines); + previousType = type; + } + + const blob = new Blob([lines.join(EOL) + EOL], { type: "text/plain;charset=utf-8" }); + return Promise.resolve(blob); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + convertFrom(_: ArrayBuffer): Partial { + // Formatted text is a rendering, not a source format: the indentation + // is ambiguous enough that `.txt` files are imported as Fountain. + throw new Error("Import from formatted text is not supported"); + } + + /** The blank lines that go between a block of `previous` type and one of `next`. */ + private separator(previous: string, next: string): string[] { + if (next === "scene" || next === "section") return ["", ""]; + + // A speech is one visual unit: the cue, its parentheticals and its + // dialogue run together with no blank line between them. + if (SPEECH_BLOCKS.has(next) && (previous === "character" || SPEECH_BLOCKS.has(previous))) { + // Two dialogue blocks in a row are separate paragraphs of the same + // speech, and those do keep their blank line. + return previous === "dialogue" && next === "dialogue" ? [""] : []; + } + + return [""]; + } + + /** + * Lay a dual dialogue out as two side-by-side columns, one per speaker, so + * the simultaneity survives in plain text. Rows are padded to a fixed width + * and the shorter column simply runs out of lines. + */ + private layoutDualDialogue(node: JSONContent, options: BaseExportOptions): string[] { + const characters = options.characters; + const columns = (node.content ?? []).filter( + (column) => !characters || characters.includes(columnCharacter(column)), + ); + + if (columns.length === 0) return []; + + // Only one speaker survived the character filter — print it as a + // regular full-width speech rather than half a table. + if (columns.length === 1) { + return (columns[0].content ?? []).flatMap((child) => this.layoutSpeechBlock(child, LAYOUTS)); + } + + const [left, right] = columns.map((column) => + (column.content ?? []).flatMap((child) => this.layoutSpeechBlock(child, DUAL_LAYOUTS)), + ); + + const rows = Math.max(left.length, right.length); + const merged: string[] = []; + + for (let row = 0; row < rows; row++) { + const leftLine = left[row] ?? ""; + const rightLine = right[row] ?? ""; + merged.push((padTo(leftLine, DUAL_WIDTH + DUAL_GAP) + rightLine).trimEnd()); + } + + return merged; + } + + /** Lay out one block of a dialogue column with the given set of layouts. */ + private layoutSpeechBlock(child: JSONContent, layouts: Record): string[] { + const type: string = child.attrs?.class ?? child.type ?? "dialogue"; + const text = flatten(child.content); + if (!text.trim()) return []; + return layoutText(printableText(type, text), layouts[type] ?? layouts.dialogue); + } + + /** + * Render the title page document, centred/aligned within the text column. + * The `tp-title` / `tp-author` / `tp-date` atoms carry no text of their own + * (the editor expands them from project metadata), so they are resolved + * from the export options here. + */ + private buildTitlePage(project: ProjectState, options: BaseExportOptions): string[] { + const content = titlepageOf(project); + if (!content || content.length === 0) return []; + + const lines: string[] = []; + + for (const node of content) { + const text = (node.content ?? []) + .map((child) => child.text ?? this.resolveTitlePageAtom(child.type, options)) + .join("") + .trim(); + + if (!text) { + lines.push(""); + continue; + } + + const align = node.attrs?.textAlign; + for (const line of wrap(text, PAGE_WIDTH)) { + if (align === "center") { + lines.push(" ".repeat(Math.floor((PAGE_WIDTH - line.length) / 2)) + line); + } else if (align === "right") { + lines.push(" ".repeat(PAGE_WIDTH - line.length) + line); + } else { + lines.push(line); + } + } + } + + // Drop the leading/trailing blank lines of an otherwise empty page. + while (lines.length > 0 && !lines[0].trim()) lines.shift(); + while (lines.length > 0 && !lines[lines.length - 1].trim()) lines.pop(); + + return lines; + } + + /** Expand a title page format atom to the value it displays. */ + private resolveTitlePageAtom(type: string | undefined, options: BaseExportOptions): string { + switch (type) { + case "tp-title": + return options.title || ""; + case "tp-author": + return options.projectAuthor || ""; + case "tp-date": + return new Date().toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); + default: + return ""; + } + } +} diff --git a/src/lib/adapters/writersolo/writersolo-adapter.ts b/src/lib/adapters/writersolo/writersolo-adapter.ts index 22fd8c52..cd85dbba 100644 --- a/src/lib/adapters/writersolo/writersolo-adapter.ts +++ b/src/lib/adapters/writersolo/writersolo-adapter.ts @@ -266,7 +266,9 @@ function selectScreenplayBranch(branches: Record): unknown { export class WriterSoloAdapter extends ProjectAdapter { label = "WriterSolo"; - extension = "wdz"; + // Import-only: `convertTo` below rejects, so the export UI never offers it. + exportTarget = null; + importExtensions = ["wdz"]; convertTo(): Promise { return Promise.reject(new Error("Export to WriterSolo is not supported")); diff --git a/src/lib/editor/document-editor-config.ts b/src/lib/editor/document-editor-config.ts index 28138373..ccb97c9d 100644 --- a/src/lib/editor/document-editor-config.ts +++ b/src/lib/editor/document-editor-config.ts @@ -10,18 +10,26 @@ export type PaginationMode = "screenplay" | "titlepage"; /** * DOM attributes applied to every editor's ProseMirror contenteditable element. * - * `autocorrect: "off"` maps to iOS `UITextInputTraits.autocorrectionType = .no` - * in the WKWebView, which suppresses the native QuickType predictive-suggestions - * bar that otherwise floats between our MobileFormatToolbar and the keyboard. + * `autocorrect: "on"` maps to iOS `UITextInputTraits.autocorrectionType = .yes` + * in the WKWebView, giving writers the same automatic typo correction they get + * in every other editor on the platform. This was previously "off" to suppress + * the native QuickType bar, but that trait is all-or-nothing: turning off the + * bar also turns off correction itself. The bar is the accepted cost — it is + * included in the visual-viewport inset, so MobileFormatToolbar rides above it + * rather than being overlapped (see useKeyboardInset). + * * `writingsuggestions: "false"` disables Apple Intelligence inline writing - * suggestions on newer iOS for the same reason. `spellcheck: "false"` turns off - * the browser's native red-squiggle spellcheck — we ship our own spellcheck - * engine (see SpellcheckContext), so the native one is redundant and its long- - * press menu adds more unwanted Apple UI. Autocapitalize is intentionally left - * at the default so sentence-case capitalization still works while typing. + * suggestions on newer iOS; that is a distinct trait from autocorrection and + * does not affect it. `spellcheck: "false"` turns off the browser's native + * red-squiggle spellcheck — we ship our own spellcheck engine (see + * SpellcheckContext), so the native one is redundant and its long-press menu + * adds more unwanted Apple UI. Spell checking and autocorrection are separate + * `UITextInputTraits`, so suppressing the squiggles keeps correction working. + * Autocapitalize is intentionally left at the default so sentence-case + * capitalization still works while typing. */ export const EDITOR_INPUT_ATTRIBUTES: Record = { - autocorrect: "off", + autocorrect: "on", autocomplete: "off", spellcheck: "false", writingsuggestions: "false", diff --git a/src/lib/editor/focus-in-viewport.ts b/src/lib/editor/focus-in-viewport.ts index 69793961..dfcc084a 100644 --- a/src/lib/editor/focus-in-viewport.ts +++ b/src/lib/editor/focus-in-viewport.ts @@ -1,4 +1,111 @@ import type { Editor } from "@tiptap/react"; +import { Selection } from "@tiptap/pm/state"; + +import { coveredBottomBand } from "./visible-band"; + +/** Vertical distance from a point to a rect's band; 0 when the point is inside it. */ +const verticalGap = (rect: DOMRect, top: number) => Math.max(rect.top - top, top - rect.bottom, 0); + +const elementRect = (dom: Node | null): DOMRect | null => + dom instanceof HTMLElement ? dom.getBoundingClientRect() : null; + +/** + * Hit-test a client (viewport) point and return a position a caret can actually + * sit in, or null when the point isn't over the document at all. + * + * `posAtCoords` does not only report in-text positions. Whenever the point falls + * outside the box of the block the browser resolved it to — most often *above* + * it, in the blank margin screenplay elements leave between lines, but also left + * of it or past the end of a short line — ProseMirror deliberately throws the + * browser's caret offset away and returns the boundary before/after that block + * instead (`posFromCaret` in prosemirror-view). Such a position lives directly in + * the doc rather than inside any node, and everything downstream then reads it as + * nothing at all: + * + * - the selection's parent is the doc, which carries no `class` attribute, so + * the active screenplay element reports as empty (see `onSelectionUpdate` in + * use-document-editor); + * - `domAtPos` answers with the editor container instead of a line, so the + * centring that follows scrolls to the middle of the whole script. + * + * That blank margin is exactly what a finger aimed at the *first character* of a + * line tends to catch, and the pen button's fixed aim point (a quarter down the + * viewport, see {@link focusEditorInViewport}) lands in one just as easily — + * which is why this showed up as "focusing the start of a node is broken". + * + * Recovery keeps the line the user meant: of the two blocks flanking the + * boundary, take whichever the point is vertically nearest, then hit-test again + * with the point pulled just inside that block's box, so the caret still lands on + * the column the finger was over rather than being dumped at offset 0. If even + * that yields no text position, settle for the block's near edge — its start when + * we came from before it, its end when from after. + */ +const caretPosAtCoords = (editor: Editor, left: number, top: number): number | null => { + const { view } = editor; + const hit = view.posAtCoords({ left, top }); + if (!hit) return null; + + const { doc } = editor.state; + const clamp = (pos: number) => Math.max(0, Math.min(doc.content.size, pos)); + const $hit = doc.resolve(clamp(hit.pos)); + if ($hit.parent.isTextblock) return $hit.pos; + + // A boundary between two blocks (at any depth — the same override fires for + // an ancestor such as a dual-dialogue container). + const before = $hit.nodeBefore; + const after = $hit.nodeAfter; + const beforeRect = elementRect(before && view.nodeDOM($hit.pos - before.nodeSize)); + const afterRect = elementRect(after && view.nodeDOM($hit.pos)); + + let forward: boolean; + if (beforeRect && afterRect) forward = verticalGap(afterRect, top) <= verticalGap(beforeRect, top); + else if (beforeRect) forward = false; + else forward = true; + + // Pull the point just inside the chosen block and ask again: with nothing + // outside its box left to veto, posAtCoords keeps the browser's caret offset, + // so the caret lands on the column the finger was actually over. + const rect = forward ? afterRect : beforeRect; + if (rect && rect.width > 0 && rect.height > 0) { + const retry = view.posAtCoords({ + left: Math.min(Math.max(left, rect.left + 1), rect.right - 1), + top: Math.min(Math.max(top, rect.top + 1), rect.bottom - 1), + }); + if (retry) { + const $retry = doc.resolve(clamp(retry.pos)); + if ($retry.parent.isTextblock) return $retry.pos; + } + } + + // Nothing hit-testable there (an unrendered or zero-sized neighbour): walk to + // the nearest caret position in the chosen direction. `textOnly` makes it + // descend into container nodes rather than stopping on one, so the result is + // always inside a textblock. + const dir = forward ? 1 : -1; + const near = Selection.findFrom($hit, dir, true) ?? Selection.findFrom($hit, -dir, true); + return near ? near.head : null; +}; + +/** + * The DOM element of the block the caret sits in — the thing worth scrolling to. + * + * Read from the caret's *block* rather than from the raw head position: a head + * that isn't inside a textblock makes `domAtPos` answer with the editor + * container, and centring that scrolls to the middle of the entire script. + * {@link caretPosAtCoords} keeps such positions out of the selection, but a caret + * restored from elsewhere could still sit on one, so the container is rejected + * outright rather than scrolled to. + */ +const caretBlockElement = (editor: Editor): HTMLElement | null => { + const { $head } = editor.state.selection; + if ($head.depth > 0) { + const dom = editor.view.nodeDOM($head.before($head.depth)); + if (dom instanceof HTMLElement) return dom; + } + const { node } = editor.view.domAtPos($head.pos); + const element = node instanceof HTMLElement ? node : node.parentElement; + return element && element !== editor.view.dom ? element : null; +}; /** * Focus a screenplay/title editor with the caret dropped on the character at the @@ -20,15 +127,16 @@ import type { Editor } from "@tiptap/react"; * made from the stale selection. */ export const focusEditorAtCoords = (editor: Editor, left: number, top: number) => { - const coords = editor.view.posAtCoords({ left, top }); - if (coords) { - // Place the caret on the character under that point. Tiptap resolves the - // raw position to the nearest valid text selection; it's already visible, - // so the focus's scroll-into-view is a no-op and nothing jumps. - editor.commands.focus(coords.pos); + // Always a position inside a textblock — see caretPosAtCoords for why the raw + // hit test isn't good enough. It's already visible, so the focus's + // scroll-into-view is a no-op and nothing jumps. + const pos = caretPosAtCoords(editor, left, top); + if (pos !== null) { + editor.commands.focus(pos); } else { - // No node under the point (e.g. an empty/short document, or a tap in a - // margin) — fall back to the default focus so the keyboard still comes up. + // Nothing under the point (e.g. an empty/short document, or a tap below + // the end of the script) — fall back to the default focus so the keyboard + // still comes up. editor.commands.focus(); } }; @@ -89,11 +197,7 @@ const findScrollContainer = (from: HTMLElement): HTMLElement | null => { * this same container and must not inherit a keyboard-sized offset. */ const withVisibleBandPadding = (container: HTMLElement, scroll: () => void) => { - const vv = window.visualViewport; - const toolbar = document.querySelector('[role="toolbar"]'); - const covered = toolbar - ? window.innerHeight - toolbar.getBoundingClientRect().top - : window.innerHeight - (vv ? vv.offsetTop + vv.height : window.innerHeight); + const covered = coveredBottomBand(); container.style.scrollPaddingTop = window.getComputedStyle(container).paddingTop; container.style.scrollPaddingBottom = `${covered}px`; @@ -124,8 +228,7 @@ export const centerCaretInView = (editor: Editor) => { const center = () => { stop(); if (editor.isDestroyed) return; - const { node } = editor.view.domAtPos(editor.state.selection.head); - const element = node instanceof HTMLElement ? node : node.parentElement; + const element = caretBlockElement(editor); if (!element) return; const scroll = () => element.scrollIntoView({ behavior: "smooth", block: "center" }); const container = findScrollContainer(element); diff --git a/src/lib/editor/use-document-editor.ts b/src/lib/editor/use-document-editor.ts index 343fd9aa..0a8c4ace 100644 --- a/src/lib/editor/use-document-editor.ts +++ b/src/lib/editor/use-document-editor.ts @@ -346,9 +346,9 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum const editor = useEditor( { immediatelyRender: false, - // Suppress iOS's native autocorrect/predictive UI on the contenteditable - // (see EDITOR_INPUT_ATTRIBUTES). Screenplay editors re-set editorProps via - // setOptions in DocumentEditorPanel, which re-applies these attributes. + // Text-input traits for the contenteditable: native autocorrect on, native + // spellcheck off (see EDITOR_INPUT_ATTRIBUTES). Screenplay editors re-set + // editorProps via setOptions in DocumentEditorPanel, which re-applies these. editorProps: { attributes: EDITOR_INPUT_ATTRIBUTES, }, @@ -473,9 +473,13 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum lastReportedElementRef.current = elementAnchor; cb.setActiveElement?.(elementAnchor, false); - if (anchor.nodeBefore) { - cb.setSelectedStyles?.(getStylesFromMarks([...anchor.nodeBefore.marks])); - } + // $anchor.marks() is the set that applies *at the caret*: the + // preceding text node's marks normally, but the following + // node's at offset 0, where there is no nodeBefore. Reading + // nodeBefore directly left the style buttons showing the + // previous line's marks whenever the caret landed on a node's + // first character (and none at all in an empty node). + cb.setSelectedStyles?.(getStylesFromMarks([...anchor.marks()])); if (!transaction.docChanged) { setSuggestions([]); } diff --git a/src/lib/editor/use-keyboard-caret-visibility.ts b/src/lib/editor/use-keyboard-caret-visibility.ts new file mode 100644 index 00000000..ce9c29c9 --- /dev/null +++ b/src/lib/editor/use-keyboard-caret-visibility.ts @@ -0,0 +1,122 @@ +"use client"; + +import { useEffect } from "react"; +import type { Editor } from "@tiptap/react"; + +import { coveredBottomBand } from "./visible-band"; + +/** Breathing room kept between the caret's line and the top of that chrome. */ +const CARET_GAP = 12; + +/** + * How long after focus to re-measure. The format toolbar is mounted in reaction + * to the editor focusing, so it isn't in the DOM yet when the focus event fires + * and the band measured then is ~a toolbar short. + */ +const TOOLBAR_SETTLE_MS = 250; + +interface KeyboardCaretVisibilityOptions { + /** The panel's scroll container (`.container` in EditorPanel.module.css). */ + container: HTMLElement | null; + editor: Editor | null; + /** Phone, in edit mode — nowhere else can the keyboard cover the editor. */ + enabled: boolean; +} + +/** + * Keep the line being written clear of the on-screen keyboard and the + * MobileFormatToolbar riding on it. Two things are needed, and neither happens + * on its own: + * + * - **Room to scroll into.** The shell is pinned to the layout viewport and the + * keyboard just covers its bottom, so the script's last lines sit *behind* the + * keyboard with nothing below them to scroll up — the wrapper's fixed tail + * (40% of its width, ~150px on a phone) is far shorter than a keyboard. + * `--keyboard-inset` adds exactly the covered height to that tail while the + * keyboard is up; `.editor_wrapper` inherits it from the scroll container and + * folds it into its bottom padding, so the end of the script can always be + * brought above the chrome. + * - **Something to do the scrolling.** WebKit does chase the caret, but it aims + * at the visual viewport and knows nothing about the toolbar floating over it, + * so it parks the caret behind the bar; and when the container can't scroll + * far enough it scrolls the *document* instead, which the shell's anchoring + * guard (see ProjectWorkspace) snaps straight back — leaving the caret hidden. + * So after every caret move we measure and top the scroll up ourselves. + * + * Only ever scrolls *down*, and only in reaction to an edit or a caret move, so a + * reader who scrolls away from their own caret is never yanked back to it. + */ +export const useKeyboardCaretVisibility = ({ + container, + editor, + enabled, +}: KeyboardCaretVisibilityOptions) => { + useEffect(() => { + if (!enabled || !container || !editor || typeof window === "undefined") return; + + let frame: number | null = null; + let settleTimer: ReturnType | null = null; + + const update = () => { + frame = null; + if (editor.isDestroyed) return; + + const covered = coveredBottomBand(); + // The reserved room tracks the keyboard alone, not this editor's + // focus: a tap on the toolbar blurs the contenteditable for a tick, + // and dropping the padding in that gap would clamp the scroll and + // jerk the page down under the finger. + if (covered > 0) container.style.setProperty("--keyboard-inset", `${covered}px`); + else container.style.removeProperty("--keyboard-inset"); + + // The scroll, on the other hand, must only follow a caret that is + // really being written in — another field (the screenplay search) can + // own the keyboard while this editor holds a stale selection. + if (covered <= 0 || !editor.isFocused) return; + + let caretBottom: number; + try { + caretBottom = editor.view.coordsAtPos(editor.state.selection.head).bottom; + } catch { + // Position not rendered yet (a collaboration edit mid-flight); + // the next caret move measures again. + return; + } + const overflow = caretBottom - (window.innerHeight - covered - CARET_GAP); + if (overflow > 0) container.scrollTop += overflow; + }; + + // Coalesce to one measurement per frame: a keystroke fires both a + // selection update and an input event, and the keyboard rising fires a + // burst of viewport resizes. + const schedule = () => { + if (frame == null) frame = requestAnimationFrame(update); + }; + + const onFocus = () => { + schedule(); + if (settleTimer) clearTimeout(settleTimer); + settleTimer = setTimeout(schedule, TOOLBAR_SETTLE_MS); + }; + + const dom = editor.view.dom; + editor.on("selectionUpdate", schedule); + editor.on("focus", onFocus); + // Catches edits that push the caret down without moving it in the + // document — text wrapping onto a new visual line. + dom.addEventListener("input", schedule); + // The keyboard opening, closing, or being swapped for a taller one. + window.visualViewport?.addEventListener("resize", schedule); + schedule(); + + return () => { + if (frame != null) cancelAnimationFrame(frame); + if (settleTimer) clearTimeout(settleTimer); + editor.off("selectionUpdate", schedule); + editor.off("focus", onFocus); + dom.removeEventListener("input", schedule); + window.visualViewport?.removeEventListener("resize", schedule); + container.style.removeProperty("--keyboard-inset"); + }; + }, [container, editor, enabled]); +}; diff --git a/src/lib/editor/visible-band.ts b/src/lib/editor/visible-band.ts new file mode 100644 index 00000000..3cc4019b --- /dev/null +++ b/src/lib/editor/visible-band.ts @@ -0,0 +1,42 @@ +/** + * How much of the layout viewport the on-screen keyboard — and the phone format + * toolbar riding on top of it — currently cover at the bottom. + * + * The app shell is pinned to the layout viewport (100vh, and ProjectWorkspace + * locks window scroll to 0), so the keyboard shrinks nothing: it simply sits over + * the bottom of the editor. Everything that has to keep content clear of it — + * the reserved scroll room and caret follow in useKeyboardCaretVisibility, the + * centring in centerCaretInView, the toolbar's own offset — measures from here so + * they all agree on where "visible" ends. + */ + +// Below this the visualViewport shrink is just browser chrome jitter, not an +// open on-screen keyboard. +export const KEYBOARD_THRESHOLD = 120; + +/** Height (px) the on-screen keyboard hides at the bottom. 0 when it's down. */ +export const keyboardInsetNow = (): number => { + if (typeof window === "undefined" || !window.visualViewport) return 0; + const vv = window.visualViewport; + // Layout-viewport height minus the visible visual viewport (and any offset + // from a scrolled visual viewport) is what the keyboard hides. + const covered = window.innerHeight - vv.height - vv.offsetTop; + return covered > KEYBOARD_THRESHOLD ? covered : 0; +}; + +/** + * The keyboard inset plus the MobileFormatToolbar floating above it (found by its + * `role="toolbar"`) — i.e. everything between the top of that chrome and the + * bottom of the layout viewport. 0 when no keyboard is up. + * + * `Math.max` rather than the toolbar alone: the bar is only mounted while an + * editor holds the keyboard, and it mounts a beat after the keyboard starts + * rising, so the raw inset is the floor until it appears. + */ +export const coveredBottomBand = (): number => { + const keyboard = keyboardInsetNow(); + if (keyboard <= 0) return 0; + const toolbar = document.querySelector('[role="toolbar"]'); + if (!toolbar) return keyboard; + return Math.max(keyboard, window.innerHeight - toolbar.getBoundingClientRect().top); +}; diff --git a/src/lib/editor/zoom-scroll-anchor.ts b/src/lib/editor/zoom-scroll-anchor.ts new file mode 100644 index 00000000..b05cf9e0 --- /dev/null +++ b/src/lib/editor/zoom-scroll-anchor.ts @@ -0,0 +1,172 @@ +"use client"; + +/** + * Keeps the reader looking at the same line across an editor zoom change. + * + * Scaling the scroll offset by the zoom ratio (`centre * factor`) looks right on + * paper but assumes the document's height scales *exactly* with the zoom — that + * a point 40 000px down lands at 48 000px at 1.2×. Browsers don't honour that: + * `zoom` re-lays the page out at scaled font sizes, so per-line rounding (and, + * on WebKit below ~0.56×, the minimum-font-size clamp changing where lines wrap) + * leaves the real height slightly off the ideal multiple. The error is per line, + * so it accumulates with depth — measured at up to ~900px (a page or two) 27 + * pages into a script on WebKit, while Chromium stayed within 5px. + * + * So this anchors to content instead of arithmetic, closed-loop: record which + * block the viewport centre sits in and how far through it, then afterwards + * measure where that block actually landed and scroll by the difference. No + * assumption about the scale is made anywhere — it reads the new geometry — so + * any non-proportional layout change (a re-wrap, a scrollbar appearing, the + * wrapper's percentage padding) corrects itself. + * + * Sibling of {@link useViewModeScrollAnchor}, which does the same for the + * endless ⇄ paged switch. Kept separate because that one anchors the block at + * the top of the fold across a mode toggle it doesn't own, while zoom is applied + * here and wants the viewport centre (the page grows symmetrically around it). + */ + +/** + * Frame budget for re-applying the correction. The zoom doesn't necessarily + * finish settling in the commit that applies it — a ResizeObserver-driven + * relayout or a content-visibility subtree becoming relevant can land a frame or + * two later. Each pass re-measures from scratch, so they converge rather than + * compound, and the loop stops as soon as the anchor holds still. + */ +const MAX_SETTLE_FRAMES = 5; + +/** Frames the anchor must hold still before the layout counts as settled. */ +const SETTLED_FRAMES = 2; + +/** Below this many px the anchor is where it should be; nothing to correct. */ +const SETTLED_EPSILON = 0.5; + +export interface ZoomScrollAnchor { + /** Top-level block the viewport centre sat in before the rescale. */ + el: HTMLElement; + /** How far through that block the centre sat, as a fraction of its height. */ + ratioY: number; + /** Where the centre sat across the page, as a fraction of the page width. */ + ratioX: number; + /** + * The reader was at the very start of the document. Anchoring the centre + * there would need to scroll *up* past the top when zooming out, which + * clamps at 0 and quietly loses the position — so a zoom in and back out + * would leave them a few lines into the script instead of at the top. The + * top of the document is a fixed point instead: nothing above it to keep. + */ + atTop: boolean; +} + +const clamp = (value: number, max: number) => Math.max(0, Math.min(max, value)); + +/** + * Record what the reader is looking at. Call while the *outgoing* layout is + * still on screen — i.e. before the zoom variable is applied. + * + * Ratios rather than pixel offsets: the block's height and the page's width both + * change with the zoom, and a fraction of each survives that unchanged, so + * {@link restoreZoomAnchor} needs no knowledge of the zoom levels involved. + */ +export const captureZoomAnchor = ( + container: HTMLElement, + editorDom: HTMLElement | null | undefined, +): ZoomScrollAnchor | null => { + const blocks = editorDom?.children; + if (!editorDom || !blocks || blocks.length === 0) return null; + + const containerRect = container.getBoundingClientRect(); + const focusY = containerRect.top + container.clientHeight / 2; + const focusX = containerRect.left + container.clientWidth / 2; + + // First block whose bottom edge is still below the focal line. Binary search + // rather than a scan: rects are monotonic in document order and a + // feature-length script has thousands of blocks. The first read flushes + // layout, so the ~12 that follow are cheap. + let lo = 0; + let hi = blocks.length - 1; + let found = hi; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if ((blocks[mid] as HTMLElement).getBoundingClientRect().bottom > focusY) { + found = mid; + hi = mid - 1; + } else { + lo = mid + 1; + } + } + + // A collapsed block (display:none, e.g. a page break hidden by endless + // scroll) has no position to come back to and would make the ratio + // meaningless — step on to the next block that is actually laid out. + let el = blocks[found] as HTMLElement; + let rect = el.getBoundingClientRect(); + for (let i = found + 1; rect.height === 0 && i < blocks.length; i++) { + el = blocks[i] as HTMLElement; + rect = el.getBoundingClientRect(); + } + if (rect.height === 0) return null; + + // Signed and unbounded on purpose: when the centre falls in the gap between + // pages the ratio lands outside [0, 1], which still restores exactly — the + // block scales with everything around it. + const pageRect = editorDom.getBoundingClientRect(); + return { + el, + ratioY: (focusY - rect.top) / rect.height, + ratioX: pageRect.width > 0 ? (focusX - pageRect.left) / pageRect.width : 0.5, + atTop: container.scrollTop < 1, + }; +}; + +/** + * Put the anchored content back under the viewport centre, by scrolling the + * container by however far the new layout moved it. Returns whether it was + * already in place, i.e. there was nothing to correct. + */ +export const restoreZoomAnchor = ( + container: HTMLElement, + editorDom: HTMLElement | null | undefined, + anchor: ZoomScrollAnchor, +): boolean => { + if (!editorDom || !anchor.el.isConnected) return true; + + const containerRect = container.getBoundingClientRect(); + const rect = anchor.el.getBoundingClientRect(); + const pageRect = editorDom.getBoundingClientRect(); + + const deltaY = anchor.atTop + ? -container.scrollTop + : rect.top + anchor.ratioY * rect.height - (containerRect.top + container.clientHeight / 2); + const deltaX = pageRect.left + anchor.ratioX * pageRect.width - (containerRect.left + container.clientWidth / 2); + if (Math.abs(deltaY) < SETTLED_EPSILON && Math.abs(deltaX) < SETTLED_EPSILON) return true; + + container.scrollTop = clamp(container.scrollTop + deltaY, container.scrollHeight - container.clientHeight); + container.scrollLeft = clamp(container.scrollLeft + deltaX, container.scrollWidth - container.clientWidth); + return false; +}; + +/** + * Restore now, then keep re-checking for a few frames in case the new layout is + * still settling. Returns a cancel function for the caller's effect cleanup — + * call it before starting another zoom so successive steps don't run competing + * loops. + */ +export const settleZoomAnchor = ( + container: HTMLElement, + editorDom: HTMLElement | null | undefined, + anchor: ZoomScrollAnchor, +): (() => void) => { + restoreZoomAnchor(container, editorDom, anchor); + + let frames = 0; + let stillFrames = 0; + let raf = requestAnimationFrame(function settle() { + stillFrames = restoreZoomAnchor(container, editorDom, anchor) ? stillFrames + 1 : 0; + // Held still for a couple of frames: the new layout has stopped shifting + // under us, so stop re-measuring rather than burning the whole budget on + // a heavy document. + if (stillFrames >= SETTLED_FRAMES || ++frames >= MAX_SETTLE_FRAMES) return; + raf = requestAnimationFrame(settle); + }); + return () => cancelAnimationFrame(raf); +}; diff --git a/src/lib/import/import-project.ts b/src/lib/import/import-project.ts index 62ab5792..c1b9f428 100644 --- a/src/lib/import/import-project.ts +++ b/src/lib/import/import-project.ts @@ -5,7 +5,7 @@ import { ProjectData, ProjectState, applyProjectData } from "@src/lib/project/project-state"; import { CURRENT_PROJECT_VERSION } from "@src/lib/project/migrations/project-migrations"; -import { getAdapterByFilename } from "@src/lib/adapters/registry"; +import { getImportAdapterByFilename } from "@src/lib/adapters/registry"; import { restoreScriptioAssets } from "@src/lib/adapters/scriptio/scriptio-adapter"; import { createCachedProject, createCachedProjectWithId } from "@src/lib/persistence/storage-provider/local-persistence"; import { writeYjsDocumentLocally } from "@src/lib/persistence/y-local-provider"; @@ -27,7 +27,7 @@ export interface ImportResult { * Parse a file and extract project content. */ function parseProjectData(filename: string, content: ArrayBuffer): ProjectData { - const adapter = getAdapterByFilename(filename); + const adapter = getImportAdapterByFilename(filename); if (!adapter) { throw new Error(`Unsupported file type: ${filename.split(".").pop()}`); } @@ -55,7 +55,7 @@ export async function importFileIntoProject( titlePageEditor?: Editor | null, repository?: ProjectRepository | null, ): Promise { - const adapter = getAdapterByFilename(file.name); + const adapter = getImportAdapterByFilename(file.name); if (!adapter) { throw new Error(`Unsupported file type: ${file.name.split(".").pop()}`); } @@ -183,10 +183,3 @@ export async function importFileAsProject( }; } } - -/** - * Get supported import file extensions. - */ -export function getSupportedImportExtensions(): string { - return ".fountain,.txt,.fdx,.scriptio,.fadein,.wdz"; -} diff --git a/src/lib/utils/enums.ts b/src/lib/utils/enums.ts index d5f3142b..8a3f6887 100644 --- a/src/lib/utils/enums.ts +++ b/src/lib/utils/enums.ts @@ -34,6 +34,24 @@ export enum SaveMode { export type PageFormat = "A4" | "LETTER"; +/** + * A format the project can be exported TO, as asked for by the export UI. + * + * These are format ids, not file extensions — the adapter owns the extension it + * writes. The two differ wherever an extension means something else on import: + * `TEXT` writes a `.txt` file, but a `.txt` file being imported is read as + * Fountain, so the id has to be distinct. Each adapter declares which id it + * answers to (see `ProjectAdapter.exportTarget`), so this enum is the single + * list of what the UI may ask for. + */ +export enum ExportFormat { + PDF = "pdf", + FOUNTAIN = "fountain", + FDX = "fdx", + TEXT = "text", + SCRIPTIO = "scriptio", +} + export enum Style { None = 0, Bold = 1, diff --git a/src/lib/utils/hooks.ts b/src/lib/utils/hooks.ts index 26372e95..cfd53263 100644 --- a/src/lib/utils/hooks.ts +++ b/src/lib/utils/hooks.ts @@ -14,6 +14,7 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { ProjectRole } from "../../generated/client/browser"; import { isTauri } from "@tauri-apps/api/core"; import { useTranslations } from "next-intl"; +import { keyboardInsetNow } from "@src/lib/editor/visible-band"; interface Position { x: number; @@ -105,10 +106,6 @@ const useIsPhone = (): boolean => { }; -// Below this the visualViewport shrink is just browser chrome jitter, not an -// open on-screen keyboard. -const KEYBOARD_THRESHOLD = 120; - /** * Distance in px the on-screen keyboard covers at the bottom of the layout * viewport, tracked via the VisualViewport API. 0 when no keyboard is up. @@ -121,12 +118,7 @@ const useKeyboardInset = (enabled: boolean): number => { // harmless — just don't subscribe. if (!enabled || typeof window === "undefined" || !window.visualViewport) return; const vv = window.visualViewport; - const update = () => { - // Layout-viewport height minus the visible visual viewport (and any - // offset from a scrolled visual viewport) is what the keyboard hides. - const covered = window.innerHeight - vv.height - vv.offsetTop; - setInset(covered > KEYBOARD_THRESHOLD ? covered : 0); - }; + const update = () => setInset(keyboardInsetNow()); update(); vv.addEventListener("resize", update); vv.addEventListener("scroll", update); diff --git a/src/tests/adapters/pdf-zoom-invariance.test.ts b/src/tests/adapters/pdf-zoom-invariance.test.ts new file mode 100644 index 00000000..916fffb7 --- /dev/null +++ b/src/tests/adapters/pdf-zoom-invariance.test.ts @@ -0,0 +1,180 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { PDFAdapter, type PDFExportOptions } from "@src/lib/adapters/pdf/pdf-adapter"; +import type { VisualLine } from "@src/lib/adapters/pdf/pdf.worker"; + +/** + * The PDF exporter reads its geometry from the live editor DOM, which the user + * can scale on screen (desktop `zoom`, phone paged `transform: scale`). These + * tests mount a miniature editor, scale it, and assert the collected lines are + * the same as at 1× — the PDF must not depend on the current zoom level. + * + * Runs in real Chromium and WebKit (see vitest.config.ts): the whole point is + * browser layout, which jsdom cannot provide. + */ + +// The adapter's DOM pass is internal; tests reach it directly rather than +// booting a worker + jsPDF to produce a blob. +type AdapterInternals = { + collectLines(el: HTMLElement, options: PDFExportOptions): VisualLine[]; + withCanonicalScale(elements: (HTMLElement | undefined)[], measure: () => T): T; + getPageLeftPx(el: HTMLElement): number; +}; + +const internals = (adapter: PDFAdapter) => adapter as unknown as AdapterInternals; + +const options = { includeNotes: true } as PDFExportOptions; + +/** Long enough to wrap several times inside the dialogue column. */ +const LONG_LINE = + "the quick brown fox jumps over the lazy dog while the dog sleeps on and " + + "on beneath a wide and cloudless afternoon sky above the quiet valley"; + +const teardown: Array<() => void> = []; +afterEach(() => { + while (teardown.length) teardown.pop()!(); +}); + +/** + * Mount a stand-in for the editor: a scroll container holding a page-width + * `.ProseMirror` whose scale is driven by the same CSS variables the real + * stylesheet uses, so pinning it exercises the production code path. + */ +const mountEditor = () => { + const style = document.createElement("style"); + style.textContent = ` + .test-scroller { width: 600px; height: 300px; overflow: auto; } + .test-pm { + width: 612px; + box-sizing: border-box; + font: 16px monospace; + line-height: 16px; + --page-margin-left: 96px; + --page-margin-right: 96px; + zoom: var(--editor-user-zoom, 1); + transform: scale(var(--editor-zoom, 1)); + transform-origin: top center; + } + .test-pm p { margin: 0 0 16px 0; padding: 0 96px; } + .test-pm p.dialogue { padding: 0 168px 0 240px; } + .test-pm p.character { padding: 0 0 0 336px; text-transform: uppercase; } + `; + document.head.appendChild(style); + + const scroller = document.createElement("div"); + scroller.className = "test-scroller"; + const editor = document.createElement("div"); + editor.className = "test-pm"; + editor.innerHTML = ` +

INT. TEST STAGE - DAY

+

${LONG_LINE}

+

a character

+

${LONG_LINE}

+

+

${LONG_LINE}

+ `; + scroller.appendChild(editor); + document.body.appendChild(scroller); + + teardown.push(() => { + scroller.remove(); + style.remove(); + }); + return { scroller, editor }; +}; + +/** + * The geometry the worker actually consumes: X relative to the page edge and Y + * relative to the first line, both of which must be scale-independent. Rounded + * to whole pixels so sub-pixel layout noise doesn't fail the comparison. + */ +const signature = (adapter: PDFAdapter, editor: HTMLElement): string[] => { + const lines = internals(adapter).collectLines(editor, options); + const pageLeft = internals(adapter).getPageLeftPx(editor); + const firstY = lines.find((l) => l.runs.length > 0)?.y ?? 0; + return lines.map((line) => { + const text = line.runs.map((r) => r.text).join(""); + const x = line.runs.length > 0 ? Math.round(line.runs[0].x - pageLeft) : 0; + return `${line.type ?? ""}|${Math.round(line.y - firstY)}|${x}|${text}`; + }); +}; + +describe("PDF export is invariant of the editor zoom level", () => { + // Desktop zoom (CSS `zoom`) and phone paged mode (`transform: scale`) are + // the two mechanisms in play; 0.5 also covers WebKit's minimum-font-size + // clamp, which changes line wrapping under `zoom` and cannot be undone by + // scaling the numbers afterwards. + const scales: Array<[string, string, number]> = [ + ["desktop zoom in", "--editor-user-zoom", 1.75], + ["desktop zoom out", "--editor-user-zoom", 0.5], + ["phone paged transform", "--editor-zoom", 0.48], + ]; + + for (const [label, variable, value] of scales) { + it(`matches the 1x layout with ${label} (${value})`, () => { + const adapter = new PDFAdapter(); + const { editor } = mountEditor(); + + const canonical = signature(adapter, editor); + expect(canonical.length).toBeGreaterThan(6); // wrapped, not one line per

+ + editor.style.setProperty(variable, `${value}`); + + // Guard the test itself: the raw DOM measurements must actually be + // contaminated by the scale, otherwise nothing is being proven. + expect(signature(adapter, editor)).not.toEqual(canonical); + + const pinned = internals(adapter).withCanonicalScale([editor], () => signature(adapter, editor)); + expect(pinned).toEqual(canonical); + }); + } + + it("restores the on-screen scale and scroll position afterwards", () => { + const adapter = new PDFAdapter(); + const { scroller, editor } = mountEditor(); + editor.style.setProperty("--editor-user-zoom", "2.5"); + editor.style.setProperty("opacity", "0.9"); // an unrelated inline style must survive + + const scaledWidth = editor.getBoundingClientRect().width; + scroller.scrollTop = scroller.scrollHeight - scroller.clientHeight; + scroller.scrollLeft = scroller.scrollWidth - scroller.clientWidth; + const { scrollTop, scrollLeft } = scroller; + expect(scrollTop).toBeGreaterThan(0); + expect(scrollLeft).toBeGreaterThan(0); + + internals(adapter).withCanonicalScale([editor], () => signature(adapter, editor)); + + expect(editor.getBoundingClientRect().width).toBeCloseTo(scaledWidth, 1); + expect(editor.style.zoom).toBe(""); + expect(editor.style.transform).toBe(""); + expect(editor.style.opacity).toBe("0.9"); + expect(scroller.scrollTop).toBe(scrollTop); + expect(scroller.scrollLeft).toBe(scrollLeft); + }); + + it("restores the scale even when the measurement throws", () => { + const adapter = new PDFAdapter(); + const { editor } = mountEditor(); + editor.style.setProperty("--editor-user-zoom", "2"); + const scaledWidth = editor.getBoundingClientRect().width; + + expect(() => + internals(adapter).withCanonicalScale([editor], () => { + throw new Error("measurement failed"); + }), + ).toThrow("measurement failed"); + + expect(editor.getBoundingClientRect().width).toBeCloseTo(scaledWidth, 1); + }); + + it("keeps an inline scale the editor itself set", () => { + const adapter = new PDFAdapter(); + const { editor } = mountEditor(); + editor.style.setProperty("zoom", "1.5", "important"); + + internals(adapter).withCanonicalScale([editor], () => signature(adapter, editor)); + + expect(editor.style.zoom).toBe("1.5"); + expect(editor.style.getPropertyPriority("zoom")).toBe("important"); + }); +}); diff --git a/src/tests/adapters/registry.test.ts b/src/tests/adapters/registry.test.ts new file mode 100644 index 00000000..148699f2 --- /dev/null +++ b/src/tests/adapters/registry.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "vitest"; + +import { + getExportAdapter, + getImportAdapterByFilename, + getRegisteredAdapters, + getSupportedImportExtensions, +} from "@src/lib/adapters/registry"; +import { FountainAdapter } from "@src/lib/adapters/fountain/fountain-adapter"; +import { FormattedTextAdapter } from "@src/lib/adapters/text/text-adapter"; +import { PDFAdapter } from "@src/lib/adapters/pdf/pdf-adapter"; +import { ExportFormat } from "@src/lib/utils/enums"; +import { ProjectState } from "@src/lib/project/project-state"; + +/** + * The registry keys reading by file extension and writing by `ExportFormat` id, + * because `.txt` means different things in each direction: it is written by the + * formatted-text exporter but read as Fountain. These tests pin that down, plus + * the invariants that keep the two maps unambiguous as adapters are added. + */ +describe("adapter registry", () => { + describe("import routing", () => { + it("reads both .fountain and .txt as Fountain", () => { + expect(getImportAdapterByFilename("script.fountain")).toBeInstanceOf(FountainAdapter); + expect(getImportAdapterByFilename("script.txt")).toBeInstanceOf(FountainAdapter); + // Same instance: one adapter owns both extensions, not two copies. + expect(getImportAdapterByFilename("script.txt")).toBe(getImportAdapterByFilename("a.fountain")); + }); + + it("ignores extension case and earlier dots in the name", () => { + expect(getImportAdapterByFilename("SCRIPT.TXT")).toBeInstanceOf(FountainAdapter); + expect(getImportAdapterByFilename("my.draft.v2.Fountain")).toBeInstanceOf(FountainAdapter); + }); + + it("routes the other readable formats to their own adapters", () => { + for (const [filename, extension] of [ + ["a.fdx", "fdx"], + ["a.scriptio", "scriptio"], + ["a.fadein", "fadein"], + ["a.wdz", "wdz"], + ] as const) { + expect(getImportAdapterByFilename(filename)?.importExtensions, filename).toContain(extension); + } + }); + + it("claims nothing for export-only or unknown formats", () => { + expect(getImportAdapterByFilename("a.pdf")).toBeUndefined(); + expect(getImportAdapterByFilename("a.docx")).toBeUndefined(); + expect(getImportAdapterByFilename("noextension")).toBeUndefined(); + expect(getImportAdapterByFilename("")).toBeUndefined(); + }); + }); + + describe("export routing", () => { + it("resolves every format the UI can ask for", () => { + for (const format of Object.values(ExportFormat)) { + expect(getExportAdapter(format), format).toBeDefined(); + } + }); + + it("gives formatted text the .txt writer, not the Fountain reader", () => { + const adapter = getExportAdapter(ExportFormat.TEXT); + expect(adapter).toBeInstanceOf(FormattedTextAdapter); + expect(adapter?.exportTarget?.extension).toBe("txt"); + expect(getExportAdapter(ExportFormat.FOUNTAIN)).toBeInstanceOf(FountainAdapter); + expect(getExportAdapter(ExportFormat.PDF)).toBeInstanceOf(PDFAdapter); + }); + + it("does not expose import-only adapters as export targets", () => { + // FadeIn / WriterSolo cannot be written; their ids must not resolve, + // even though those strings are valid import extensions. + expect(getExportAdapter("fadein" as ExportFormat)).toBeUndefined(); + expect(getExportAdapter("wdz" as ExportFormat)).toBeUndefined(); + // ...and "txt" is an extension, never an export id. + expect(getExportAdapter("txt" as ExportFormat)).toBeUndefined(); + }); + }); + + describe("invariants", () => { + it("has no two adapters claiming the same import extension", () => { + const owners = new Map(); + for (const adapter of getRegisteredAdapters()) { + for (const extension of adapter.importExtensions) { + const key = extension.toLowerCase(); + expect(owners.has(key), `.${key} claimed by both ${owners.get(key)} and ${adapter.label}`).toBe( + false, + ); + owners.set(key, adapter.label); + } + } + }); + + it("has no two adapters claiming the same export format", () => { + const owners = new Map(); + for (const adapter of getRegisteredAdapters()) { + const format = adapter.exportTarget?.format; + if (!format) continue; + expect( + owners.has(format), + `${format} claimed by both ${owners.get(format)} and ${adapter.label}`, + ).toBe(false); + owners.set(format, adapter.label); + } + }); + + it("declares extensions bare and lower-case", () => { + for (const adapter of getRegisteredAdapters()) { + const written = adapter.exportTarget ? [adapter.exportTarget.extension] : []; + for (const extension of [...written, ...adapter.importExtensions]) { + expect(extension, adapter.label).toBeTruthy(); + expect(extension, adapter.label).toBe(extension.toLowerCase()); + expect(extension.startsWith("."), adapter.label).toBe(false); + } + } + }); + + it("names exported files with the written extension, not the export id", () => { + // Formatted text answers to "text" but must write a .txt file, so the + // two halves of an ExportTarget are never interchangeable. + for (const [format, extension] of [ + [ExportFormat.PDF, "pdf"], + [ExportFormat.FOUNTAIN, "fountain"], + [ExportFormat.FDX, "fdx"], + [ExportFormat.TEXT, "txt"], + [ExportFormat.SCRIPTIO, "scriptio"], + ] as const) { + expect(getExportAdapter(format)?.exportTarget?.extension, format).toBe(extension); + } + }); + + it("keeps every adapter reachable in at least one direction", () => { + for (const adapter of getRegisteredAdapters()) { + const reachable = adapter.importExtensions.length > 0 || adapter.exportTarget !== null; + expect(reachable, `${adapter.label} can neither be imported nor exported`).toBe(true); + } + }); + + it("refuses to export an import-only format with a readable error", async () => { + // The guard in `export()` fires before anything touches a filename. + const fadeIn = getImportAdapterByFilename("a.fadein")!; + expect(fadeIn.exportTarget).toBeNull(); + await expect( + fadeIn.export(new ProjectState(), { + title: "T", + author: "a@b.c", + includeNotes: false, + }), + ).rejects.toThrow(/cannot be exported/); + }); + + it("refuses to read the formats it only writes", () => { + const empty = new ArrayBuffer(0); + expect(() => new PDFAdapter().convertFrom(empty)).toThrow(); + expect(() => new FormattedTextAdapter().convertFrom(empty)).toThrow(); + }); + }); + + describe("file picker accept list", () => { + it("offers exactly the readable extensions", () => { + const accepted = getSupportedImportExtensions().split(","); + const declared = getRegisteredAdapters().flatMap((a) => a.importExtensions.map((e) => `.${e}`)); + expect([...accepted].sort()).toEqual([...declared].sort()); + }); + + it("includes .txt and .fountain and excludes what cannot be read", () => { + const accepted = getSupportedImportExtensions(); + expect(accepted).toContain(".fountain"); + expect(accepted).toContain(".txt"); + expect(accepted).not.toContain(".pdf"); + }); + }); +}); diff --git a/src/tests/adapters/text-export.test.ts b/src/tests/adapters/text-export.test.ts new file mode 100644 index 00000000..40c85814 --- /dev/null +++ b/src/tests/adapters/text-export.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it } from "vitest"; +import { prosemirrorJSONToYXmlFragment } from "y-prosemirror"; +import type { JSONContent } from "@tiptap/react"; + +import { ProjectState } from "@src/lib/project/project-state"; +import { ScreenplaySchema } from "@src/lib/screenplay/editor"; +import { TitlePageSchema } from "@src/lib/titlepage/editor"; +import { FormattedTextAdapter, type TextExportOptions } from "@src/lib/adapters/text/text-adapter"; + +let nextId = 0; +const block = (type: string, text: string, attrs: Record = {}): JSONContent => ({ + type, + attrs: { "data-id": `n${nextId++}`, class: type, ...attrs }, + content: text ? [{ type: "text", text }] : undefined, +}); + +const column = (...nodes: JSONContent[]): JSONContent => ({ type: "dual_dialogue_column", content: nodes }); + +const tpLine = (content: JSONContent[], align: string): JSONContent => ({ + type: "tp-text", + attrs: { textAlign: align }, + content, +}); + +const titlePage: JSONContent[] = [ + tpLine([{ type: "tp-title" }], "center"), + tpLine([{ type: "text", text: "Written by " }, { type: "tp-author" }], "center"), +]; + +const opts: TextExportOptions = { title: "T", author: "a@b.c", projectAuthor: "A", includeNotes: false }; + +/** Export `nodes` (plus an optional title page) and return the lines of the file. */ +const exportLines = async ( + nodes: JSONContent[], + options: TextExportOptions = opts, + titlepage?: JSONContent[], +): Promise => { + const ydoc = new ProjectState(); + prosemirrorJSONToYXmlFragment( + ScreenplaySchema, + { type: "doc", content: nodes }, + ydoc.screenplayFragment(), + ); + if (titlepage) { + prosemirrorJSONToYXmlFragment( + TitlePageSchema, + { type: "doc", content: titlepage }, + ydoc.titlepageFragment(), + ); + } + + const blob = await new FormattedTextAdapter().convertTo(ydoc, options); + const text = await blob.text(); + ydoc.destroy(); + return text.split("\r\n"); +}; + +/** Column the first non-space character of `line` sits at, or -1 for a blank line. */ +const indentOf = (line: string) => line.search(/\S/); + +describe("Formatted text export", () => { + it("places each element at its standard margin", async () => { + const lines = await exportLines([ + block("scene", "int. house - day"), + block("action", "Jane walks in."), + block("character", "jane"), + block("parenthetical", "softly"), + block("dialogue", "Hello."), + block("transition", "cut to"), + ]); + + const find = (needle: string) => lines.find((l) => l.includes(needle))!; + + expect(find("INT. HOUSE")).toBe("INT. HOUSE - DAY"); + expect(indentOf(find("Jane walks in."))).toBe(0); + expect(indentOf(find("JANE"))).toBe(22); + expect(find("softly").trim()).toBe("(softly)"); + expect(indentOf(find("softly"))).toBe(15); + expect(indentOf(find("Hello."))).toBe(10); + // Transitions are flushed against the right edge of the text column. + expect(find("CUT TO")).toBe(" ".repeat(53) + "CUT TO:"); + }); + + it("keeps a speech together and separates other blocks with a blank line", async () => { + const lines = await exportLines([ + block("action", "Jane walks in."), + block("character", "JANE"), + block("parenthetical", "softly"), + block("dialogue", "Hello."), + block("dialogue", "Anyone home?"), + block("action", "Silence."), + ]); + + expect(lines.slice(0, 9)).toEqual([ + "Jane walks in.", + "", + " ".repeat(22) + "JANE", + " ".repeat(15) + "(softly)", + " ".repeat(10) + "Hello.", + "", + " ".repeat(10) + "Anyone home?", + "", + "Silence.", + ]); + }); + + it("wraps text inside its column instead of overflowing", async () => { + const long = "word ".repeat(60).trim(); + const lines = await exportLines([block("action", long), block("dialogue", long)]); + + const action = lines.filter((l) => l.startsWith("word")); + const dialogue = lines.filter((l) => l.startsWith(" ".repeat(10) + "word")); + + expect(action.length).toBeGreaterThan(1); + expect(dialogue.length).toBeGreaterThan(1); + // 60-char text column for action, 35 for dialogue (both at 10 cpi). + expect(Math.max(...action.map((l) => l.length))).toBeLessThanOrEqual(60); + expect(Math.max(...dialogue.map((l) => l.length))).toBeLessThanOrEqual(45); + // Nothing ever runs past the page width. + expect(Math.max(...lines.map((l) => l.length))).toBeLessThanOrEqual(60); + }); + + it("honours the notes and characters options", async () => { + const nodes = [ + block("note", "Rewrite this."), + block("character", "JANE"), + block("dialogue", "Mine."), + block("character", "JOHN"), + block("parenthetical", "flatly"), + block("dialogue", "Not mine."), + ]; + + const withoutNotes = (await exportLines(nodes)).join("\n"); + expect(withoutNotes).not.toContain("Rewrite this."); + + const withNotes = (await exportLines(nodes, { ...opts, includeNotes: true })).join("\n"); + expect(withNotes).toContain("[[Rewrite this.]]"); + + const onlyJane = (await exportLines(nodes, { ...opts, characters: ["JANE"] })).join("\n"); + expect(onlyJane).toContain("Mine."); + expect(onlyJane).not.toContain("JOHN"); + expect(onlyJane).not.toContain("flatly"); + expect(onlyJane).not.toContain("Not mine."); + }); + + it("emits a form feed for a manual page break", async () => { + const lines = await exportLines([ + block("action", "Before."), + block("action", "After.", { pageBreak: true }), + ]); + + expect(lines).toEqual(["Before.", "\f", "After.", ""]); + }); + + it("lays a dual dialogue out as two side-by-side columns", async () => { + const lines = await exportLines([ + { + type: "dual_dialogue", + content: [ + column(block("character", "JANE"), block("dialogue", "Left side.")), + column(block("character", "JOHN"), block("dialogue", "Right side.")), + ], + }, + ]); + + const cues = lines.find((l) => l.includes("JANE"))!; + expect(cues).toContain("JOHN"); + // The right column starts halfway across the page. + expect(cues.indexOf("JOHN")).toBeGreaterThanOrEqual(30); + expect(lines.find((l) => l.includes("Left side."))).toContain("Right side."); + }); + + it("renders the title page above a page break", async () => { + const lines = await exportLines([block("action", "Jane walks in.")], opts, titlePage); + + expect(lines[0].trim()).toBe("T"); + expect(indentOf(lines[0])).toBeGreaterThan(0); // centred + expect(lines[1].trim()).toBe("Written by A"); + expect(lines[2]).toBe("\f"); + expect(lines[3]).toBe("Jane walks in."); + }); + + it("drops the title page when the option is off", async () => { + const lines = await exportLines( + [block("action", "Jane walks in.")], + { ...opts, includeTitlePage: false }, + titlePage, + ); + + expect(lines).toEqual(["Jane walks in.", ""]); + }); +}); diff --git a/src/tests/editor/focus-in-viewport.test.ts b/src/tests/editor/focus-in-viewport.test.ts new file mode 100644 index 00000000..aaa63bb7 --- /dev/null +++ b/src/tests/editor/focus-in-viewport.test.ts @@ -0,0 +1,166 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { Editor } from "@tiptap/core"; + +import { BASE_EXTENSIONS } from "@src/lib/screenplay/editor"; +import { focusEditorAtCoords } from "@src/lib/editor/focus-in-viewport"; + +/** + * Entering edit mode on phone drops the caret at a tapped point instead of at the + * stored selection. The point often falls in the blank margin a screenplay leaves + * above a line — a finger aimed at that line's first character catches it easily, + * and the pen button's fixed aim point (a quarter down the viewport) lands there + * too. ProseMirror answers such a point with the *boundary* before the block + * rather than a position inside it, and a caret parked on a boundary reports no + * screenplay element and resolves to the editor container instead of a line. + * + * Needs real layout (rects, line boxes, margin collapsing), so it runs in + * Chromium and WebKit — see vitest.config.ts. + */ + +const LINE = 20; +/** Blank band above a line: what a tap aimed at its first character can catch. */ +const GAP = 16; +/** Indent of a dialogue block — padding, so the box still spans the full width. */ +const INDENT = 120; + +const teardown: Array<() => void> = []; +afterEach(() => { + while (teardown.length) teardown.pop()!(); +}); + +const mount = () => { + const style = document.createElement("style"); + style.textContent = ` + .fv-host { width: 480px; height: 360px; overflow-y: auto; position: relative; } + .fv-host .ProseMirror { font: 14px monospace; line-height: ${LINE}px; outline: none; } + .fv-host .ProseMirror > * { margin: ${GAP}px 0 0 0; padding: 0; } + .fv-host .ProseMirror .dialogue { padding-left: ${INDENT}px; padding-right: ${INDENT}px; } + .fv-host .ProseMirror .character { padding-left: ${INDENT}px; } + `; + document.head.appendChild(style); + + const host = document.createElement("div"); + host.className = "fv-host"; + document.body.appendChild(host); + + const line = (type: string, text: string, id: string) => ({ + type, + attrs: { class: type, "data-id": id }, + content: [{ type: "text", text }], + }); + + const editor = new Editor({ + element: host, + extensions: BASE_EXTENSIONS, + injectCSS: false, + autofocus: false, + content: { + type: "doc", + content: [ + line("action", "The room is empty, save for a single chair.", "n0"), + line("character", "MARLOWE", "n1"), + line("dialogue", "I have been here before, and I will be here again.", "n2"), + line("action", "He sits.", "n3"), + ], + }, + }); + + teardown.push(() => { + editor.destroy(); + host.remove(); + style.remove(); + }); + + return editor; +}; + +/** Position of the first character of the nth top-level block. */ +const startOfBlock = (editor: Editor, index: number) => { + let pos = -1; + let i = 0; + editor.state.doc.forEach((_node, offset) => { + if (i++ === index) pos = offset + 1; + }); + return pos; +}; + +/** What the app reads off the caret: the element class, and where in the node it is. */ +const caret = (editor: Editor) => { + const { $head } = editor.state.selection; + return { + isTextblock: $head.parent.isTextblock, + element: $head.parent.attrs.class as string | undefined, + offset: $head.parentOffset, + textLength: $head.parent.content.size, + }; +}; + +describe("focusEditorAtCoords", () => { + it("lands on the first character when the tap catches the margin above a line", () => { + const editor = mount(); + const start = startOfBlock(editor, 2); // the dialogue + const box = editor.view.coordsAtPos(start); + + // A few px above the line's top edge and at its left edge — inside the + // block's blank leading margin, which is where a finger aimed at the first + // character actually lands. + focusEditorAtCoords(editor, box.left, box.top - GAP / 2); + + expect(caret(editor)).toMatchObject({ isTextblock: true, element: "dialogue", offset: 0 }); + }); + + it("lands inside the line when the tap falls left of its indented text", () => { + const editor = mount(); + const start = startOfBlock(editor, 2); + const box = editor.view.coordsAtPos(start); + + // Left of the dialogue's indent, vertically on the line itself. + focusEditorAtCoords(editor, box.left - INDENT / 2, (box.top + box.bottom) / 2); + + expect(caret(editor)).toMatchObject({ isTextblock: true, element: "dialogue", offset: 0 }); + }); + + it("keeps the tapped line when the tap falls past the end of a short one", () => { + const editor = mount(); + const start = startOfBlock(editor, 1); // the character cue + const end = start + editor.state.doc.nodeAt(start - 1)!.content.size; + const box = editor.view.coordsAtPos(end); + + // Well right of "MARLOWE", still on its line: the caret belongs at the end + // of that cue, not at the start of the dialogue underneath. + focusEditorAtCoords(editor, box.right + 200, (box.top + box.bottom) / 2); + + const c = caret(editor); + expect(c).toMatchObject({ isTextblock: true, element: "character" }); + expect(c.offset).toBe(c.textLength); + }); + + it("keeps the tapped column, not just the start of the node", () => { + const editor = mount(); + const start = startOfBlock(editor, 2); + const box = editor.view.coordsAtPos(start); + const tenth = editor.view.coordsAtPos(start + 10); + + // Above the line again, but horizontally over its 10th character. + focusEditorAtCoords(editor, tenth.left, box.top - GAP / 2); + + const c = caret(editor); + expect(c).toMatchObject({ isTextblock: true, element: "dialogue" }); + expect(c.offset).toBeGreaterThan(0); + }); + + it("resolves the caret's block, not the editor container, for the follow-up scroll", () => { + const editor = mount(); + const start = startOfBlock(editor, 2); + const box = editor.view.coordsAtPos(start); + + focusEditorAtCoords(editor, box.left, box.top - GAP / 2); + + // centerCaretInView scrolls whatever domAtPos reports here; on a boundary + // position that used to be the whole editor, which centres the script. + const { node } = editor.view.domAtPos(editor.state.selection.head); + const element = node instanceof HTMLElement ? node : node.parentElement; + expect(element).not.toBe(editor.view.dom); + expect(element?.closest(".dialogue")).not.toBeNull(); + }); +}); diff --git a/src/tests/editor/zoom-scroll-anchor.test.ts b/src/tests/editor/zoom-scroll-anchor.test.ts new file mode 100644 index 00000000..633d457d --- /dev/null +++ b/src/tests/editor/zoom-scroll-anchor.test.ts @@ -0,0 +1,172 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { captureZoomAnchor, restoreZoomAnchor } from "@src/lib/editor/zoom-scroll-anchor"; + +/** + * Zooming the editor must leave the line the reader was looking at under the + * viewport centre. Runs in real Chromium and WebKit (see vitest.config.ts) — + * the bug this covers only appears in an engine whose `zoom` relayout isn't a + * perfect multiple of the previous one, which jsdom cannot model at all. + * + * The harness mirrors the editor's structure closely enough for the geometry to + * be comparable: a page-width `zoom`ed page inside a centred, width-capped + * wrapper with percentage bottom padding, wrapping paragraphs of varied length, + * page-break spacers carrying `content-visibility`, and the horizontal overflow + * swap that the panel applies above 1×. + */ + +const PAGE_WIDTH = 612; +const PAGES = 30; +const BREAK_HEIGHT = 260; + +/** Max drift (px) we accept: sub-pixel rounding over a long zoom gesture. */ +const TOLERANCE = 8; + +const teardown: Array<() => void> = []; +afterEach(() => { + while (teardown.length) teardown.pop()!(); +}); + +const mount = () => { + const style = document.createElement("style"); + style.textContent = ` + .za-host { width: 700px; height: 420px; overflow-y: auto; overflow-x: clip; + scrollbar-gutter: stable; position: relative; } + .za-host.zoomed-x { overflow-x: auto; } + .za-wrap { width: 100%; max-width: 1000px; margin: 0 auto; padding-bottom: 30%; contain: layout; } + .za-host.zoomed-x .za-wrap { width: fit-content; max-width: none; } + .za-page { width: ${PAGE_WIDTH}px; box-sizing: border-box; margin: 0 auto; + font: 16px monospace; line-height: 20px; zoom: var(--editor-user-zoom, 1); } + .za-page p { margin: 0 0 20px 0; padding: 0 96px; } + .za-page p.dialogue { padding: 0 168px 0 240px; } + .za-break { content-visibility: auto; contain-intrinsic-size: none ${BREAK_HEIGHT}px; + height: ${BREAK_HEIGHT}px; } + `; + document.head.appendChild(style); + + const container = document.createElement("div"); + container.className = "za-host"; + const wrapper = document.createElement("div"); + wrapper.className = "za-wrap"; + const page = document.createElement("div"); + page.className = "za-page"; + + const words = "the quick brown fox jumps over a lazy dog while dawn breaks over the quiet valley".split(" "); + let html = ""; + for (let p = 0; p < PAGES; p++) { + for (let l = 0; l < 8; l++) { + const length = 6 + ((p * 7 + l * 5) % 12); // varied paragraph heights + const text = Array.from({ length }, (_, i) => words[(l + i) % words.length]).join(" "); + html += `

${text}

`; + } + if (p < PAGES - 1) html += `
`; + } + page.innerHTML = html; + wrapper.appendChild(page); + container.appendChild(wrapper); + document.body.appendChild(container); + + teardown.push(() => { + container.remove(); + style.remove(); + }); + return { container, page }; +}; + +/** Signed distance (px) from the viewport centre to the target line's middle. */ +const drift = (container: HTMLElement, target: HTMLElement) => { + const centre = container.getBoundingClientRect().top + container.clientHeight / 2; + const rect = target.getBoundingClientRect(); + return rect.top + rect.height / 2 - centre; +}; + +/** What the panel's layout effect does: capture, apply the scale, restore. */ +const applyZoom = (container: HTMLElement, page: HTMLElement, zoom: number) => { + const anchor = captureZoomAnchor(container, page); + container.classList.toggle("zoomed-x", zoom > 1); + container.style.setProperty("--editor-user-zoom", `${zoom}`); + if (!anchor) return; + // The panel re-checks over a few frames (settleZoomAnchor); synchronously + // the loop converges immediately, so a bounded retry stands in for it here. + for (let pass = 0; pass < 3 && !restoreZoomAnchor(container, page, anchor); pass++); +}; + +describe("editor zoom keeps the reading position", () => { + // A full keyboard gesture: in to 1.73x, back through 1x, out to the 0.5x + // floor, then reset. 0.5x matters because WebKit clamps the shrunk font to a + // 9px rendered minimum, which changes where lines wrap. + const gesture = [1.2, 1.44, 1.73, 1.44, 1, 0.83, 0.69, 0.58, 0.5, 1]; + + // Depth is the point: the old multiplicative anchoring drifted per line, so + // the error grew the further into the script the reader was. (Page 0 is + // excluded — a line that close to the top can't be brought to the viewport + // centre at all, since scrollTop clamps at 0; see the test below it.) + for (const depth of [5, 14, 27]) { + it(`holds the centred line through a zoom gesture on page ${depth}`, () => { + const { container, page } = mount(); + const target = page.querySelector(`#p${depth}l4`)!; + container.scrollTop += drift(container, target); + + const worst = { zoom: 1, drift: 0 }; + for (const zoom of gesture) { + applyZoom(container, page, zoom); + const d = drift(container, target); + if (Math.abs(d) > Math.abs(worst.drift)) { + worst.zoom = zoom; + worst.drift = d; + } + } + + expect(Math.abs(worst.drift), `worst drift ${worst.drift}px at ${worst.zoom}x`).toBeLessThan(TOLERANCE); + }); + } + + it("stays at the top of the document instead of scrolling into it", () => { + const { container, page } = mount(); + expect(container.scrollTop).toBe(0); + + // Nothing above the first line to reveal, so the correction must clamp + // rather than push the reader down into the script. + for (const zoom of gesture) applyZoom(container, page, zoom); + + expect(container.scrollTop).toBe(0); + }); + + it("anchors on the block under the viewport centre", () => { + const { container, page } = mount(); + const target = page.querySelector("#p9l4")!; + container.scrollTop += drift(container, target); + + const anchor = captureZoomAnchor(container, page); + expect(anchor?.el.id).toBe("p9l4"); + // The centre sits on the line's middle, so mid-block vertically. + expect(anchor!.ratioY).toBeGreaterThan(0); + expect(anchor!.ratioY).toBeLessThan(1); + // Horizontally the viewport centre is the page centre (margin: 0 auto). + expect(anchor!.ratioX).toBeCloseTo(0.5, 1); + }); + + it("reports settled when nothing moved, and needs no editor DOM", () => { + const { container, page } = mount(); + container.scrollTop += drift(container, page.querySelector("#p3l4")!); + + const anchor = captureZoomAnchor(container, page)!; + expect(restoreZoomAnchor(container, page, anchor)).toBe(true); + expect(captureZoomAnchor(container, null)).toBeNull(); + expect(captureZoomAnchor(container, undefined)).toBeNull(); + }); + + it("survives the anchored block being removed mid-zoom", () => { + const { container, page } = mount(); + const target = page.querySelector("#p7l4")!; + container.scrollTop += drift(container, target); + + const anchor = captureZoomAnchor(container, page)!; + const before = container.scrollTop; + anchor.el.remove(); + // Disconnected anchor: report settled and leave the scroll alone rather + // than scrolling to a stale position. + expect(restoreZoomAnchor(container, page, anchor)).toBe(true); + expect(container.scrollTop).toBe(before); + }); +});