(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);
+ });
+});