From e935bbdf2fb378d2357fe5094ea9abfdcecbdeb0 Mon Sep 17 00:00:00 2001 From: Lycoon Date: Fri, 14 Aug 2026 23:22:35 +0200 Subject: [PATCH 1/3] fixed revision marks rendering in pdf export --- src/lib/adapters/pdf/pdf-adapter.ts | 125 +++--- src/lib/adapters/pdf/pdf.worker.ts | 9 +- .../adapters/pdf-revision-asterisks.test.ts | 175 +++++++++ .../adapters/pdf-revision-parity.test.ts | 364 ++++++++++++++++++ 4 files changed, 617 insertions(+), 56 deletions(-) create mode 100644 src/tests/adapters/pdf-revision-asterisks.test.ts create mode 100644 src/tests/adapters/pdf-revision-parity.test.ts diff --git a/src/lib/adapters/pdf/pdf-adapter.ts b/src/lib/adapters/pdf/pdf-adapter.ts index c5cb3945..de33b027 100644 --- a/src/lib/adapters/pdf/pdf-adapter.ts +++ b/src/lib/adapters/pdf/pdf-adapter.ts @@ -383,8 +383,10 @@ export class PDFAdapter extends ProjectAdapter { // ── Dual dialogue container ── if (el.classList.contains("dual_dialogue")) { + // Each column paragraph stamps its own revised lines; the + // container only carries the fallback attribute. const ddLines = this.collectDualDialogueLines(el, options, yOffset); - this.stampRevision(ddLines, this.getParagraphRevision(el)); + this.stampNodeRevision(el, [ddLines]); allLines.push(...ddLines); continue; } @@ -436,10 +438,6 @@ export class PDFAdapter extends ProjectAdapter { // compareDocumentPosition which works correctly at any nesting depth. const splitWidget = el.querySelector(".pagination-page-break") as HTMLElement | null; - // Revision the node was last changed under — stamped on every line it - // produces so the revised-pages filter can tell which pages changed. - const paragraphRevision = this.getParagraphRevision(el); - if (splitWidget) { // Collect lines BEFORE the split widget const beforeLines = this.collectParagraphLines(el, nodeType, splitWidget, "before"); @@ -448,7 +446,6 @@ export class PDFAdapter extends ProjectAdapter { for (const line of beforeLines) line.y -= yOffset; } this.injectPseudoContent(el, beforeLines, options, sceneInfo); - this.stampRevision(beforeLines, paragraphRevision); allLines.push(...beforeLines); } @@ -467,9 +464,13 @@ export class PDFAdapter extends ProjectAdapter { if (yOffset > 0) { for (const line of afterLines) line.y -= yOffset; } - this.stampRevision(afterLines, paragraphRevision); allLines.push(...afterLines); } + + // Both halves are already in `allLines`, but they are the same + // objects — the attribute fallback can still stamp the node's + // first line whichever side of the break it fell on. + this.stampNodeRevision(el, [beforeLines, afterLines]); } else { const paragraphLines = this.collectParagraphLines(el, nodeType); @@ -480,7 +481,7 @@ export class PDFAdapter extends ProjectAdapter { } // ── Pseudo-element content (not captured by TreeWalker) ── this.injectPseudoContent(el, paragraphLines, options, sceneInfo); - this.stampRevision(paragraphLines, paragraphRevision); + this.stampNodeRevision(el, [paragraphLines]); allLines.push(...paragraphLines); } else { // Empty paragraph — no text nodes, so collectParagraphLines @@ -490,12 +491,9 @@ export class PDFAdapter extends ProjectAdapter { // misinterpret the accumulated gap as a page break. const rect = el.getBoundingClientRect(); if (rect.height > 0) { - allLines.push({ - runs: [], - y: rect.top - yOffset, - type: nodeType, - revision: paragraphRevision >= 1 ? paragraphRevision : undefined, - }); + const emptyLine: VisualLine = { runs: [], y: rect.top - yOffset, type: nodeType }; + this.stampNodeRevision(el, [[emptyLine]]); + allLines.push(emptyLine); } } } @@ -542,12 +540,15 @@ export class PDFAdapter extends ProjectAdapter { for (const line of paragraphLines) line.y -= yOffset; } this.injectPseudoContent(p, paragraphLines, options); + this.stampNodeRevision(p, [paragraphLines]); columnLines.push(...paragraphLines); } else { // Empty paragraph — emit a spacer line so Y advances correctly. const rect = p.getBoundingClientRect(); if (rect.height > 0) { - columnLines.push({ runs: [], y: rect.top - yOffset, type: nodeType }); + const emptyLine: VisualLine = { runs: [], y: rect.top - yOffset, type: nodeType }; + this.stampNodeRevision(p, [[emptyLine]]); + columnLines.push(emptyLine); } } } @@ -614,10 +615,15 @@ export class PDFAdapter extends ProjectAdapter { // Resolve marks once per text node (they don't change mid-node) const marks = getMarksFromComputedStyle(textNode); - // Revision index colouring this run, if any — read straight from the - // `revision` mark span so it's independent of the editor's current - // display mode (which only tints, it never removes the attribute). - const revision = this.getTextNodeRevision(textNode, el); + // Revision mark covering this text node, if any — read straight from + // the `revision` mark span so it's independent of the editor's + // current display mode (which only tints, it never removes the + // attribute). `lineRevision` marks the visual line the characters + // land on (asterisk); `revision` additionally tints the run, so a + // deletion anchor — an invisible marker riding a surviving + // character — is excluded from it. + const { index: lineRevision, isDel } = this.readRevisionMark(textNode, el); + const revision = isDel ? 0 : lineRevision; for (let ci = 0; ci < text.length; ci++) { const rawChar = text[ci]; @@ -695,6 +701,16 @@ export class PDFAdapter extends ProjectAdapter { currentLine = { runs: [], y: rect.top, type }; } + // Asterisk stamping: only the visual lines a revision mark + // actually lands on are revised — matching the editor overlay, + // which measures the marked range's client rects line by line. + // Zero-height chars (trailing wrapped spaces) never get here, so + // they can't stamp the line they were laid out on, exactly as + // the overlay skips their empty rects. + if (lineRevision >= 1 && lineRevision > (currentLine.revision ?? 0)) { + currentLine.revision = lineRevision; + } + // ── Update or start run ────────────────────────────────── if (isSameRun()) { currentRun!.text += char; @@ -909,55 +925,58 @@ export class PDFAdapter extends ProjectAdapter { // ── Revision filtering ─────────────────────────────────────────────────── /** - * Highest revision index a top-level node was changed under, read from the - * DOM the revisions extension renders: the inline `data-revision` marks - * (changed text, both "ins" and "del" anchors) and the `data-revision-line` - * node attribute (empty/deleted lines). Returns 0 when the node carries no - * revision (the common case). Display mode is irrelevant — these attributes - * are always present, only their colour varies. + * Stamp a top-level node's collected lines, mirroring the editor overlay + * (`computeNodeLines` in revisions-extension) so the PDF's asterisks land on + * exactly the lines the screenplay shows them on: + * - when the node's text carries inline `revision` marks, only the visual + * lines those marks actually land on are revised. `collectParagraphLines` + * has already stamped them character by character, so there is nothing + * left to do — stamping the whole node here would print a column of + * asterisks down a paragraph where a single word changed. + * - otherwise the node-level `data-revision-line` attribute (an empty or + * emptied line, which has no character to anchor a mark on) stamps the + * node's FIRST line, like the overlay's single entry at `lineHeight / 2`. + * + * `lineGroups` are the node's line runs in document order — more than one + * only when a page break splits the node, in which case the attribute + * belongs to the first half that produced any line. */ - private getParagraphRevision(el: HTMLElement): number { - let max = 0; - const lineAttr = el.getAttribute("data-revision-line"); - if (lineAttr) { - const v = parseInt(lineAttr, 10); - if (v >= 1) max = v; - } - const marks = el.querySelectorAll("[data-revision]"); - for (let i = 0; i < marks.length; i++) { - const v = parseInt(marks[i].getAttribute("data-revision") || "", 10); - if (v >= 1 && v > max) max = v; + private stampNodeRevision(el: HTMLElement, lineGroups: VisualLine[][]): void { + if (el.querySelector("[data-revision]")) return; + const attr = parseInt(el.getAttribute("data-revision-line") || "", 10); + if (!(attr >= 1)) return; + for (const lines of lineGroups) { + if (lines.length > 0) { + lines[0].revision = attr; + return; + } } - return max; - } - - /** Tag every line with `rev` when it is a real revision (>=1); a no-op otherwise. */ - private stampRevision(lines: VisualLine[], rev: number): void { - if (rev < 1) return; - for (const line of lines) line.revision = rev; } /** - * Revision index colouring a single text node, or 0 when it carries none. - * Walks up to the paragraph looking for the inline `revision` mark span - * (`data-revision`). A "del" anchor (`data-revision-kind="del"`) is an - * invisible position marker — it must NOT tint its surviving character — so - * it returns 0. Reading the attribute (not the computed colour) keeps the - * export independent of the editor's current revision display mode. + * The inline `revision` mark covering a text node, or index 0 when it + * carries none. Walks up to the paragraph looking for the mark span + * (`data-revision`); reading the attribute rather than the computed colour + * keeps the export independent of the editor's current display mode. + * + * `isDel` flags a deletion anchor (`data-revision-kind="del"`): an invisible + * marker pinned to a character that SURVIVED the deletion, so the asterisk + * lands on the line the text was removed from. It marks the line but must + * never tint the character it rides on. */ - private getTextNodeRevision(textNode: Text, stopEl: HTMLElement): number { + private readRevisionMark(textNode: Text, stopEl: HTMLElement): { index: number; isDel: boolean } { let node = textNode.parentElement; while (node && node !== stopEl.parentElement) { const raw = node.getAttribute("data-revision"); if (raw !== null) { - if (node.getAttribute("data-revision-kind") === "del") return 0; const v = parseInt(raw, 10); - return v >= 1 ? v : 0; + if (!(v >= 1)) return { index: 0, isDel: false }; + return { index: v, isDel: node.getAttribute("data-revision-kind") === "del" }; } if (node === stopEl) break; node = node.parentElement; } - return 0; + return { index: 0, isDel: false }; } /** diff --git a/src/lib/adapters/pdf/pdf.worker.ts b/src/lib/adapters/pdf/pdf.worker.ts index 2090fbcb..556e6a08 100644 --- a/src/lib/adapters/pdf/pdf.worker.ts +++ b/src/lib/adapters/pdf/pdf.worker.ts @@ -24,9 +24,12 @@ export interface VisualLine { runs: TextRun[]; y: number; // browser Y position in pixels (for line-spacing within a page) type?: string; // e.g. "dialogue", "character", "scene", "__page_break__" - /** Revision index this line was last changed under (>=1), or undefined for - * unchanged lines. Set by the PDF adapter from the DOM revision marks/attrs - * and used only for the "export revision pages" filter. */ + /** Revision index this VISUAL line was last changed under (>=1), or + * undefined for unchanged lines. Set by the PDF adapter from the DOM + * revision marks/attrs on the characters this line actually holds — a + * wrapped paragraph where one word changed marks only the line that word + * wrapped onto, as the editor overlay does. Drives both the right-margin + * asterisk and the "export revision pages" filter. */ revision?: number; /** When set, this revised visual line gets a right-margin asterisk in this * hex colour ("#000000" for the black & white mode). Absent for unchanged diff --git a/src/tests/adapters/pdf-revision-asterisks.test.ts b/src/tests/adapters/pdf-revision-asterisks.test.ts new file mode 100644 index 00000000..c89f9ccd --- /dev/null +++ b/src/tests/adapters/pdf-revision-asterisks.test.ts @@ -0,0 +1,175 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { PDFAdapter, type PDFExportOptions, type RevisionExportMode } from "@src/lib/adapters/pdf/pdf-adapter"; +import type { VisualLine } from "@src/lib/adapters/pdf/pdf.worker"; +import { revisionColor } from "@src/lib/screenplay/revisions"; + +/** + * A production revision asterisk marks the VISUAL line that changed, not the + * paragraph it sits in — a word retyped at the end of a five-line speech prints + * one asterisk, beside that line only. The editor overlay measures the revision + * mark's client rects to place them (see `computeNodeLines` in + * revisions-extension), and the PDF must agree line for line, since both are + * read off the same laid-out DOM. + * + * Runs in real Chromium and WebKit (see vitest.config.ts): the whole point is + * where the browser wraps the text, which jsdom cannot provide. + */ + +type AdapterInternals = { + collectLines(el: HTMLElement, options: PDFExportOptions): VisualLine[]; + applyRevisionStyling(lines: VisualLine[], mode: RevisionExportMode): void; +}; + +const internals = (adapter: PDFAdapter) => adapter as unknown as AdapterInternals; + +const options = { includeNotes: true } as PDFExportOptions; + +/** Long enough to wrap over several lines inside the action margins. */ +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 and " + + "the river that runs slowly past the old mill on its way to the distant sea"; + +/** The revision mark span as the revisions extension renders it. */ +const ins = (index: number, text: string) => `${text}`; +/** A deletion anchor: invisible marker riding a character that survived. */ +const del = (index: number, text: string) => + `${text}`; + +const teardown: Array<() => void> = []; +afterEach(() => { + while (teardown.length) teardown.pop()!(); +}); + +/** Mount a page-width stand-in for the editor holding `html`. */ +const mountEditor = (html: string) => { + const style = document.createElement("style"); + style.textContent = ` + .test-scroller { width: 700px; height: 400px; overflow: auto; } + .test-pm { + --page-width: 612px; + --display-margin-scale: 1; + width: var(--page-width) !important; + box-sizing: border-box; + font: 16px monospace; + line-height: 16px; + /* As ProseMirror's own stylesheet sets it. It decides where lines + * wrap and keeps trailing spaces occupying real width, so omitting + * it would measure a layout the app never renders. */ + white-space: break-spaces; + --page-margin-left: 96px; + --page-margin-right: 96px; + } + .test-pm p { margin: 0 0 16px 0; padding: 0 96px; } + `; + document.head.appendChild(style); + + const scroller = document.createElement("div"); + scroller.className = "test-scroller"; + const editor = document.createElement("div"); + editor.className = "test-pm"; + editor.innerHTML = html; + scroller.appendChild(editor); + document.body.appendChild(scroller); + + teardown.push(() => { + scroller.remove(); + style.remove(); + }); + return editor; +}; + +/** Collected content lines (page-break sentinels dropped) and their text. */ +const collect = (editor: HTMLElement) => { + const lines = internals(new PDFAdapter()).collectLines(editor, options); + return lines.filter((l) => l.type !== "__page_break__"); +}; + +const textOf = (line: VisualLine) => line.runs.map((r) => r.text).join(""); + +describe("PDF revision asterisks land on the changed visual line only", () => { + it("marks just the wrapped line holding the revised word", () => { + const editor = mountEditor(`

${LONG_LINE} ${ins(2, "rewritten")}

`); + const lines = collect(editor); + + expect(lines.length).toBeGreaterThan(2); // the paragraph really wraps + const marked = lines.filter((l) => l.revision !== undefined); + expect(marked).toHaveLength(1); + expect(marked[0].revision).toBe(2); + expect(textOf(marked[0])).toContain("rewritten"); + }); + + it("marks every line a multi-line revised run covers, and no others", () => { + const editor = mountEditor( + `

short opener. ${ins(1, LONG_LINE)} tail.

`, + ); + const lines = collect(editor); + + // The run starts on line 1 and wraps to the end, so every line is marked + // — the correct outcome here, reached per line rather than by fiat. + expect(lines.length).toBeGreaterThan(2); + expect(lines.every((l) => l.revision === 1)).toBe(true); + }); + + it("keeps the highest revision when two overlap on one line", () => { + const editor = mountEditor( + `

${ins(1, "blue word")} plain ${ins(3, "yellow word")}

`, + ); + const [line] = collect(editor); + + expect(line.revision).toBe(3); + }); + + it("marks a deletion's line without tinting the character it anchors to", () => { + const editor = mountEditor(`

${LONG_LINE} ${del(4, "t")}ail

`); + const lines = collect(editor); + + const marked = lines.filter((l) => l.revision !== undefined); + expect(marked).toHaveLength(1); + expect(marked[0].revision).toBe(4); + // The anchor is a position marker, so no run on that line is coloured. + internals(new PDFAdapter()).applyRevisionStyling(lines, "colored"); + expect(marked[0].asteriskColor).toBe(revisionColor(4)); + expect(marked[0].runs.every((r) => r.color === undefined)).toBe(true); + }); + + it("marks the first line only when the node attribute is the sole revision", () => { + // `data-revision-line` covers changes with no markable text (a new empty + // line, or one emptied by a deletion). A non-empty node can still carry + // it from an earlier edit; the mark, when present, wins. + // `
` stands in for ProseMirror's trailing break, which is what gives + // an empty paragraph a line box (and so a measurable height) at all. + const editor = mountEditor( + `

${LONG_LINE}

` + + `


`, + ); + const lines = collect(editor); + + expect(lines.length).toBeGreaterThan(2); + expect(lines[0].revision).toBe(5); + expect(lines.slice(1, -1).every((l) => l.revision === undefined)).toBe(true); + // The trailing empty paragraph is a single line and keeps its stamp. + expect(lines[lines.length - 1].revision).toBe(5); + }); + + it("ignores the node attribute when the text carries a mark", () => { + const editor = mountEditor( + `

${LONG_LINE} ${ins(2, "rewritten")}

`, + ); + const lines = collect(editor); + + const marked = lines.filter((l) => l.revision !== undefined); + expect(marked).toHaveLength(1); + expect(marked[0].revision).toBe(2); + }); + + it("draws no asterisks at all in the `none` export mode", () => { + const editor = mountEditor(`

${LONG_LINE} ${ins(2, "rewritten")}

`); + const lines = collect(editor); + internals(new PDFAdapter()).applyRevisionStyling(lines, "none"); + + expect(lines.every((l) => l.asteriskColor === undefined)).toBe(true); + expect(lines.every((l) => l.runs.every((r) => r.color === undefined))).toBe(true); + }); +}); diff --git a/src/tests/adapters/pdf-revision-parity.test.ts b/src/tests/adapters/pdf-revision-parity.test.ts new file mode 100644 index 00000000..4a6d7693 --- /dev/null +++ b/src/tests/adapters/pdf-revision-parity.test.ts @@ -0,0 +1,364 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { Editor } from "@tiptap/core"; + +import { PDFAdapter, type PDFExportOptions, type RevisionExportMode } from "@src/lib/adapters/pdf/pdf-adapter"; +import type { VisualLine } from "@src/lib/adapters/pdf/pdf.worker"; +import { BASE_EXTENSIONS } from "@src/lib/screenplay/editor"; +import { createRevisionsExtension, refreshRevisions } from "@src/lib/screenplay/extensions/revisions-extension"; + +/** + * The editor and the PDF exporter place revision asterisks through completely + * separate code — the overlay measures the revision mark's client rects + * (`computeNodeLines`), the exporter walks characters into `VisualLine`s — over + * one and the same laid-out DOM. Two implementations of one rule is exactly the + * shape that drifts: the exporter once stamped every line of a revised node, so + * a word changed at the end of a wrapped speech printed an asterisk beside every + * line of it while the screen showed one. + * + * These tests drive a real editor through the real stamping path, then assert + * the two agree LINE FOR LINE — which visual line carries an asterisk, and in + * which revision colour. They fail if either side drifts, so they also cover the + * editor's own placement, and they are written against the property the user + * sees ("the PDF matches the screenplay") rather than against either + * implementation's idea of it. + * + * Both sides are compared as computed placements, not painted pixels: the + * overlay's inline `top` is read straight back. Where the overlay element then + * lands on screen is a stylesheet concern, and not what drifted here. + * + * Runs in real Chromium and WebKit (see vitest.config.ts): the whole point is + * where the browser wraps the text, which jsdom cannot provide. + */ + +type AdapterInternals = { + collectLines(el: HTMLElement, options: PDFExportOptions): VisualLine[]; + applyRevisionStyling(lines: VisualLine[], mode: RevisionExportMode): void; +}; + +const internals = (adapter: PDFAdapter) => adapter as unknown as AdapterInternals; + +const options = { includeNotes: true } as PDFExportOptions; + +/** Editor line box, in px. Font size matches it so a text rect's height is the + * line box height on both engines, keeping the two centre lines sub-pixel + * apart — see {@link asteriskRows}. */ +const LINE_HEIGHT = 16; + +/** Wraps to several lines inside the fixture's 420px text column. */ +const LONG = + "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"; + +type RevState = { enabled: boolean; current: number; display: "all" | "hidden" | "current" }; + +const teardown: Array<() => void> = []; +afterEach(() => { + while (teardown.length) teardown.pop()!(); +}); + +/** + * Boot a real editor holding `paragraphs` as action nodes, sized to a page so + * the long ones wrap. The page custom properties are normally set by the + * pagination extension; without them the overlay renderer bails out and paints + * nothing. + * + * `injectCSS` is left ON so ProseMirror's own stylesheet governs how text is + * laid out — in particular `white-space: break-spaces`, which keeps a trailing + * space measurable instead of collapsing it. A fixture that hand-rolls its + * styles silently measures a layout the app never renders; see the guard test at + * the bottom of this file. + */ +function makeEditor(paragraphs: string[]) { + const style = document.createElement("style"); + style.textContent = ` + .parity-host .ProseMirror { + width: 612px; + box-sizing: border-box; + font: ${LINE_HEIGHT}px monospace; + line-height: ${LINE_HEIGHT}px; + } + .parity-host .ProseMirror p { margin: 0; padding: 0 96px; } + `; + document.head.appendChild(style); + + const el = document.createElement("div"); + el.className = "parity-host"; + document.body.appendChild(el); + const rev: RevState = { enabled: false, current: 0, display: "all" }; + + const editor = new Editor({ + element: el, + injectCSS: true, + autofocus: false, + content: { + type: "doc", + content: paragraphs.map((text, i) => ({ + type: "action", + attrs: { "data-id": `n${i}`, class: "action" }, + content: text ? [{ type: "text", text }] : undefined, + })), + }, + extensions: [ + ...BASE_EXTENSIONS, + createRevisionsExtension({ + getRevisionsEnabled: () => rev.enabled, + getCurrentRevision: () => rev.current, + getDisplayMode: () => rev.display, + }), + ], + }); + + const dom = editor.view.dom as HTMLElement; + dom.style.setProperty("--page-height", "1000px"); + dom.style.setProperty("--page-gap", "20px"); + dom.style.setProperty("--page-width", "612px"); + dom.style.setProperty("--page-margin-right", "96px"); + dom.style.setProperty("--page-margin-top", "0px"); + dom.style.setProperty("--page-margin-bottom", "0px"); + dom.style.setProperty("--line-height", `${LINE_HEIGHT}px`); + + teardown.push(() => { + editor.destroy(); + el.remove(); + style.remove(); + }); + return { editor, rev, dom }; +} + +/** Revision stamping is debounced (FLUSH_DELAY = 220ms). */ +const settle = () => new Promise((r) => setTimeout(r, 320)); +/** The overlay repaints on a coalesced rAF; two frames is past it. */ +const frames = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(null)))); + +/** Document position just inside top-level child `index`. */ +const insidePos = (editor: Editor, index: number): number => { + let pos = -1; + editor.state.doc.forEach((node, p, i) => { + if (i === index) pos = p + 1; + }); + return pos; +}; + +/** Document position at the very END of top-level child `index`. */ +const endPos = (editor: Editor, index: number): number => { + let pos = -1; + editor.state.doc.forEach((node, p, i) => { + if (i === index) pos = p + node.nodeSize - 1; + }); + return pos; +}; + +/** CSSOM-normalised colour ("#2f74c0" and "rgb(47, 116, 192)" → one string), so + * the exporter's hex and the overlay's computed rgb() compare equal. */ +const probe = document.createElement("span"); +const normalizeColor = (c: string): string => { + probe.style.color = ""; + probe.style.color = c; + return probe.style.color; +}; + +/** The exporter's content lines (page-break sentinels dropped), already carrying + * their asterisk colours. */ +const pdfLines = (dom: HTMLElement): VisualLine[] => { + const adapter = new PDFAdapter(); + const lines = internals(adapter).collectLines(dom, options).filter((l) => l.type !== "__page_break__"); + internals(adapter).applyRevisionStyling(lines, "colored"); + return lines; +}; + +/** + * The heart of the comparison: reduce both renderers to `visual line ordinal → + * asterisk colour`, so they are compared as "which line changed" rather than as + * raw pixels in two different coordinate spaces. + * + * The exporter's ordinals are the index into its own content lines. Each overlay + * asterisk is paired with the exporter line nearest it vertically: the overlay's + * inline `top` is the line's centre in editor-local coordinates, and the + * exporter's `y` is that same line's top in viewport coordinates, so the two + * land within a fraction of a pixel once both are put in editor-local centres. + * Rows are a whole line-height apart, so requiring the winner to be within half + * a line makes the pairing unambiguous — an asterisk that matched no line, or + * matched by luck, fails the test rather than quietly mapping onto a neighbour. + */ +const asteriskRows = (dom: HTMLElement, lines: VisualLine[]): Map => { + const overlay = dom.querySelector(".revision-overlay") as HTMLElement | null; + const domTop = dom.getBoundingClientRect().top; + const centres = lines.map((l) => l.y - domTop + LINE_HEIGHT / 2); + + const rows = new Map(); + for (const node of overlay?.querySelectorAll(".revision-asterisk") ?? []) { + const el = node as HTMLElement; + const top = parseFloat(el.style.top); + let best = -1; + let bestDistance = Infinity; + centres.forEach((centre, i) => { + const distance = Math.abs(centre - top); + if (distance < bestDistance) { + bestDistance = distance; + best = i; + } + }); + expect(bestDistance).toBeLessThan(LINE_HEIGHT / 2); + rows.set(best, normalizeColor(el.style.color)); + } + return rows; +}; + +/** The same map, from the exporter's side. */ +const exportedRows = (lines: VisualLine[]): Map => { + const rows = new Map(); + lines.forEach((line, i) => { + if (line.asteriskColor) rows.set(i, normalizeColor(line.asteriskColor)); + }); + return rows; +}; + +/** Repaint the overlay and collect both sides over one settled DOM. */ +async function bothSides(editor: Editor, dom: HTMLElement) { + await settle(); + refreshRevisions(editor); + await frames(); + + const lines = pdfLines(dom); + return { lines, screen: asteriskRows(dom, lines), pdf: exportedRows(lines) }; +} + +describe("PDF revision asterisks match the screenplay's, line for line", () => { + it("agrees on a word changed at the end of a wrapped paragraph", async () => { + const { editor, rev, dom } = makeEditor(["OPENING", LONG, LONG, "CLOSING"]); + rev.enabled = true; + rev.current = 2; + + editor.chain().focus().insertContentAt(endPos(editor, 1), " rewritten").run(); + const { lines, screen, pdf } = await bothSides(editor, dom); + + expect(lines.length).toBeGreaterThan(6); // the fixture really wraps + expect(screen.size).toBe(1); // one changed line, one asterisk + expect(pdf).toEqual(screen); + }); + + it("agrees when the change opens a paragraph instead of ending it", async () => { + const { editor, rev, dom } = makeEditor(["OPENING", LONG, LONG, "CLOSING"]); + rev.enabled = true; + rev.current = 1; + + editor.chain().focus().insertContentAt(insidePos(editor, 2), "rewritten ").run(); + const { screen, pdf } = await bothSides(editor, dom); + + expect(screen.size).toBe(1); + expect(pdf).toEqual(screen); + }); + + it("agrees on an insertion long enough to cover several visual lines", async () => { + const { editor, rev, dom } = makeEditor(["OPENING", "short line", "CLOSING"]); + rev.enabled = true; + rev.current = 3; + + editor.chain().focus().insertContentAt(endPos(editor, 1), ` ${LONG}`).run(); + const { screen, pdf } = await bothSides(editor, dom); + + expect(screen.size).toBeGreaterThan(1); // genuinely multi-line + expect(pdf).toEqual(screen); + }); + + it("agrees on a deletion, which marks its line without colouring text", async () => { + const { editor, rev, dom } = makeEditor(["OPENING", LONG, "CLOSING"]); + rev.enabled = true; + rev.current = 4; + + // Delete "brown " from inside the first visual line, so the anchor mark + // lands on the "f" of "fox". + const from = insidePos(editor, 1) + 10; + editor.chain().focus().deleteRange({ from, to: from + 6 }).run(); + const { lines, screen, pdf } = await bothSides(editor, dom); + + expect(screen.size).toBe(1); + expect(pdf).toEqual(screen); + // The anchor is a position marker riding a surviving character, so it + // must not tint anything on either side. + expect(lines.every((l) => l.runs.every((r) => r.color === undefined))).toBe(true); + }); + + it("agrees on deleting a line's last word, anchoring on the trailing space", async () => { + const { editor, rev, dom } = makeEditor(["OPENING", LONG, "CLOSING"]); + rev.enabled = true; + rev.current = 2; + + // Backspacing away the final word leaves the line ending in a space, and + // that space is what the invisible "del" mark rides. `white-space: + // break-spaces` is what keeps it occupying width, so it can still carry + // the line's asterisk — on screen and in the PDF alike. + const at = endPos(editor, 1); + editor.chain().focus().deleteRange({ from: at - "valley".length, to: at }).run(); + const { screen, pdf } = await bothSides(editor, dom); + + expect(editor.state.doc.child(1).textContent.endsWith(" ")).toBe(true); + expect(screen.size).toBe(1); + expect(pdf).toEqual(screen); + }); + + it("agrees on a new empty line, which has no text to anchor a mark to", async () => { + const { editor, rev, dom } = makeEditor(["OPENING", LONG, "CLOSING"]); + rev.enabled = true; + rev.current = 5; + + editor.chain().focus().setTextSelection(endPos(editor, 1)).splitBlock().run(); + const { screen, pdf } = await bothSides(editor, dom); + + expect(screen.size).toBeGreaterThan(0); + expect(pdf).toEqual(screen); + }); + + it("agrees on two revisions landing on different lines of one paragraph", async () => { + const { editor, rev, dom } = makeEditor(["OPENING", LONG, "CLOSING"]); + rev.enabled = true; + + rev.current = 1; + editor.chain().focus().insertContentAt(insidePos(editor, 1), "first ").run(); + await settle(); + + rev.current = 6; + editor.chain().focus().insertContentAt(endPos(editor, 1), " second").run(); + const { screen, pdf } = await bothSides(editor, dom); + + expect(screen.size).toBe(2); + expect(new Set(screen.values()).size).toBe(2); // two different colours + expect(pdf).toEqual(screen); + }); + + it("lays text out the way the app does (fixture guard)", () => { + // First paragraph deliberately ends in a space — what deleting a line's + // last word leaves behind. + const { dom } = makeEditor(["They walk toward the ", LONG]); + + // ProseMirror's stylesheet sets `white-space: break-spaces` on the + // editor. It is not cosmetic: it decides where lines wrap, and it keeps + // a trailing space occupying real width rather than collapsing to zero + // rects. A fixture that omits it measures a layout the app never + // renders — and every parity assertion in this file is then agreement + // about the wrong thing. Pinned here so a change in what TipTap injects + // fails loudly instead of quietly deforming the fixture. + expect(getComputedStyle(dom).whiteSpace).toBe("break-spaces"); + + // A trailing space therefore measures: deleting a line's last word + // anchors the invisible "del" mark on it, and it must still be able to + // carry that line's asterisk. + const p = dom.querySelector("p")!; + const text = p.firstChild as Text; + const range = document.createRange(); + range.setStart(text, text.length - 1); + range.setEnd(text, text.length); + expect(range.getClientRects().length).toBeGreaterThan(0); + }); + + it("agrees that an untouched script carries no asterisks at all", async () => { + const { editor, rev, dom } = makeEditor(["OPENING", LONG, LONG, "CLOSING"]); + rev.enabled = true; + rev.current = 2; + + const { screen, pdf } = await bothSides(editor, dom); + + expect(screen.size).toBe(0); + expect(pdf).toEqual(screen); + }); +}); From e4d95a88abb2ecea1596e20c3f6870582e405b44 Mon Sep 17 00:00:00 2001 From: Lycoon Date: Sat, 15 Aug 2026 01:39:20 +0200 Subject: [PATCH 2/3] fixed duplicate revision marks on deletion --- .../extensions/revisions-extension.ts | 69 ++++++++- .../repro/revisions-stale-pending.test.ts | 136 ++++++++++++++++++ 2 files changed, 200 insertions(+), 5 deletions(-) create mode 100644 src/tests/repro/revisions-stale-pending.test.ts diff --git a/src/lib/screenplay/extensions/revisions-extension.ts b/src/lib/screenplay/extensions/revisions-extension.ts index d4dcfd09..d612baef 100644 --- a/src/lib/screenplay/extensions/revisions-extension.ts +++ b/src/lib/screenplay/extensions/revisions-extension.ts @@ -8,7 +8,9 @@ import { ScreenplayElement } from "../../utils/enums"; import { REVISION_COLORS, REVISION_STAMP_META, RevisionDisplayMode, revisionColor } from "../revisions"; import { paginationKey } from "./pagination-extension"; -const revisionsPluginKey = new PluginKey("revisions"); +/** Key of the revisions plugin; its state is the {@link Pending} edit set. + * Exported so tests can assert that a flush consumed it. */ +export const revisionsPluginKey = new PluginKey("revisions"); const REFRESH_META = "revisionsRefresh"; /** Mark type name; the inline mark that stamps changed text with its revision index. */ const REVISION_MARK = "revision"; @@ -203,6 +205,10 @@ const forEachChange = (tr: Transaction, cb: (from: number, to: number) => void): * attribute, since they have no character to anchor. * Tagged `REVISION_STAMP_META` so pagination skips it and the plugin clears * pending when it lands. + * + * Returning null means "nothing to write" — NOT "nothing happened". Several + * pending edits legitimately produce no change (see the `continue`s below), and + * the caller must still consume the pending set in that case; see `flush`. */ const buildStampTransaction = (state: EditorState, pending: Pending, rev: number): Transaction | null => { const markType = state.schema.marks[REVISION_MARK]; @@ -581,8 +587,26 @@ const renderOverlay = ( // Deletions: one point per change → one asterisk on its visual line. for (const point of pending.del) { if (point < fromPos || point > toPos) continue; + const at = clampPos(point); + // Only a point INSIDE a textblock has a line of its own to mark + // — the same test the stamp makes. A point BETWEEN blocks (a + // whole node deleted) has none, and `coordsAtPos` there doesn't + // fail: it flattens to the neighbouring block's full rect, so + // previewing it drops an asterisk on that block's vertical + // centre — a different line from the one the stamp settles on + // (the emptied neighbour's node attribute, half a line up). + // Skipping keeps preview and committed paint on the same line. + // + // The resolve is on the rAF paint path, never the keypress, and + // only inside this block — which is gated on there being pending + // edits at all, i.e. the ~220ms after an edit. Measured on a + // feature-length doc: 6µs cold / 0.07µs warm, against the 13.5µs + // `coordsAtPos` below that it gates (and skips outright when it + // returns false) and the 11µs `nodesBetween` this paint already + // spends walking the visible window. + if (!doc.resolve(at).parent.isTextblock) continue; try { - const c = view.coordsAtPos(clampPos(point)); + const c = view.coordsAtPos(at); addAt(toEditorY((c.top + c.bottom) / 2)); } catch { /* position not laid out yet — skip, retried next paint */ @@ -870,8 +894,22 @@ export const createRevisionsExtension = (config: RevisionsConfig) => { if (rev < 1) return; const pending = revisionsPluginKey.getState(view.state); if (!pending || !pending.dirty) return; - const tr = buildStampTransaction(view.state, pending, rev); - if (tr) view.dispatch(tr); + // A flush ALWAYS consumes the pending set — hence the + // empty fallback transaction, which carries nothing but + // the meta flag that resets the plugin state. Pending + // edits with nothing to write are routine: a deletion + // whose surviving neighbour is already marked, or one + // that empties a line already stamped at this revision + // (both `continue` in buildStampTransaction). Dropping + // the dispatch there stranded those points in pending + // for the rest of the session: mapped forward through + // every later transaction, and painted on every frame by + // the debounce-bridging preview below — a second, + // permanent asterisk beside the committed one on that + // line, which cleared only once some later edit on it + // finally produced a stampable change. + const tr = buildStampTransaction(view.state, pending, rev) ?? view.state.tr; + view.dispatch(tr.setMeta(REVISION_STAMP_META, true)); }; const scheduleFlush = () => { if (flushTimer) clearTimeout(flushTimer); @@ -909,7 +947,28 @@ export const createRevisionsExtension = (config: RevisionsConfig) => { // Repaint so existing marks track shifting content. // Viewport-culled and node-cached, on a coalesced rAF // — never synchronously on the keypress. - if (docChanged || pagChanged || rev !== lastRev || mode !== lastMode) { + // + // The pending test comes LAST on purpose: it is only + // reached by an update that changed nothing else, so + // the typing path (docChanged) short-circuits before + // it and pays literally nothing. The overlay previews + // the not-yet-flushed pending edits, so it must + // repaint when that set changes with no doc change — + // a flush that consumes pending without writing + // anything (see `flush`) is exactly that case, and + // its preview asterisks would otherwise linger until + // the next edit or scroll. `prevState` is always set + // here (docChanged covers the initial call), and + // `getState` is a plain property read on the state. + if ( + docChanged || + pagChanged || + rev !== lastRev || + mode !== lastMode || + (!!prevState && + revisionsPluginKey.getState(v.state) !== + revisionsPluginKey.getState(prevState)) + ) { lastRev = rev; schedule(); } diff --git a/src/tests/repro/revisions-stale-pending.test.ts b/src/tests/repro/revisions-stale-pending.test.ts new file mode 100644 index 00000000..e31922fb --- /dev/null +++ b/src/tests/repro/revisions-stale-pending.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect } from "vitest"; +import { Editor } from "@tiptap/core"; + +import { BASE_EXTENSIONS } from "@src/lib/screenplay/editor"; +import { createRevisionsExtension, revisionsPluginKey } from "@src/lib/screenplay/extensions/revisions-extension"; + +/** + * Regression guard for a duplicated revision asterisk. + * + * The overlay bridges the stamping debounce by painting the plugin's pending + * (not-yet-committed) edits on top of the committed marks. That set is cleared + * when the debounced stamp transaction lands — so a flush that had nothing to + * write, and therefore dispatched nothing, left its pending edits behind + * permanently. Deleting an empty line whose surviving neighbour is an empty + * line already stamped at the current revision is exactly that case: the + * deletion point sits between blocks (nothing to anchor) and the neighbour's + * node attribute is already at the current revision, so the stamp is a no-op. + * The stranded deletion point then painted a second asterisk on that line for + * the rest of the session — until an edit on the line produced a stampable + * change and finally cleared pending. + */ + +type RevState = { enabled: boolean; current: number; display: "all" | "hidden" | "current" }; + +function makeEditor(n: number) { + const el = document.createElement("div"); + document.body.appendChild(el); + const rev: RevState = { enabled: false, current: 0, display: "all" }; + + const content: object[] = []; + for (let i = 0; i < n; i++) { + content.push({ + type: "action", + attrs: { "data-id": `n${i}`, class: "action" }, + content: [{ type: "text", text: `L${i}` }], + }); + } + + const editor = new Editor({ + element: el, + injectCSS: false, + autofocus: false, + content: { type: "doc", content }, + extensions: [ + ...BASE_EXTENSIONS, + createRevisionsExtension({ + getRevisionsEnabled: () => rev.enabled, + getCurrentRevision: () => rev.current, + getDisplayMode: () => rev.display, + }), + ], + }); + + // The overlay renderer reads its page geometry from these (normally set by + // the pagination extension); without them it bails out and paints nothing. + const dom = editor.view.dom as HTMLElement; + dom.style.setProperty("--page-height", "1000px"); + dom.style.setProperty("--page-gap", "20px"); + dom.style.setProperty("--page-width", "800px"); + dom.style.setProperty("--page-margin-right", "100px"); + dom.style.setProperty("--page-margin-top", "0px"); + dom.style.setProperty("--page-margin-bottom", "0px"); + dom.style.setProperty("--line-height", "16px"); + return { editor, rev, el }; +} + +/** Revision stamping is debounced (FLUSH_DELAY = 220ms). */ +const settle = () => new Promise((r) => setTimeout(r, 320)); +/** The overlay repaints on a coalesced rAF; two frames is past it. */ +const frames = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => r(null)))); + +/** Document position of top-level child `index`. */ +function startPos(editor: Editor, index: number): number { + let pos = -1; + editor.state.doc.forEach((node, p, i) => { + if (i === index) pos = p; + }); + return pos; +} + +const asteriskCount = (el: HTMLElement) => el.querySelectorAll(".revision-asterisk").length; + +describe("revisions overlay: deleting an empty line", () => { + it("leaves a single asterisk on the line the cursor lands on", async () => { + const { editor, rev, el } = makeEditor(6); + rev.enabled = true; + rev.current = 1; + + // Enter at the end of line 2 → empty line 3, stamped via its node attr. + const end2 = startPos(editor, 2) + editor.state.doc.child(2).nodeSize - 1; + editor.chain().focus().setTextSelection(end2).splitBlock().run(); + await settle(); + // Enter again on that empty line → empty lines 3 and 4, both stamped. + editor + .chain() + .focus() + .setTextSelection(startPos(editor, 3) + 1) + .splitBlock() + .run(); + await settle(); + await frames(); + expect(editor.state.doc.child(3).attrs.revision).toBe(1); + expect(editor.state.doc.child(4).attrs.revision).toBe(1); + + // Backspace on the empty line 4: the node goes, the cursor lands on the + // (already stamped) empty line 3 — a change the stamp can't write. + editor + .chain() + .focus() + .setTextSelection(startPos(editor, 4) + 1) + .run(); + editor.commands.keyboardShortcut("Backspace"); + + // Inside the debounce window: the pending preview must not add a second + // asterisk. The deletion point lies BETWEEN blocks, where `coordsAtPos` + // resolves to the neighbouring block's whole rect rather than to a line + // — so previewing it painted an asterisk at that block's centre, half a + // line off the committed one. (If the flush happened to land early this + // still holds: pending is then cleared and only the committed mark is + // painted.) + await frames(); + expect(asteriskCount(el)).toBe(1); + + // Past the flush: it had nothing to write, but must still have consumed + // the pending set — otherwise the deletion point lives on, mapped + // forward and repainted for the rest of the session. + await settle(); + await frames(); + expect(editor.state.doc.child(3).attrs.revision).toBe(1); + expect(revisionsPluginKey.getState(editor.state)?.dirty).toBe(false); + expect(revisionsPluginKey.getState(editor.state)?.del).toEqual([]); + expect(asteriskCount(el)).toBe(1); + + editor.destroy(); + }); +}); From ac44d1b5a21403a17c7132f0865066997f552d73 Mon Sep 17 00:00:00 2001 From: Lycoon Date: Sat, 15 Aug 2026 11:12:49 +0200 Subject: [PATCH 3/3] fixed revision mode --- src/context/ProjectContext.tsx | 61 +- src/lib/editor/use-document-editor.ts | 1 + src/lib/project/project-doc.ts | 26 +- src/lib/project/project-repository.ts | 30 +- .../extensions/revisions-extension.ts | 477 ++++++++++++++-- src/lib/screenplay/revisions.ts | 280 +++++++++- src/tests/repro/revisions-baseline.test.ts | 523 ++++++++++++++++++ 7 files changed, 1339 insertions(+), 59 deletions(-) create mode 100644 src/tests/repro/revisions-baseline.test.ts diff --git a/src/context/ProjectContext.tsx b/src/context/ProjectContext.tsx index 8a853d3e..1a76d169 100644 --- a/src/context/ProjectContext.tsx +++ b/src/context/ProjectContext.tsx @@ -36,7 +36,11 @@ import { } from "@src/lib/project/project-state"; import { Screenplay } from "@src/lib/utils/types"; import { ScreenplayElement, TitlePageElement, Style, PageFormat } from "@src/lib/utils/enums"; -import { RevisionDisplayMode, DEFAULT_REVISION_DISPLAY_MODE } from "@src/lib/screenplay/revisions"; +import { + RevisionDisplayMode, + DEFAULT_REVISION_DISPLAY_MODE, + captureRevisionBaseline, +} from "@src/lib/screenplay/revisions"; import { SearchMatch } from "@src/lib/screenplay/extensions/search-highlight-extension"; import { useAssetGc } from "@src/lib/assets/use-asset-gc"; @@ -1071,20 +1075,71 @@ export const ProjectProvider = ({ children, projectId }: ProjectProviderProps) = [repository], ); + /** + * Snapshot every line's current text as the baseline for revision `index` — + * the draft that revision's asterisks will be measured against, so a line + * edited and then restored can drop its mark again. + * + * Needs the mounted screenplay editor for the document. When there is none + * (the revision was changed from a view that has no editor) nothing is + * written, and stamping stays on the event-based path until the next advance + * captures one — which over-marks rather than mis-clears. + */ + const captureRevisionBase = useCallback( + (index: number) => { + if (!repository || !editor) return; + repository.captureRevisionBase(index, captureRevisionBaseline(editor.state.doc, index)); + }, + [repository, editor], + ); + + /** + * Adopt a baseline for a revision that has none, so revision marks start being + * derived rather than accumulated. + * + * Without this a project whose revision opened before baselines existed — or + * one advanced while no editor was mounted to snapshot it — stays on the + * event-based path indefinitely, since nothing short of an advance captures + * one. Taking it mid-revision is safe: `captureRevisionBaseline` records + * `self` for every line already marked at this revision, so those keep their + * marks unconditionally and only lines edited from here on are judged by + * comparison. + * + * Gated on the Yjs sync, and on the editor having actually bound to a + * populated document: freezing an empty screenplay as the baseline would make + * every real line read as new the moment it was touched. + */ + useEffect(() => { + if (!isYjsSynced || !editor || !repository) return; + if (!revisionsEnabled || currentRevision < 1) return; + if (repository.getRevisionBaseline()?.index === currentRevision) return; + if (editor.state.doc.content.size === 0 && (repository.getState()?.screenplayFragment().length ?? 0) > 0) + return; + captureRevisionBase(currentRevision); + }, [isYjsSynced, editor, repository, revisionsEnabled, currentRevision, captureRevisionBase]); + const setRevisionsEnabled = useCallback( (enabled: boolean) => { setRevisionsEnabledState(enabled); repository?.setRevisionsEnabled(enabled); + // Switching stamping on is where the current revision starts measuring + // from, so take a baseline if this revision hasn't got one yet. + if (enabled && currentRevision >= 1 && repository?.getRevisionBaseline()?.index !== currentRevision) { + captureRevisionBase(currentRevision); + } }, - [repository], + [repository, currentRevision, captureRevisionBase], ); const setCurrentRevision = useCallback( (index: number) => { setCurrentRevisionState(index); repository?.setCurrentRevision(index); + // A revision opens: the document as it stands right now is the draft + // this revision's marks will be compared against. + if (index >= 1) captureRevisionBase(index); }, - [repository], + [repository, captureRevisionBase], ); const setRevisionDisplayMode = useCallback( diff --git a/src/lib/editor/use-document-editor.ts b/src/lib/editor/use-document-editor.ts index 87af6918..c07a9202 100644 --- a/src/lib/editor/use-document-editor.ts +++ b/src/lib/editor/use-document-editor.ts @@ -320,6 +320,7 @@ export const useDocumentEditor = (config: DocumentEditorConfig, callbacks: Docum getRevisionsEnabled: () => !!ext.revisionsEnabled, getCurrentRevision: () => ext.currentRevision ?? 0, getDisplayMode: () => ext.revisionDisplayMode ?? "all", + getBaseline: () => ext.repository?.getRevisionBaseline() ?? null, }) : null; diff --git a/src/lib/project/project-doc.ts b/src/lib/project/project-doc.ts index 5ef4a61a..4a0eb989 100644 --- a/src/lib/project/project-doc.ts +++ b/src/lib/project/project-doc.ts @@ -18,7 +18,7 @@ import type { CharacterItem } from "../screenplay/characters"; import type { LocationItem } from "../screenplay/locations"; import type { PersistentScene } from "../screenplay/scenes"; import type { PersistentPage } from "../screenplay/page-locking"; -import type { RevisionDisplayMode } from "../screenplay/revisions"; +import type { RevisionBaseEntry, RevisionDisplayMode } from "../screenplay/revisions"; import type { Comment } from "../utils/types"; // -------------------------------- // @@ -162,6 +162,14 @@ export type ProductionData = { * cumulative). See `src/lib/screenplay/revisions.ts`. */ currentRevision?: number; + /** + * Revision index the `revisionBase` map was captured for. Stamping only takes + * the baseline-derived path while this equals `currentRevision`; when it is + * absent or stale (a project that predates the feature, or a revision advanced + * with no editor open to snapshot it) marks fall back to being stamped from + * edit events, which is conservative — it can over-mark, never wrongly clear. + */ + revisionBaseIndex?: number; /** * How committed revision marks are displayed ("all" | "hidden" | "current"). * Independent of `revisionsEnabled`, which only gates whether new edits are @@ -366,6 +374,7 @@ export class ProjectState extends Y.Doc { COMMENTS: "comments", DICTIONARY: "dictionary", SHELF: "shelf", + REVISION_BASE: "revisionBase", } as const; private _readOnly: boolean = false; @@ -457,5 +466,20 @@ export class ProjectState extends Y.Doc { shelfFragment(nodeId: string, versionId: string): Y.XmlFragment { return this.getXmlFragment(`shelf_${nodeId}_${versionId}`); } + + /** + * Per-line text as of the moment the current revision opened, keyed by the + * line's stable `data-id`. Read on the debounced revision flush to decide + * whether an edited line still differs from the draft it is being compared + * to — a line restored to its baseline loses this revision's marks. Replaced + * wholesale whenever the revision advances, so it holds one draft's worth of + * text and never grows with the number of revisions. See + * `RevisionBaseEntry` in `src/lib/screenplay/revisions.ts` for the meaning of + * a `null` value, and `revisionBaseIndex` in {@link ProductionData} for the + * revision it belongs to. + */ + revisionBase(): Y.Map { + return this.getMap(this.KEYS.REVISION_BASE); + } } diff --git a/src/lib/project/project-repository.ts b/src/lib/project/project-repository.ts index e9e181fc..9687967c 100644 --- a/src/lib/project/project-repository.ts +++ b/src/lib/project/project-repository.ts @@ -22,7 +22,7 @@ import { CharacterMap } from "../screenplay/characters"; import { LocationMap } from "../screenplay/locations"; import { computeSceneItems, PersistentScene, PersistentSceneMap, TransientScene } from "../screenplay/scenes"; import { PersistentPage, PersistentPageMap } from "../screenplay/page-locking"; -import { RevisionDisplayMode } from "../screenplay/revisions"; +import { RevisionBaseEntry, RevisionBaseline, RevisionDisplayMode } from "../screenplay/revisions"; import { PageFormat } from "../utils/enums"; import { generateNodeId } from "../screenplay/nodes"; import { JSONContent } from "@tiptap/react"; @@ -457,6 +457,34 @@ export class ProjectRepository { if (this.guardWrite("setRevisionDisplayMode")) return; this.ydoc.production().set("revisionDisplayMode", mode); } + + /** + * Replace the per-line revision baseline and tag it with the revision it was + * captured for. One transaction so a peer never observes a half-written + * baseline — an intermediate state would let the flush clear marks against a + * partially-populated map, where an absent line reads as "new". + */ + captureRevisionBase(index: number, entries: Map) { + if (this.guardWrite("captureRevisionBase")) return; + const map = this.ydoc.revisionBase(); + this.ydoc.transact(() => { + map.clear(); + entries.forEach((text, id) => map.set(id, text)); + this.ydoc.production().set("revisionBaseIndex", index); + }); + } + + /** + * Baseline lookup for the revision it was captured for, or null when there is + * none — which puts revision stamping back on the event-based path. Reads are + * O(1) Y.Map lookups, so the flush can call `get` per touched line. + */ + getRevisionBaseline(): RevisionBaseline | null { + const index = this.ydoc.production().get("revisionBaseIndex"); + if (typeof index !== "number") return null; + const map = this.ydoc.revisionBase(); + return { index, get: (dataId: string) => map.get(dataId) }; + } setSceneNumberingStyle(style: "suffix" | "prefix") { if (this.guardWrite("setSceneNumberingStyle")) return; this.ydoc.production().set("sceneNumberingStyle", style); diff --git a/src/lib/screenplay/extensions/revisions-extension.ts b/src/lib/screenplay/extensions/revisions-extension.ts index d612baef..69bc52b2 100644 --- a/src/lib/screenplay/extensions/revisions-extension.ts +++ b/src/lib/screenplay/extensions/revisions-extension.ts @@ -4,34 +4,27 @@ import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state"; import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view"; import { ySyncPluginKey } from "@tiptap/y-tiptap"; -import { ScreenplayElement } from "../../utils/enums"; -import { REVISION_COLORS, REVISION_STAMP_META, RevisionDisplayMode, revisionColor } from "../revisions"; +import { + REVISION_COLORS, + REVISION_MARK, + REVISION_STAMP_META, + RevisionBaseline, + RevisionDisplayMode, + STAMP_TYPES, + diffRuns, + revisionColor, +} from "../revisions"; import { paginationKey } from "./pagination-extension"; /** Key of the revisions plugin; its state is the {@link Pending} edit set. * Exported so tests can assert that a flush consumed it. */ export const revisionsPluginKey = new PluginKey("revisions"); const REFRESH_META = "revisionsRefresh"; -/** Mark type name; the inline mark that stamps changed text with its revision index. */ -const REVISION_MARK = "revision"; /** Idle delay (ms) before accumulated revision edits are written to the document. * Keeps the per-keystroke path free of the document write; marks appear shortly * after the user pauses. */ const FLUSH_DELAY = 220; -/** Top-level block types that can carry the node-level `revision` attribute. */ -const STAMP_TYPES = new Set([ - ScreenplayElement.Scene, - ScreenplayElement.Action, - ScreenplayElement.Character, - ScreenplayElement.Dialogue, - ScreenplayElement.Parenthetical, - ScreenplayElement.Transition, - ScreenplayElement.Section, - ScreenplayElement.Note, - ScreenplayElement.DualDialogue, -]); - /** * Schema-level `revision` inline MARK: stamps the actual changed *text* with the * index of the revision it was last edited under (see @@ -126,6 +119,9 @@ type RevisionsConfig = { getCurrentRevision: () => number; /** Display switch: how committed marks are shown (independent of stamping). */ getDisplayMode: () => RevisionDisplayMode; + /** Per-line text as of this revision's opening, or null when none has been + * captured — see {@link RevisionBaseline}. Consulted once per flush. */ + getBaseline?: () => RevisionBaseline | null; }; /** CSS custom property a `revision` mark of index `i` reads for its text colour. @@ -162,11 +158,18 @@ type Pending = { /** Touched span, for finding empty new lines (Enter) at flush time. */ lo: number; hi: number; + /** `data-id`s of top-level lines that existed before this edit window and are + * gone from the document now. The one thing comparing surviving lines against + * the baseline cannot see: when a whole line is removed, every line that + * remains may still match its baseline exactly, yet the deletion is real and + * the adjacent line has to carry its asterisk. Ids, not positions, so nothing + * needs mapping forward. */ + gone: Set; /** Whether anything is waiting to be flushed. */ dirty: boolean; }; -const EMPTY_PENDING: Pending = { ins: [], del: [], lo: Infinity, hi: -Infinity, dirty: false }; +const EMPTY_PENDING: Pending = { ins: [], del: [], lo: Infinity, hi: -Infinity, gone: new Set(), dirty: false }; /** Merge overlapping/adjacent ranges so continuous typing stays O(1) entries. */ const mergeRanges = (ranges: Range[]): Range[] => { @@ -193,6 +196,86 @@ const forEachChange = (tr: Transaction, cb: (from: number, to: number) => void): }); }; +/** + * Revision indices carried by a node's TEXT — deliberately ignoring the + * node-level attribute, which the caller has to reason about separately: the + * attribute is what gets rewritten, the marks are the evidence for what it + * should say. + */ +const textRevisions = (node: PMNode, rev: number): { self: boolean; prior?: number } => { + let self = false; + let prior: number | undefined; + node.descendants((child) => { + if (!child.isText) return true; + for (const m of child.marks) { + if (m.type.name !== REVISION_MARK) continue; + const i = m.attrs.index; + if (typeof i !== "number") continue; + if (i === rev) self = true; + else if (i < rev && (prior === undefined || i > prior)) prior = i; + } + return false; + }); + return { self, prior }; +}; + +/** Stable `data-id`s of the top-level lines overlapping [from, to] in `doc`. */ +const idsInSpan = (doc: PMNode, from: number, to: number): string[] => { + const size = doc.content.size; + const lo = Math.max(0, Math.min(from, size)); + const hi = Math.max(lo, Math.min(to, size)); + const out: string[] = []; + doc.nodesBetween(lo, hi, (node, _pos, parent) => { + if (parent !== doc) return false; + const id = node.attrs["data-id"]; + if (typeof id === "string") out.push(id); + return false; + }); + return out; +}; + +/** + * Lines present before this transaction and absent after it, as `data-id`s. + * + * Gated on the document's top-level child count actually shrinking, which is an + * O(1) read and false for every keystroke — typing, and any edit confined to one + * line, can never drop a block. Only when a block really disappears do the two + * bounded walks run, over the changed span alone rather than the whole document. + * + * A transaction that deletes one line and adds another leaves the count equal and + * is skipped. That under-reports rather than over-reports: the surviving lines are + * still compared against the baseline, so a real content change is still marked — + * only the anchor for the vanished line is missed, and any edit that produced it + * has almost certainly changed a neighbouring line's text too. + */ +const goneIds = (tr: Transaction, oldDoc: PMNode, newDoc: PMNode, lo: number, hi: number): string[] => { + if (newDoc.childCount >= oldDoc.childCount) return []; + + // The removed span in ORIGINAL-document coordinates. Mapping the result span + // backwards is not enough: a deletion collapses to a single point in the + // result, so inverting it yields a point too — a window that covers the line + // the join landed in and misses the line that was taken out, which is the only + // one being looked for. Each step's own map reports what it removed in its + // INPUT coordinates, so rebase those onto the original doc. + let oLo = Infinity; + let oHi = -Infinity; + tr.mapping.maps.forEach((map, i) => { + const back = tr.mapping.slice(0, i).invert(); + map.forEach((os: number, oe: number) => { + const a = back.map(os, -1); + const b = back.map(oe, 1); + if (a < oLo) oLo = a; + if (b > oHi) oHi = b; + }); + }); + if (oLo === Infinity) return []; + + const before = idsInSpan(oldDoc, oLo - 1, oHi + 1); + if (before.length === 0) return []; + const after = new Set(idsInSpan(newDoc, lo - 1, hi + 1)); + return before.filter((id) => !after.has(id)); +}; + /** * Build the (single) transaction that applies all pending revision edits at * revision `rev`, or null if there's nothing to apply: @@ -283,6 +366,231 @@ const buildStampTransaction = (state: EditorState, pending: Pending, rev: number return tr; }; +/** + * Build the stamp transaction by COMPARING each touched line against the baseline + * captured when this revision opened, rather than by replaying the edit events + * that reached it. See the note above {@link RevisionBaseEntry} for why the + * comparison is the definition and the events are only an approximation of it. + * + * Per top-level line the edit window touched: + * - no baseline entry → the line is new; mark all of it; + * - baseline is `null` → already revised when the baseline was taken; leave its + * marks alone and re-stamp, never clear; + * - baseline equals the current text → RESTORED; drop this revision's marks; + * - otherwise → mark exactly the run that differs. + * + * Cost is a string compare plus an O(len) two-sided trim for the handful of short + * lines an edit touched, on the already-debounced flush — strictly less work than + * the position bookkeeping the event path does on every keystroke. + * + * Note the comparison is on text only: reverting a line's words but leaving new + * bold or italic on them reads as restored. Production revision marks track + * dialogue and action changing, not styling, so that is the intended reading. + */ +const buildDerivedStampTransaction = ( + state: EditorState, + pending: Pending, + rev: number, + baseline: RevisionBaseline, +): Transaction | null => { + const markType = state.schema.marks[REVISION_MARK]; + if (!markType || pending.lo === Infinity) return null; + + const doc = state.doc; + const size = doc.content.size; + const clamp = (p: number) => Math.max(0, Math.min(p, size)); + const insMark = markType.create({ index: rev, kind: "ins" }); + const delMark = markType.create({ index: rev, kind: "del" }); + let tr: Transaction | null = null; + + // Did this window remove a line that existed at the baseline? A line the user + // created and then removed inside the same revision leaves nothing to report — + // that is precisely the "type a character, delete it, keep the asterisk + // forever" case the event path could not distinguish. + let removedBaselineLine = false; + for (const id of pending.gone) { + if (baseline.get(id) !== undefined) { + removedBaselineLine = true; + break; + } + } + + const wLo = Math.max(0, clamp(pending.lo) - 1); + const wHi = clamp(pending.hi + 1); + + doc.nodesBetween(wLo, wHi, (node, pos, parent) => { + if (parent !== doc) return false; + if (!STAMP_TYPES.has(node.type.name)) return false; + + const id = node.attrs["data-id"]; + const base = typeof id === "string" ? baseline.get(id) : undefined; + const text = node.textContent; + const start = pos + 1; + + // Restored to the draft this revision is measured against: retract this + // revision's marks. Earlier revisions' marks on surviving characters are + // left untouched — safe by construction, since the baseline was captured + // when revision `rev` opened and its text already contains revisions + // 1..rev-1 — and `prior` restores the asterisk for any that the edit being + // undone destroyed along with the characters carrying them. + if (base !== undefined && !base.self && base.text === text) { + const end = start + node.content.size; + const marks = node.content.size > 0 ? textRevisions(node, rev) : { self: false, prior: undefined }; + + if (marks.self) { + tr = tr ?? state.tr; + tr.removeMark(start, end, insMark); + tr.removeMark(start, end, delMark); + } + + // A node stamp with NO marked text of its own is a deletion anchor: it + // records that material NEXT TO this line was cut, which comparing this + // line's text can neither confirm nor refute. Left alone deliberately — + // it is the one piece of state not recoverable from (baseline, text), + // so rewriting it would drop the only trace of that deletion the next + // time any edit happened to touch this line. + const bareAnchor = !marks.self && node.attrs.revision === rev; + if (!bareAnchor) { + // An older revision still marked on surviving characters shows its + // own asterisk. Only when none survives does the line need `prior` + // written to the node — the edit just undone destroyed those marks + // along with the characters that carried them. + const rescue = marks.prior === undefined ? base.prior : undefined; + let want: number | null | undefined; + if (node.attrs.revision === rev) want = rescue ?? null; + else if (rescue !== undefined && node.attrs.revision == null) want = rescue; + if (want !== undefined && node.attrs.revision !== want) { + tr = tr ?? state.tr; + tr.setNodeMarkup(pos, undefined, { ...node.attrs, revision: want }); + } + } + return false; + } + + // Empty line (a fresh Enter, or one emptied by this edit) — no character + // to hang a mark on, so the node attribute carries the asterisk. + if (node.content.size === 0) { + if (node.attrs.revision !== rev) { + tr = tr ?? state.tr; + tr.setNodeMarkup(pos, undefined, { ...node.attrs, revision: rev }); + } + return false; + } + + // Where the user's caret actually put text in this line, as a line-local + // offset. Comparing against the baseline says WHAT changed but cannot say + // which of several identical alignments the user meant; this is the other + // half of that answer, and it is already sitting in the pending set. Only + // covers edits from the current window — a line edited in an earlier flush + // falls back to the diff's own leftmost alignment, which is stable because + // an unambiguous run has only one alignment to choose from. + let anchor = -1; + const nodeEnd = start + node.content.size; + for (const r of pending.ins) { + if (r.from >= start && r.from <= nodeEnd) { + anchor = r.from - start; + break; + } + } + + // New line, or one already revised when the baseline was captured: the + // whole thing is this revision's. Otherwise, exactly the runs that differ. + const runs = + base === undefined || base.self ? [{ from: 0, to: text.length }] : diffRuns(base.text, text, anchor); + if (runs.length === 0) return false; + + // RECOMPUTE rather than accumulate: drop whatever an earlier flush wrote at + // this revision before re-applying. A previous flush saw an earlier version + // of this line and may have marked a wider span than the current text + // justifies — most obviously the single region spanning two separate edits + // that a prefix/suffix trim used to report. Only a full rewrite makes the + // committed marks a true function of (baseline, text); adding to them would + // let the coarser earlier answer survive underneath as stale colour. + if (doc.rangeHasMark(start, nodeEnd, insMark) || doc.rangeHasMark(start, nodeEnd, delMark)) { + tr = tr ?? state.tr; + tr.removeMark(start, nodeEnd, insMark); + tr.removeMark(start, nodeEnd, delMark); + } + + for (const run of runs) { + if (run.to > run.from) { + tr = tr ?? state.tr; + tr.addMark(clamp(start + run.from), clamp(start + run.to), insMark); + continue; + } + + // Collapsed run: text was only removed here. Anchor the asterisk on a + // surviving neighbouring character, skipping one that already carries a + // revision mark — the `revision` mark excludes its own type, so stamping + // a "del" anchor over an existing "ins" run would strip that character's + // colour, and the line keeps its asterisk from that mark anyway. + const at = clamp(start + run.from); + const $at = doc.resolve(at); + if (!$at.parent.isTextblock) continue; + // Against the in-progress doc, so runs already applied in this same + // rewrite (and other revisions' marks) are both visible. + const markedDoc = tr ? (tr as Transaction).doc : doc; + if (at < $at.end()) { + if (markedDoc.rangeHasMark(at, at + 1, markType)) continue; + tr = tr ?? state.tr; + tr.addMark(at, at + 1, delMark); + } else if (at > $at.start()) { + if (markedDoc.rangeHasMark(at - 1, at, markType)) continue; + tr = tr ?? state.tr; + tr.addMark(at - 1, at, delMark); + } + } + return false; + }); + + // A whole line that existed at the baseline vanished, and every surviving line + // still matches its own baseline — so nothing above marked anything, yet the + // cut is real and has to show. Anchor it on the line left beside the gap. + if (removedBaselineLine && !tr) { + const stampNode = (nodePos: number) => { + const n = doc.nodeAt(nodePos); + if (!n || !STAMP_TYPES.has(n.type.name) || n.attrs.revision === rev) return; + tr = tr ?? state.tr; + tr.setNodeMarkup(nodePos, undefined, { ...n.attrs, revision: rev }); + }; + + for (const point of pending.del) { + const at = clamp(point); + const $at = doc.resolve(at); + + if ($at.parent.isTextblock) { + // Inside a surviving line — hang the asterisk invisibly off an + // adjacent character, never one already carrying a mark. + if (at < $at.end() && !doc.rangeHasMark(at, at + 1, markType)) { + tr = tr ?? state.tr; + tr.addMark(at, at + 1, delMark); + } else if (at > $at.start() && !doc.rangeHasMark(at - 1, at, markType)) { + tr = tr ?? state.tr; + tr.addMark(at - 1, at, delMark); + } else if ($at.depth >= 1 && $at.start() === $at.end()) { + stampNode($at.before(1)); // emptied line — nothing to hang on + } + } else if ($at.depth === 0) { + // BETWEEN two blocks: this is the removed line's own position, so + // no character survives to carry the mark. Stamp the line that + // closed the gap — the one now following the cut, which is where a + // reader looks for what replaced it — and fall back to the line + // before it when the cut ran to the end of the document. + const after = $at.nodeAfter; + if (after && STAMP_TYPES.has(after.type.name)) stampNode(at); + else { + const before = $at.nodeBefore; + if (before && STAMP_TYPES.has(before.type.name)) stampNode(at - before.nodeSize); + } + } + if (tr) break; + } + } + + if (tr) (tr as Transaction).setMeta(REVISION_STAMP_META, true); + return tr; +}; + // --------------------------------------------------------------------------- // Overlay rendering // --------------------------------------------------------------------------- @@ -730,7 +1038,7 @@ const renderOverlay = ( * measure pass). Every entry point early-exits when revisions are off. */ export const createRevisionsExtension = (config: RevisionsConfig) => { - const { getRevisionsEnabled, getCurrentRevision, getDisplayMode } = config; + const { getRevisionsEnabled, getCurrentRevision, getDisplayMode, getBaseline } = config; return Extension.create({ name: "revisions", @@ -758,6 +1066,58 @@ export const createRevisionsExtension = (config: RevisionsConfig) => { let raf = 0; + // Memo for the decorations prop. `DecorationSet.create` builds its tree + // by walking EVERY top-level node (see `buildTree` in prosemirror-view: + // it iterates the doc's children and rescans the span list for each), so + // it costs O(document), not O(decorations) — measured at ~1.5µs over 200 + // lines, ~7µs over 1000 and ~17-20µs over 3000 in desktop Chromium, and + // proportionally worse on the phone's WKWebView. + // + // That is paid on EVERY transaction, in every mode, because the overlay + // widget is returned unconditionally. But the inputs change far less + // often than the prop is asked: a selection move, a focus change, or any + // other plugin's no-op transaction all re-ask for an answer identical to + // the last one. Keying on them collapses that whole class to a pointer + // compare. A doc change still rebuilds — see the note in `decorations`. + let memoDoc: PMNode | null = null; + let memoPending: Pending | null = null; + let memoKey = ""; + let memoSet: DecorationSet | null = null; + + /** + * The decoration set for a given state: the always-mounted overlay + * widget, plus — unless revisions are hidden — inline colour over the + * still-pending (not-yet-flushed) inserted text. + * + * That live colouring is why the pending ranges exist in view state at + * all: new text shows its revision colour on the keystroke, rather than + * only once the debounced mark write lands ~220ms later. It adds no + * document write to the hot path, since continuous typing keeps `ins` at + * O(1) merged ranges — a handful of decorations over the run being + * edited. When the flush stamps the real marks it clears pending, so + * these vanish exactly as the marks (same colour) take over — no flicker. + */ + const buildDecorations = ( + state: EditorState, + mode: RevisionDisplayMode, + rev: number, + pending: Pending | null, + ): DecorationSet => { + const color = mode === "hidden" ? undefined : revisionColor(rev); + if (!color || !pending || pending.ins.length === 0) { + return DecorationSet.create(state.doc, [overlayDeco]); + } + const size = state.doc.content.size; + const style = `color: ${color}`; + const decos: Decoration[] = [overlayDeco]; + for (const r of pending.ins) { + const from = Math.max(0, Math.min(r.from, size)); + const to = Math.max(0, Math.min(r.to, size)); + if (to > from) decos.push(Decoration.inline(from, to, { style })); + } + return DecorationSet.create(state.doc, decos); + }; + return [ new Plugin({ key: revisionsPluginKey, @@ -769,7 +1129,7 @@ export const createRevisionsExtension = (config: RevisionsConfig) => { // debounce (see the view below), keeping the key event free. state: { init: () => EMPTY_PENDING, - apply(tr, value) { + apply(tr, value, oldState, newState) { // Our debounced flush landed → pending is now applied. if (tr.getMeta(REVISION_STAMP_META)) return EMPTY_PENDING; if (!getRevisionsEnabled() || getCurrentRevision() < 1) { @@ -789,7 +1149,7 @@ export const createRevisionsExtension = (config: RevisionsConfig) => { // are stamped by their author — keep our pending mapped // forward, but don't record them as local changes. if (tr.getMeta(ySyncPluginKey)) { - return { ins: mergeRanges(ins), del, lo, hi, dirty: value.dirty }; + return { ins: mergeRanges(ins), del, lo, hi, gone: value.gone, dirty: value.dirty }; } forEachChange(tr, (from, to) => { @@ -798,11 +1158,24 @@ export const createRevisionsExtension = (config: RevisionsConfig) => { if (from < lo) lo = from; if (to > hi) hi = to; }); + + // Lines this transaction removed outright. O(1) unless a + // block actually disappeared; see {@link goneIds}. + let gone = value.gone; + if (lo !== Infinity) { + const removed = goneIds(tr, oldState.doc, newState.doc, lo, hi); + if (removed.length > 0) { + gone = new Set(gone); + for (const id of removed) gone.add(id); + } + } + return { ins: mergeRanges(ins), del: del.length > 8 ? [...new Set(del)] : del, lo, hi, + gone, dirty: true, }; }, @@ -828,37 +1201,23 @@ export const createRevisionsExtension = (config: RevisionsConfig) => { // — the path scroll culling and the "current" filter already // take on every frame. decorations(state) { - // Hidden: overlay only — no live colouring of pending text. - if (getDisplayMode() === "hidden") return DecorationSet.create(state.doc, [overlayDeco]); - - // Colour the still-pending (not-yet-flushed) inserted - // text live, via cheap inline decorations rebuilt from - // the same merged `ins` ranges the keystroke already - // accumulated — so new text shows its revision colour - // *immediately* while typing, instead of only once the - // debounced mark write lands. This adds no document - // write to the hot path: continuous typing keeps `ins` - // at O(1) merged ranges, so this is a handful of - // decorations over the run being edited. When the flush - // stamps the real marks it clears pending, so these - // decorations vanish exactly as the marks (same colour) - // take over — no flicker. - const pending = revisionsPluginKey.getState(state); - if (!pending || pending.ins.length === 0) { - return DecorationSet.create(state.doc, [overlayDeco]); - } - const color = revisionColor(getCurrentRevision()); - if (!color) return DecorationSet.create(state.doc, [overlayDeco]); - - const size = state.doc.content.size; - const style = `color: ${color}`; - const decos: Decoration[] = [overlayDeco]; - for (const r of pending.ins) { - const from = Math.max(0, Math.min(r.from, size)); - const to = Math.max(0, Math.min(r.to, size)); - if (to > from) decos.push(Decoration.inline(from, to, { style })); + const mode = getDisplayMode(); + const rev = getCurrentRevision(); + const pending = revisionsPluginKey.getState(state) ?? null; + // Everything the answer depends on. `pending` is compared + // by identity on purpose: its `apply` returns the SAME + // object when a transaction changed nothing it tracks, so + // the pointer is exactly the "no new edits" signal. + const key = `${mode}:${rev}`; + if (memoSet && memoDoc === state.doc && memoPending === pending && memoKey === key) { + return memoSet; } - return DecorationSet.create(state.doc, decos); + const set = buildDecorations(state, mode, rev, pending); + memoDoc = state.doc; + memoPending = pending; + memoKey = key; + memoSet = set; + return set; }, }, @@ -908,7 +1267,21 @@ export const createRevisionsExtension = (config: RevisionsConfig) => { // permanent asterisk beside the committed one on that // line, which cleared only once some later edit on it // finally produced a stampable change. - const tr = buildStampTransaction(view.state, pending, rev) ?? view.state.tr; + // + // Prefer comparing against the baseline captured when + // this revision opened; fall back to replaying the edit + // events when there is no baseline for it (a project + // that predates the feature, or a revision advanced with + // no editor open to snapshot it). The fallback can + // over-mark — the exact wart the baseline removes — but + // it never clears a mark it cannot justify, so an absent + // baseline degrades to the previous behaviour rather + // than to a wrong one. + const baseline = getBaseline?.() ?? null; + const tr = + (baseline && baseline.index === rev + ? buildDerivedStampTransaction(view.state, pending, rev, baseline) + : buildStampTransaction(view.state, pending, rev)) ?? view.state.tr; view.dispatch(tr.setMeta(REVISION_STAMP_META, true)); }; const scheduleFlush = () => { diff --git a/src/lib/screenplay/revisions.ts b/src/lib/screenplay/revisions.ts index d6fdbb70..7bbda6fd 100644 --- a/src/lib/screenplay/revisions.ts +++ b/src/lib/screenplay/revisions.ts @@ -17,6 +17,10 @@ * colour order and are surfaced verbatim in printed page headers. */ +import type { Node as PMNode } from "@tiptap/pm/model"; + +import { ScreenplayElement } from "../utils/enums"; + export interface RevisionColor { name: string; value: string; @@ -54,11 +58,27 @@ export const DEFAULT_REVISION_DISPLAY_MODE: RevisionDisplayMode = "all"; * that writes the `revision` attr onto edited lines). Stamping only changes an * attribute — never layout — so the pagination plugin skips it exactly like it * skips `nodeDedupId`, avoiding a redundant second measure pass per keystroke. - * Defined here (a dependency-free module) so both the pagination and revisions - * extensions can import it without a cycle. + * Defined here (a leaf module — it imports only the element enum) so both the + * pagination and revisions extensions can import it without a cycle. */ export const REVISION_STAMP_META = "revisionStamp"; +/** Mark type name; the inline mark that stamps changed text with its revision index. */ +export const REVISION_MARK = "revision"; + +/** Top-level block types that can carry the node-level `revision` attribute. */ +export const STAMP_TYPES = new Set([ + ScreenplayElement.Scene, + ScreenplayElement.Action, + ScreenplayElement.Character, + ScreenplayElement.Dialogue, + ScreenplayElement.Parenthetical, + ScreenplayElement.Transition, + ScreenplayElement.Section, + ScreenplayElement.Note, + ScreenplayElement.DualDialogue, +]); + /** Clamp an arbitrary index into the colour list's bounds. */ export const clampRevision = (index: number): number => Math.max(0, Math.min(index, REVISION_COLORS.length - 1)); @@ -70,3 +90,259 @@ export const revisionColor = (index: number | null | undefined): string | undefi /** Next revision index (the colour a draft-lock or "advance" moves to). Caps at the last colour. */ export const nextRevision = (index: number): number => clampRevision(index + 1); + +// --------------------------------------------------------------------------- +// Baseline — the text each line held when the current revision opened +// --------------------------------------------------------------------------- + +/** + * Why a baseline exists at all. + * + * In production the right-margin asterisk means "this line DIFFERS from the last + * coloured pages that went out" — a statement about content, which is what lets a + * script supervisor scan the margin instead of re-reading the scene. Stamping + * from edit events only approximates that: it says "this line was TOUCHED". The + * two agree until someone changes their mind, at which point deleting a character + * and retyping it, or typing one and deleting it, leaves a line marked as revised + * that is character-for-character identical to the draft it is being compared to. + * + * Recording what each line held when the revision opened turns the mark into a + * pure function of (baseline, current text). Beyond matching the real definition, + * that makes stamping idempotent and convergent — every client computing from the + * same baseline and the same text arrives at the same marks with no coordination, + * which event-accumulation cannot promise in a collaborative document. + */ + +/** + * One line's state when the current revision opened. An absent entry means the + * line did not exist at capture time, so it is new and belongs to this revision + * in its entirety. + */ +export type RevisionBaseEntry = { + /** The line's text at capture time. Reading back identical means restored. */ + text: string; + /** + * Highest revision index the line was ALREADY marked at when the baseline was + * captured, if any. + * + * Restoring a line's text cannot restore those older marks: the characters + * carrying them were destroyed by the very edit now being undone, and the + * baseline stores text, not mark runs. Without this the line would come back + * completely unmarked and silently lose an asterisk it is still entitled to. + * Re-stamping the node at this index keeps that asterisk, in the right colour. + */ + prior?: number; + /** + * The line already carried THIS revision's mark at capture time — a project + * that predates the baseline, or a revision re-opened over existing marks. Its + * text was never measured against this revision, so it is treated as + * permanently differing and never auto-cleared rather than being judged + * against a comparison that was never taken. + */ + self?: boolean; +}; + +/** + * Baseline lookup handed to the revisions extension. `index` is the revision the + * baseline was captured for; stamping only takes the derived path when it matches + * the current revision, so a missing or stale baseline falls back to the + * event-based path rather than clearing marks against the wrong draft. + */ +export type RevisionBaseline = { + index: number; + get: (dataId: string) => RevisionBaseEntry | undefined; +}; + +/** + * A run of `next` that differs from the baseline, in `next`'s own offsets. A + * collapsed run (`to === from`) is a deletion point: nothing survives to colour, + * so the caller anchors the line's asterisk there instead. + */ +export type DiffRun = { from: number; to: number }; + +/** + * Largest trimmed middle worth diffing properly. Past this the changed region is + * a rewrite rather than an edit, and one coarse run covering it is both the right + * answer and the cheap one. Comfortably above a screenplay paragraph, which the + * narrow text column and the convention of short action blocks keep well under + * this in practice. + */ +const DIFF_LIMIT = 400; + +/** + * Move an inserted run to the alignment that reflects what the user actually did. + * + * When the inserted text repeats what already surrounds it, several alignments + * produce the identical final string, and the text alone cannot say which is the + * new copy. Typing "it's " immediately before an existing "it's" is the plain + * case: both "Hey, [it's ]it's you" and "Hey, it's [it's ]you" reconstruct the + * same sentence, and a prefix trim always lands on the second — colouring the + * copy that was already there and leaving the new one looking original. The same + * ambiguity is what smears a run off its word boundary, marking the "s" of "sits" + * in "He [tands and s]its" instead of the "stands and " that was really typed. + * + * `anchor` is where the user's caret actually inserted, in the same offsets as + * `next`, which resolves it exactly — the diff supplies what changed, the edit + * supplies where. With no anchor available, the leftmost equivalent alignment is + * the conventional choice (the same "shift the hunk up" rule diff tools use) and + * keeps runs on word boundaries far more often than the trim's rightmost. + * + * `leftBound`/`rightBound` keep a run from sliding into its neighbours. + */ +const slideRun = (next: string, run: DiffRun, leftBound: number, rightBound: number, anchor: number): DiffRun => { + const len = run.to - run.from; + // Slide left while the character before the run repeats its last character, + // and right while the character after it repeats its first — the two moves + // that leave the reconstructed string untouched. + let lo = run.from; + while (lo > leftBound && next.charCodeAt(lo - 1) === next.charCodeAt(lo + len - 1)) lo--; + let hi = run.from; + while (hi + len < rightBound && next.charCodeAt(hi + len) === next.charCodeAt(hi)) hi++; + const at = anchor < 0 ? lo : Math.max(lo, Math.min(anchor, hi)); + return { from: at, to: at + len }; +}; + +/** + * Every run of `next` that differs from `prev`, left to right; empty when they + * are identical. + * + * A common prefix/suffix trim alone is NOT enough, which is worth stating plainly + * because it is tempting and wrong: it can only describe one contiguous region, + * so a paragraph edited near its start and again near its end reports a single + * run spanning everything between the two — colouring and asterisking the + * untouched middle. Because the baseline lives for the whole revision, that is + * the normal way a paragraph gets revised, not a rare case. + * + * So the trim only narrows the problem (and answers a single edit outright), and + * an LCS over what remains recovers the individual runs. Costs a table over the + * trimmed middle for the handful of short lines an edit touched, on the already + * debounced flush — the common case never reaches it, because one edit leaves one + * side of the trim empty. + */ +export const diffRuns = (prev: string, next: string, anchor = -1): DiffRun[] => { + if (prev === next) return []; + + const max = Math.min(prev.length, next.length); + let p = 0; + while (p < max && prev.charCodeAt(p) === next.charCodeAt(p)) p++; + let s = 0; + while (s < max - p && prev.charCodeAt(prev.length - 1 - s) === next.charCodeAt(next.length - 1 - s)) s++; + + const a = prev.slice(p, prev.length - s); + const b = next.slice(p, next.length - s); + const n = a.length; + const m = b.length; + + // One side empty → a pure insertion or a pure deletion, already exact. Over + // the limit → treat the whole region as rewritten. + if (n === 0 || m === 0 || n > DIFF_LIMIT || m > DIFF_LIMIT) { + const run = { from: p, to: p + m }; + return m > 0 ? [slideRun(next, run, 0, next.length, anchor)] : [run]; + } + + // lcs[i][j] = length of the longest common subsequence of a[i..] and b[j..]. + const w = m + 1; + const lcs = new Uint16Array((n + 1) * w); + for (let i = n - 1; i >= 0; i--) { + const ai = a.charCodeAt(i); + for (let j = m - 1; j >= 0; j--) { + lcs[i * w + j] = + ai === b.charCodeAt(j) + ? lcs[(i + 1) * w + (j + 1)] + 1 + : Math.max(lcs[(i + 1) * w + j], lcs[i * w + (j + 1)]); + } + } + + // `pure` marks a run that only ADDED characters. Those are the ones whose + // alignment is ambiguous and can be slid; a run that also dropped characters + // is pinned by what it replaced. + const runs: (DiffRun & { pure: boolean })[] = []; + let i = 0; + let j = 0; + let runStart = -1; + let runPure = true; + const openRun = (dropped: boolean) => { + if (runStart < 0) { + runStart = p + j; + runPure = true; + } + if (dropped) runPure = false; + }; + const closeRun = () => { + if (runStart < 0) return; + runs.push({ from: runStart, to: p + j, pure: runPure }); + runStart = -1; + }; + + while (i < n && j < m) { + if (a.charCodeAt(i) === b.charCodeAt(j)) { + closeRun(); + i++; + j++; + } else if (lcs[(i + 1) * w + j] >= lcs[i * w + (j + 1)]) { + openRun(true); // a[i] dropped + i++; + } else { + openRun(false); // b[j] added + j++; + } + } + if (runStart < 0 && (i < n || j < m)) openRun(i < n); + else if (i < n) runPure = false; + j = m; + closeRun(); + + // Resolve alignment ambiguity, bounded by the neighbouring runs so a slide + // can never cross one. + return runs.map((r, k) => { + if (!r.pure || r.to === r.from) return { from: r.from, to: r.to }; + const leftBound = k > 0 ? runs[k - 1].to : 0; + const rightBound = k + 1 < runs.length ? runs[k + 1].from : next.length; + return slideRun(next, r, leftBound, rightBound, anchor); + }); +}; + +/** + * Revision marks already on a top-level node: whether one sits at `rev`, and the + * highest index below it. One walk, since the baseline needs both. + */ +export const nodeRevisions = (node: PMNode, rev: number): { self: boolean; prior?: number } => { + let self = false; + let prior: number | undefined; + const note = (index: unknown) => { + if (typeof index !== "number") return; + if (index === rev) self = true; + else if (index < rev && (prior === undefined || index > prior)) prior = index; + }; + note(node.attrs.revision); + node.descendants((child) => { + if (!child.isText) return true; + for (const m of child.marks) if (m.type.name === REVISION_MARK) note(m.attrs.index); + return false; + }); + return { self, prior }; +}; + +/** + * Snapshot every top-level line, keyed by its stable `data-id`, to serve as the + * baseline for revision `rev` — see {@link RevisionBaseEntry}. + * + * Captured eagerly when a revision opens rather than lazily on first edit: a + * client that is offline when a line is first touched would capture a baseline + * from stale text, whereas a snapshot written once at revision open is the same + * for everyone. One text copy of the screenplay (~150–250 KB for a feature), and + * it REPLACES the previous baseline on each advance rather than accumulating. + */ +export const captureRevisionBaseline = (doc: PMNode, rev: number): Map => { + const base = new Map(); + doc.forEach((node) => { + const id = node.attrs["data-id"]; + if (typeof id !== "string" || !STAMP_TYPES.has(node.type.name)) return; + const { self, prior } = nodeRevisions(node, rev); + const entry: RevisionBaseEntry = { text: node.textContent }; + if (self) entry.self = true; + if (prior !== undefined) entry.prior = prior; + base.set(id, entry); + }); + return base; +}; diff --git a/src/tests/repro/revisions-baseline.test.ts b/src/tests/repro/revisions-baseline.test.ts new file mode 100644 index 00000000..5600a836 --- /dev/null +++ b/src/tests/repro/revisions-baseline.test.ts @@ -0,0 +1,523 @@ +import { describe, it, expect } from "vitest"; +import { Editor } from "@tiptap/core"; + +import { BASE_EXTENSIONS } from "@src/lib/screenplay/editor"; +import { createRevisionsExtension } from "@src/lib/screenplay/extensions/revisions-extension"; +import { RevisionBaseEntry, captureRevisionBaseline, diffRuns } from "@src/lib/screenplay/revisions"; + +/** + * Revision marks derived by comparing each line against the baseline captured + * when the revision opened, rather than by replaying the edits that reached it. + * + * The behaviour these lock down is the difference between "this line was touched" + * and "this line differs from the draft that went out" — the second being what a + * production asterisk actually means. + */ + +type RevState = { + enabled: boolean; + current: number; + display: "all" | "hidden" | "current"; + /** Revision the baseline belongs to; null → no baseline, event-based path. */ + baseIndex: number | null; +}; + +function makeEditor(lines: string[]) { + const el = document.createElement("div"); + document.body.appendChild(el); + const rev: RevState = { enabled: false, current: 0, display: "all", baseIndex: null }; + const base = new Map(); + + const editor = new Editor({ + element: el, + injectCSS: false, + autofocus: false, + content: { + type: "doc", + content: lines.map((text, i) => ({ + type: "action", + attrs: { "data-id": `n${i}`, class: "action" }, + content: text ? [{ type: "text", text }] : [], + })), + }, + extensions: [ + ...BASE_EXTENSIONS, + createRevisionsExtension({ + getRevisionsEnabled: () => rev.enabled, + getCurrentRevision: () => rev.current, + getDisplayMode: () => rev.display, + getBaseline: () => + rev.baseIndex === null ? null : { index: rev.baseIndex, get: (id: string) => base.get(id) }, + }), + ], + }); + + /** Snapshot the document as the baseline for `index`, as opening it would. */ + const capture = (index: number) => { + base.clear(); + captureRevisionBaseline(editor.state.doc, index).forEach((v, k) => base.set(k, v)); + rev.baseIndex = index; + }; + + return { editor, rev, base, capture }; +} + +/** Stamping is debounced (FLUSH_DELAY = 220ms); wait past it before asserting. */ +const settle = () => new Promise((r) => setTimeout(r, 320)); + +/** Document position just inside top-level child `index`. */ +function insidePos(editor: Editor, index: number): number { + let pos = -1; + editor.state.doc.forEach((node, p, i) => { + if (i === index) pos = p + 1; + }); + return pos; +} + +/** Highest revision-mark index on any text in child `index`, or null. */ +const maxRevOf = (editor: Editor, index: number): number | null => { + const node = editor.state.doc.child(index); + let max: number | null = null; + node.descendants((child) => { + if (!child.isText) return; + const mark = child.marks.find((m) => m.type.name === "revision"); + if (mark) { + const i = mark.attrs.index as number; + if (max === null || i > max) max = i; + } + }); + return max; +}; + +/** Node-level revision attribute on top-level child `index`, or null. */ +const lineAttrOf = (editor: Editor, index: number): number | null => + (editor.state.doc.child(index).attrs.revision as number | null) ?? null; + +/** Does child `index` carry any revision signal at all (mark or node attribute)? */ +const isMarked = (editor: Editor, index: number): boolean => + maxRevOf(editor, index) !== null || lineAttrOf(editor, index) !== null; + +/** The text actually carrying a revision mark in child `index`. */ +const markedTextOf = (editor: Editor, index: number): string => { + const node = editor.state.doc.child(index); + let out = ""; + node.descendants((child) => { + if (!child.isText) return; + if (child.marks.some((m) => m.type.name === "revision" && m.attrs.kind === "ins")) out += child.text ?? ""; + }); + return out; +}; + +const textOf = (editor: Editor, index: number): string => editor.state.doc.child(index).textContent; + +const deleteIn = (editor: Editor, index: number, from: number, to: number) => { + const start = insidePos(editor, index); + editor.chain().focus().deleteRange({ from: start + from, to: start + to }).run(); +}; + +const insertIn = (editor: Editor, index: number, at: number, text: string) => { + const start = insidePos(editor, index); + editor.chain().focus().insertContentAt(start + at, text).run(); +}; + +const LINES = ["FADE IN ON A HOUSE", "A man walks in", "He sits down", "SILENCE"]; + +describe("diffRuns", () => { + it("returns nothing for identical text", () => { + expect(diffRuns("abc", "abc")).toEqual([]); + }); + + it("isolates a replaced character", () => { + expect(diffRuns("abc", "aXc")).toEqual([{ from: 1, to: 2 }]); + }); + + it("isolates an insertion in the middle", () => { + expect(diffRuns("abc", "abXc")).toEqual([{ from: 2, to: 3 }]); + }); + + it("collapses on a pure deletion", () => { + expect(diffRuns("abc", "ac")).toEqual([{ from: 1, to: 1 }]); + }); + + it("handles insertion into empty text and deletion to empty", () => { + expect(diffRuns("", "abc")).toEqual([{ from: 0, to: 3 }]); + expect(diffRuns("abc", "")).toEqual([{ from: 0, to: 0 }]); + }); + + it("reports two separated edits as two runs, not one spanning region", () => { + // The regression: a prefix/suffix trim can only name one region, so it + // reported [0, 27) here — colouring the untouched middle. + expect(diffRuns("The quick brown fox jumps", "XThe quick brown fox jumpsY")).toEqual([ + { from: 0, to: 1 }, + { from: 26, to: 27 }, + ]); + }); + + it("reports an edit at each end of a sentence without touching the middle", () => { + const runs = diffRuns("He sits down slowly and waits", "She sits down slowly and waited"); + expect(runs.length).toBe(2); + expect(runs[0].from).toBe(0); + // Nothing marked across the untouched middle. + expect(runs[0].to).toBeLessThanOrEqual(3); + expect(runs[1].from).toBeGreaterThan(20); + }); + + it("reports a separated insertion and deletion", () => { + expect(diffRuns("abcdefgh", "aXcdefh")).toEqual([ + { from: 1, to: 2 }, + { from: 6, to: 6 }, + ]); + }); + + it("marks the copy the caret actually inserted, not an identical neighbour", () => { + // "Hey, it's you" + "it's " typed at offset 5. A prefix trim lands on the + // SECOND "it's"; the anchor puts it back on the one that was typed. + const prev = "Hey, it's you"; + const next = "Hey, it's it's you"; + expect(diffRuns(prev, next, 5)).toEqual([{ from: 5, to: 10 }]); + // Typed AFTER the existing one instead — same text, different intent. + expect(diffRuns(prev, next, 10)).toEqual([{ from: 10, to: 15 }]); + }); + + it("keeps an inserted run on its word boundary", () => { + // The trim reports "tands and s", orphaning the "s" of "stands" and + // claiming the "s" of "sits". + const prev = "He sits"; + const next = "He stands and sits"; + expect(diffRuns(prev, next, 3)).toEqual([{ from: 3, to: 14 }]); + expect(next.slice(3, 14)).toBe("stands and "); + }); + + it("without an anchor, prefers the leftmost equivalent alignment", () => { + expect(diffRuns("He sits", "He stands and sits")).toEqual([{ from: 2, to: 13 }]); + }); + + it("does not slide a run that replaced text rather than only adding", () => { + // "cat" → "dog" is pinned by what it replaced; nothing to disambiguate. + expect(diffRuns("the cat sat", "the dog sat", 4)).toEqual([{ from: 4, to: 7 }]); + }); + + it("falls back to one coarse run when the changed region is a rewrite", () => { + const prev = "q".repeat(600); + const next = "z".repeat(600); + expect(diffRuns(prev, next)).toEqual([{ from: 0, to: 600 }]); + }); +}); + +describe("revisions: marks are derived from the revision's baseline", () => { + it("does not mark a character deleted and retyped in one flush window", async () => { + const { editor, rev, capture } = makeEditor(LINES); + rev.enabled = true; + rev.current = 1; + capture(1); + + deleteIn(editor, 1, 2, 3); + insertIn(editor, 1, 2, "m"); + await settle(); + + expect(textOf(editor, 1)).toBe("A man walks in"); + expect(isMarked(editor, 1)).toBe(false); + }); + + it("clears a committed mark when the text is restored in a later flush", async () => { + const { editor, rev, capture } = makeEditor(LINES); + rev.enabled = true; + rev.current = 1; + capture(1); + + deleteIn(editor, 1, 2, 3); + await settle(); + // The deletion really was a change while it stood. + expect(isMarked(editor, 1)).toBe(true); + + insertIn(editor, 1, 2, "m"); + await settle(); + + expect(textOf(editor, 1)).toBe("A man walks in"); + expect(isMarked(editor, 1)).toBe(false); + }); + + it("does not mark a character typed and then deleted", async () => { + const { editor, rev, capture } = makeEditor(LINES); + rev.enabled = true; + rev.current = 1; + capture(1); + + insertIn(editor, 2, 3, "Z"); + await settle(); + expect(isMarked(editor, 2)).toBe(true); + + deleteIn(editor, 2, 3, 4); + await settle(); + + expect(textOf(editor, 2)).toBe("He sits down"); + expect(isMarked(editor, 2)).toBe(false); + }); + + it("marks only the run that actually differs from the baseline", async () => { + const { editor, rev, capture } = makeEditor(LINES); + rev.enabled = true; + rev.current = 1; + capture(1); + + insertIn(editor, 2, 3, "really "); + await settle(); + + expect(textOf(editor, 2)).toBe("He really sits down"); + expect(markedTextOf(editor, 2)).toBe("really "); + }); + + it("marks both ends of a paragraph without colouring the middle", async () => { + const PARA = "He crosses the room and opens the heavy oak door onto the garden"; + const { editor, rev, capture } = makeEditor([LINES[0], PARA]); + rev.enabled = true; + rev.current = 1; + capture(1); + + // Edit at the END of the paragraph, then at its START — the sequence that + // used to mark everything in between. + insertIn(editor, 1, PARA.length, " beyond"); + await settle(); + expect(markedTextOf(editor, 1)).toBe(" beyond"); + + insertIn(editor, 1, 0, "Slowly "); + await settle(); + + expect(textOf(editor, 1)).toBe(`Slowly ${PARA} beyond`); + // Only the two edits are coloured; the untouched middle stays clean. + expect(markedTextOf(editor, 1)).toBe("Slowly beyond"); + }); + + it("colours the duplicate word the caret typed, not the one already there", async () => { + const { editor, rev, capture } = makeEditor([LINES[0], "Hey, it's you"]); + rev.enabled = true; + rev.current = 1; + capture(1); + + // Type "it's " immediately BEFORE the existing "it's". + insertIn(editor, 1, 5, "it's "); + await settle(); + + expect(textOf(editor, 1)).toBe("Hey, it's it's you"); + expect(markedTextOf(editor, 1)).toBe("it's "); + // The marked run has to be the FIRST occurrence — offset 5, not 10. + const marked = editor.state.doc.child(1); + let markStart = -1; + marked.descendants((child, off) => { + if (!child.isText) return; + if (markStart < 0 && child.marks.some((m) => m.type.name === "revision" && m.attrs.kind === "ins")) { + markStart = off; + } + }); + expect(markStart).toBe(5); + }); + + it("keeps a typed phrase on its word boundary", async () => { + const { editor, rev, capture } = makeEditor([LINES[0], "He sits"]); + rev.enabled = true; + rev.current = 1; + capture(1); + + insertIn(editor, 1, 3, "stands and "); + await settle(); + + expect(textOf(editor, 1)).toBe("He stands and sits"); + // Not "tands and s", which is what the raw trim reports. + expect(markedTextOf(editor, 1)).toBe("stands and "); + }); + + it("narrows an earlier coarse mark once a later edit makes the runs exact", async () => { + const PARA = "She waits by the window until the car pulls away"; + const { editor, rev, capture } = makeEditor([LINES[0], PARA]); + rev.enabled = true; + rev.current = 1; + capture(1); + + insertIn(editor, 1, 0, "A"); + insertIn(editor, 1, PARA.length + 1, "B"); + await settle(); + expect(markedTextOf(editor, 1)).toBe("AB"); + + // Undo the first edit only: the second must keep its mark, and nothing + // between them may hold colour left over from the earlier flush. + deleteIn(editor, 1, 0, 1); + await settle(); + + expect(textOf(editor, 1)).toBe(`${PARA}B`); + expect(markedTextOf(editor, 1)).toBe("B"); + }); + + it("leaves other lines alone when one line is restored", async () => { + const { editor, rev, capture } = makeEditor(LINES); + rev.enabled = true; + rev.current = 1; + capture(1); + + insertIn(editor, 1, 1, "X"); + insertIn(editor, 3, 0, "Y"); + await settle(); + expect(isMarked(editor, 1)).toBe(true); + expect(isMarked(editor, 3)).toBe(true); + + deleteIn(editor, 1, 1, 2); + await settle(); + + expect(isMarked(editor, 1)).toBe(false); + expect(isMarked(editor, 3)).toBe(true); + }); + + it("keeps an earlier revision's asterisk when this revision's edit is undone", async () => { + const { editor, rev, capture } = makeEditor(LINES); + rev.enabled = true; + rev.current = 1; + capture(1); + + // Revision 1 changes the line for real. + insertIn(editor, 2, 3, "quietly "); + await settle(); + expect(maxRevOf(editor, 2)).toBe(1); + + // Revision 2 opens over that; its baseline is the revised text. + rev.current = 2; + capture(2); + + // Retype the whole line identically — destroying the revision-1 marks. + const line = textOf(editor, 2); + deleteIn(editor, 2, 0, line.length); + insertIn(editor, 2, 0, line); + await settle(); + + expect(textOf(editor, 2)).toBe(line); + // Revision 2 has nothing to say about this line... + expect(maxRevOf(editor, 2)).toBeNull(); + // ...but the revision-1 asterisk it is still entitled to survives. + expect(lineAttrOf(editor, 2)).toBe(1); + }); + + it("never auto-clears a line already marked when the baseline was captured", async () => { + const { editor, rev, capture, base } = makeEditor(LINES); + rev.enabled = true; + rev.current = 1; + capture(1); + + insertIn(editor, 1, 1, "X"); + await settle(); + expect(isMarked(editor, 1)).toBe(true); + + // Re-open revision 1 over the existing marks, as a project predating the + // baseline would: the entry records `self`, so text comparison is refused. + capture(1); + expect(base.get("n1")?.self).toBe(true); + + deleteIn(editor, 1, 1, 2); + await settle(); + + expect(textOf(editor, 1)).toBe("A man walks in"); + expect(isMarked(editor, 1)).toBe(true); + }); + + it("marks a line that did not exist at the baseline", async () => { + const { editor, rev, capture } = makeEditor(LINES); + rev.enabled = true; + rev.current = 1; + capture(1); + + // Enter at the end of line 1 → a new empty line 2. + editor + .chain() + .focus() + .setTextSelection(insidePos(editor, 1) + LINES[1].length) + .splitBlock() + .run(); + await settle(); + + expect(editor.state.doc.childCount).toBe(LINES.length + 1); + // The new line is this revision's in its entirety, whatever it contains. + expect(isMarked(editor, 2)).toBe(true); + // ...and the line it split off from is untouched. + expect(isMarked(editor, 1)).toBe(false); + }); + + it("anchors an asterisk when a baseline line is deleted outright", async () => { + const { editor, rev, capture } = makeEditor(LINES); + rev.enabled = true; + rev.current = 1; + capture(1); + + // Remove the whole of line 2 and the break that separates it from line 1. + const start = insidePos(editor, 2); + editor + .chain() + .focus() + .deleteRange({ from: start - 1, to: start + LINES[2].length }) + .run(); + await settle(); + + expect(editor.state.doc.childCount).toBe(LINES.length - 1); + // The cut leaves no character to hang the mark on, so it lands on the line + // that closed the gap — where a reader looks for what replaced it. + expect(textOf(editor, 2)).toBe("SILENCE"); + expect(lineAttrOf(editor, 2)).toBe(1); + // The line above it is untouched and stays clean. + expect(isMarked(editor, 1)).toBe(false); + }); + + it("leaves nothing behind when a line created in this revision is deleted", async () => { + const { editor, rev, capture } = makeEditor(LINES); + rev.enabled = true; + rev.current = 1; + capture(1); + + editor + .chain() + .focus() + .setTextSelection(insidePos(editor, 1) + LINES[1].length) + .splitBlock() + .run(); + await settle(); + expect(editor.state.doc.childCount).toBe(LINES.length + 1); + + // Take the new line straight back out again (Backspace at its start). + const newStart = insidePos(editor, 2); + editor + .chain() + .focus() + .deleteRange({ from: newStart - 1, to: newStart + editor.state.doc.child(2).content.size }) + .run(); + await settle(); + + expect(editor.state.doc.childCount).toBe(LINES.length); + expect(textOf(editor, 1)).toBe(LINES[1]); + expect(isMarked(editor, 1)).toBe(false); + }); + + it("falls back to event-based stamping when there is no baseline", async () => { + const { editor, rev } = makeEditor(LINES); + rev.enabled = true; + rev.current = 1; + rev.baseIndex = null; // project predating the baseline + + deleteIn(editor, 1, 2, 3); + insertIn(editor, 1, 2, "m"); + await settle(); + + // Documents the fallback: it over-marks rather than clearing a mark it + // cannot justify, which is exactly the previous behaviour. + expect(textOf(editor, 1)).toBe("A man walks in"); + expect(isMarked(editor, 1)).toBe(true); + }); + + it("ignores a baseline captured for a different revision", async () => { + const { editor, rev, capture } = makeEditor(LINES); + rev.enabled = true; + rev.current = 1; + capture(1); + rev.current = 2; // advanced with no editor open to re-snapshot + + deleteIn(editor, 1, 2, 3); + insertIn(editor, 1, 2, "m"); + await settle(); + + expect(isMarked(editor, 1)).toBe(true); + }); +});