From 5db1c374689eccde008c298f058d6937c3a6eaba Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Tue, 8 Sep 2026 20:16:35 +0200 Subject: [PATCH] perf: the pagination pass no longer restyles or churns the whole document A `:has()` left of `+` in editor.css made Chromium restyle every element under .tiptap on each inserted child, so the band-clearing rule keys on a data-wrap-band attribute the imageLine plugin writes. getLineRects reuses one Range instead of leaving hundreds alive. TipTap's Placeholder, which hit-tested the viewport on every keystroke, is replaced by placeholder.ts. docPosBeforeElement is memoised per pass. At 124 pages the pass after a split drops from 410 to 150 ms and the split keystroke from 219 to 50 ms. --- docs/architecture/frames.md | 2 +- docs/architecture/pagination.md | 14 +++++++ package-lock.json | 14 ------- package.json | 1 - src/lib/editor/extensions.ts | 2 +- src/lib/editor/extensions/CLAUDE.md | 1 + src/lib/editor/extensions/headerFooter.ts | 2 +- src/lib/editor/extensions/image.ts | 10 ++++- src/lib/editor/extensions/pageBreaks.ts | 45 ++++++++++++++-------- src/lib/editor/extensions/placeholder.ts | 37 ++++++++++++++++++ src/styles/editor.css | 12 +++--- tests/dom/run.mjs | 17 ++++++++ tests/unit/placeholder.test.ts | 47 +++++++++++++++++++++++ 13 files changed, 164 insertions(+), 40 deletions(-) create mode 100644 src/lib/editor/extensions/placeholder.ts create mode 100644 tests/unit/placeholder.test.ts diff --git a/docs/architecture/frames.md b/docs/architecture/frames.md index efa2e46..b56c489 100644 --- a/docs/architecture/frames.md +++ b/docs/architecture/frames.md @@ -3,7 +3,7 @@ Covers `src/lib/editor/extensions/image.ts` and `textBox.ts` — node views, resize/rotate handles, wrap modes, frame offsets — and their ODF/DOCX legs. -- **`image.ts`** (`Image`) — inline, as-character image atom (Word's "in line with text"). Attrs: `src` (data-URI), `alt`, `width`/`height` (**unscaled doc px @96dpi**, like `rowHeight`, and **fractional** — rounding a page-wide picture to whole px costs 0.3mm of height; `framePx` snaps back to a whole one within 0.05px, which is where our own export's 0.001cm quantization lands, so the round trip stays exact), `rotation` (CW degrees). The node view nests an `` in a rotated "rotor" inside an axis-aligned wrapper that reserves the rotated bounding box (so text reflows around it). The rotor's transform makes it a stacking context, so a selected `.image-node` is given a `z-index` above the header/footer layer to keep handles that poke into a margin grabbable. It has eight zoom-aware resize handles — corners aspect-locked, edges single-axis (width- or height-only) — plus a rotation-arrow grip, and shows a live "Width … × Height …" (cm) badge while resizing; resize deltas are un-rotated onto the image's own axes so handles track the pointer at any angle (drag math mirrors `tableRowResize.ts`; width cap = the cell or page-text-column, height cap = the page text height). `setImage` command inserts it; the toolbar button (ToolbarExpanded), drag-drop and paste (Editor.svelte) read the file via `FileReader` → data-URI and clamp the initial size to the page text box. A picture that arrives by **URL** (an `` pasted or dropped from a web page) is fetched into a data-URI right after the paste (`inlineRemoteImages`); one the site withholds is dropped with a message, since only a data-URI ever reaches a file. **Text wrap:** the `wrap` attr (`'inline'|'left'|'right'|'topBottom'|'through'`) makes the image a *floating* frame — `ImageToolbar.svelte` (a per-image floating toolbar wired in `Editor.svelte` like `TableToolbar`) sets the mode; `left`/`right` float the wrapper at its anchor paragraph (text flows on the open side via CSS `float`), `topBottom` is a **full-width `float`** (text only above/below), and `through` — Word's in-front-of / behind-text (`wp:wrapNone` + `behindDoc`), ODF's `style:wrap="run-through"` — leaves the flow entirely: `position: absolute` with no offsets keeps the static position it was anchored at, so it reserves neither width nor height, and `inFront` picks the side of the text it lands on. Read as a side float instead it reserved a near-column-wide picture's whole height, which cost the image fixture a page and ~50 reports. A text box in that mode always sits behind the text — the mode has no toolbar entry yet, so only a file can carry it in. All modes use `float` deliberately: an in-flow `display:block` on an inline-atom node view splits the paragraph's inline content into anonymous block boxes, which desyncs ProseMirror's inline view descriptor and makes it drop the following page-break spacer widget; a float is out-of-flow and avoids that. Dragging an **inline** image runs ProseMirror's native node move (the `dropCursor` plugin shows the caret). Dragging a **floating** image runs a custom drag (`ImageView.startReposition`) that **live re-anchors** the node to the text position under the cursor, so the float and text reflow in real time as you drag; it's rAF-throttled and only moves when the cursor's line changes (a float can't move within a line), and stays a single undo step (first move records history, later moves are `addToHistory:false` and get rebased on undo). A paragraph that contains an image paginates **atomically** (`pageBreaks.ts` — pushed whole to the next page, spacer placed before it) so a page break never splits the paragraph next to the image's float (where ProseMirror drops the spacer widget). **Wrap distance:** `wrapDist` is the gap the file keeps beside a float, in cm (Word's `distL`/`distR`, ODF's `fo:margin-left`/`-right` on the graphic style) — only the side the text flows on, since the other one is the frame's own offset. A frame that declares none reserves **nothing**, beside it or above/below it: probed, LibreOffice wraps flush against it, and a gap of our own put every figure in a text box 3mm too tall and broke a line a word early. `setImageWrap` gives a frame the file never floated the 0.32cm a word processor's own wrap command writes. **Vertical alignment (as-char):** `vAlign` carries ODF's `style:vertical-pos`/`-rel` pair for an inline frame — `null` (LibreOffice's `top`/`baseline` and Word's only mode) stands the frame on the text baseline, `middle`/`below` centre it on the baseline or hang it below, and `text-top`/`text-middle`/`text-bottom` measure against the character area instead (CSS `vertical-align: text-top`/`text-bottom`; nothing in CSS centres on that area, so `text-middle` uses 0.36em for half of ascent − descent, which Liberation Serif/Sans, Times and Arial share). `from-top` becomes `vAlign: 'offset'` and puts the frame's top `wrapOffsetY` cm below the baseline whatever the relation says — all probed against LibreOffice, whose formula images use `middle`/`text` and `from-top` almost exclusively. A line an as-char frame has to itself — nothing beside it but whitespace — is as tall as the frame or the block's line height, whichever is more, with **no text descent below it** (probed); `image.ts` gives such a frame an `image-line` class, since CSS cannot ask whether a line holds real text. Hard breaks are the only line boundaries the model shows, so the run between two of them is what gets tested — which covers the frame-break-caption idiom LibreOffice's own guides are full of; a frame alone on a *soft-wrapped* line is invisible from there. A frame that shares its line with text keeps the baseline and the descent. **Frame offsets:** `wrapOffset` is the frame's left edge in the text column (Word's `positionH` posOffset, ODF `svg:x`), rendered as the float's near/far margin against the live column vars — an indented anchor paragraph can't skew it — and shared with `textBox.ts` through `frameMargins`. A `topBottom` float spans the column, so its own x moves the *rotor* inside the wrapper instead. `wrapOffsetY` (`positionV`, `svg:y`) is the frame's top below the anchor paragraph's, drawn for `topBottom` only — no text sits beside such a frame, where a side float's top margin would push away lines Word keeps. Chromium places **every** line after a full-width float below it, even the ones the float's top margin leaves room for, so a frame set below the paragraph top is sunk behind that paragraph's text at import (`sinkOffsetFrames`, both importers) and `ImageView.sinkToOffset` measures what the lines above it already cover, adding only the rest. The **block after the anchor paragraph clears the band** (an `editor.css` `:has` rule): a float only pushes lines that intersect it, so a short block (a heading) would slot into the clearance gap a prior side float opens above the band — both word processors start it below; a page-break spacer and the block holding a band-sharing partner frame are exempt. Two `topBottom` frames set against **opposite ends** of nearby paragraphs (`wrapAlign`, from Word's `positionH` align / ODF `style:horizontal-pos`) share one band and float to their own sides instead: `pairAlignedFrames` (import/odt.ts, both importers) keeps the attr only for such a pair — a lone one reserves the whole band, which is what the wrap means — and scales a pair up to 15% wider than the column down to it, since Word lets the two overlap in the middle and two floats cannot. A frame Word puts *inside* running text still lands after it: browsers can't wrap text around a freely-positioned box (CSS Exclusions are unimplemented). Inline images stay as-char. Works inside table cells. Not available while editing a header/footer (the HF schema has no image node). Images live as data-URIs in the autosaved JSON; `autosave.ts` warns once if that exceeds the localStorage quota. +- **`image.ts`** (`Image`) — inline, as-character image atom (Word's "in line with text"). Attrs: `src` (data-URI), `alt`, `width`/`height` (**unscaled doc px @96dpi**, like `rowHeight`, and **fractional** — rounding a page-wide picture to whole px costs 0.3mm of height; `framePx` snaps back to a whole one within 0.05px, which is where our own export's 0.001cm quantization lands, so the round trip stays exact), `rotation` (CW degrees). The node view nests an `` in a rotated "rotor" inside an axis-aligned wrapper that reserves the rotated bounding box (so text reflows around it). The rotor's transform makes it a stacking context, so a selected `.image-node` is given a `z-index` above the header/footer layer to keep handles that poke into a margin grabbable. It has eight zoom-aware resize handles — corners aspect-locked, edges single-axis (width- or height-only) — plus a rotation-arrow grip, and shows a live "Width … × Height …" (cm) badge while resizing; resize deltas are un-rotated onto the image's own axes so handles track the pointer at any angle (drag math mirrors `tableRowResize.ts`; width cap = the cell or page-text-column, height cap = the page text height). `setImage` command inserts it; the toolbar button (ToolbarExpanded), drag-drop and paste (Editor.svelte) read the file via `FileReader` → data-URI and clamp the initial size to the page text box. A picture that arrives by **URL** (an `` pasted or dropped from a web page) is fetched into a data-URI right after the paste (`inlineRemoteImages`); one the site withholds is dropped with a message, since only a data-URI ever reaches a file. **Text wrap:** the `wrap` attr (`'inline'|'left'|'right'|'topBottom'|'through'`) makes the image a *floating* frame — `ImageToolbar.svelte` (a per-image floating toolbar wired in `Editor.svelte` like `TableToolbar`) sets the mode; `left`/`right` float the wrapper at its anchor paragraph (text flows on the open side via CSS `float`), `topBottom` is a **full-width `float`** (text only above/below), and `through` — Word's in-front-of / behind-text (`wp:wrapNone` + `behindDoc`), ODF's `style:wrap="run-through"` — leaves the flow entirely: `position: absolute` with no offsets keeps the static position it was anchored at, so it reserves neither width nor height, and `inFront` picks the side of the text it lands on. Read as a side float instead it reserved a near-column-wide picture's whole height, which cost the image fixture a page and ~50 reports. A text box in that mode always sits behind the text — the mode has no toolbar entry yet, so only a file can carry it in. All modes use `float` deliberately: an in-flow `display:block` on an inline-atom node view splits the paragraph's inline content into anonymous block boxes, which desyncs ProseMirror's inline view descriptor and makes it drop the following page-break spacer widget; a float is out-of-flow and avoids that. Dragging an **inline** image runs ProseMirror's native node move (the `dropCursor` plugin shows the caret). Dragging a **floating** image runs a custom drag (`ImageView.startReposition`) that **live re-anchors** the node to the text position under the cursor, so the float and text reflow in real time as you drag; it's rAF-throttled and only moves when the cursor's line changes (a float can't move within a line), and stays a single undo step (first move records history, later moves are `addToHistory:false` and get rebased on undo). A paragraph that contains an image paginates **atomically** (`pageBreaks.ts` — pushed whole to the next page, spacer placed before it) so a page break never splits the paragraph next to the image's float (where ProseMirror drops the spacer widget). **Wrap distance:** `wrapDist` is the gap the file keeps beside a float, in cm (Word's `distL`/`distR`, ODF's `fo:margin-left`/`-right` on the graphic style) — only the side the text flows on, since the other one is the frame's own offset. A frame that declares none reserves **nothing**, beside it or above/below it: probed, LibreOffice wraps flush against it, and a gap of our own put every figure in a text box 3mm too tall and broke a line a word early. `setImageWrap` gives a frame the file never floated the 0.32cm a word processor's own wrap command writes. **Vertical alignment (as-char):** `vAlign` carries ODF's `style:vertical-pos`/`-rel` pair for an inline frame — `null` (LibreOffice's `top`/`baseline` and Word's only mode) stands the frame on the text baseline, `middle`/`below` centre it on the baseline or hang it below, and `text-top`/`text-middle`/`text-bottom` measure against the character area instead (CSS `vertical-align: text-top`/`text-bottom`; nothing in CSS centres on that area, so `text-middle` uses 0.36em for half of ascent − descent, which Liberation Serif/Sans, Times and Arial share). `from-top` becomes `vAlign: 'offset'` and puts the frame's top `wrapOffsetY` cm below the baseline whatever the relation says — all probed against LibreOffice, whose formula images use `middle`/`text` and `from-top` almost exclusively. A line an as-char frame has to itself — nothing beside it but whitespace — is as tall as the frame or the block's line height, whichever is more, with **no text descent below it** (probed); `image.ts` gives such a frame an `image-line` class, since CSS cannot ask whether a line holds real text. Hard breaks are the only line boundaries the model shows, so the run between two of them is what gets tested — which covers the frame-break-caption idiom LibreOffice's own guides are full of; a frame alone on a *soft-wrapped* line is invisible from there. A frame that shares its line with text keeps the baseline and the descent. **Frame offsets:** `wrapOffset` is the frame's left edge in the text column (Word's `positionH` posOffset, ODF `svg:x`), rendered as the float's near/far margin against the live column vars — an indented anchor paragraph can't skew it — and shared with `textBox.ts` through `frameMargins`. A `topBottom` float spans the column, so its own x moves the *rotor* inside the wrapper instead. `wrapOffsetY` (`positionV`, `svg:y`) is the frame's top below the anchor paragraph's, drawn for `topBottom` only — no text sits beside such a frame, where a side float's top margin would push away lines Word keeps. Chromium places **every** line after a full-width float below it, even the ones the float's top margin leaves room for, so a frame set below the paragraph top is sunk behind that paragraph's text at import (`sinkOffsetFrames`, both importers) and `ImageView.sinkToOffset` measures what the lines above it already cover, adding only the rest. The **block after the anchor paragraph clears the band** (`imageLinePlugin` marks the block with `data-wrap-band`, an `editor.css` sibling rule clears the next one): a float only pushes lines that intersect it, so a short block (a heading) would slot into the clearance gap a prior side float opens above the band — both word processors start it below; a page-break spacer and the block holding a band-sharing partner frame are exempt. The marker is an attribute, not a `:has()` in the selector: with `:has()` left of `+`, Chromium restyled every element under `.tiptap` on each insertion there — a spacer, a split paragraph — 150 ms at 124 pages, twice per keystroke that moved a page break. Two `topBottom` frames set against **opposite ends** of nearby paragraphs (`wrapAlign`, from Word's `positionH` align / ODF `style:horizontal-pos`) share one band and float to their own sides instead: `pairAlignedFrames` (import/odt.ts, both importers) keeps the attr only for such a pair — a lone one reserves the whole band, which is what the wrap means — and scales a pair up to 15% wider than the column down to it, since Word lets the two overlap in the middle and two floats cannot. A frame Word puts *inside* running text still lands after it: browsers can't wrap text around a freely-positioned box (CSS Exclusions are unimplemented). Inline images stay as-char. Works inside table cells. Not available while editing a header/footer (the HF schema has no image node). Images live as data-URIs in the autosaved JSON; `autosave.ts` warns once if that exceeds the localStorage quota. - **`textBox.ts`** (`TextBox`) — an **inline** text box / basic shape carrying editable block content (`group: 'inline'`, `inline: true`, `content: '(paragraph|heading|bulletList|orderedList)+'`, `isolating`) — the frame both formats write, a character in a run that holds paragraphs. It rides a paragraph, so `inline` really is in the line, and a box reaches a table cell or a list item through their paragraphs, as one does in Word. Attrs: `width`/`height` (px @96dpi; height renders as **min-height** — content grows the box), `rotation` (CW deg), `wrap` (image's `WrapMode`), `shapeKind` (see the shape table below), `fillColor`/`strokeColor` (`null` = transparent/no border), `strokeWidthPt`. Padding is the fixed `TEXTBOX_PADDING_CM` (0.15cm). **Wrap** follows `ImageView.applyWrap` exactly: `inline` is an `inline-block` in the line, `left`/`right` are floats, `topBottom` a **full-width float** whose wrapper spans the column while `placeInBand` moves the rotor across it, `through` leaves the flow. Never `display:block` — that splits the paragraph's inline content into anonymous block boxes and ProseMirror drops the following page-break spacer. A paragraph holding a box paginates **atomically** (`pageBreaks.ts`), whichever way the box wraps. `TextBoxView` mirrors `ImageView` (wrapper reserving the rotated bbox → rotor carrying fill/stroke/rotation → `contentDOM`); the rotor is out of flow and auto-grows, so a ResizeObserver refits the wrapper. **Click model (Word-like):** `stopEvent`/`isFrameHit` claim mouse events on a ~6px frame ring (rotation-/zoom-aware) → NodeSelection + handles (the shared `image-*` handle classes/CSS); clicks further inside pass to ProseMirror (caret). The box is browser-`draggable` only while node-selected, so PM's native drag moves it without hijacking text selection. **Reaching the position beside the box:** a frame nobody is editing is an atom to the browser — a `contenteditable="false"` node decoration, dropped while the caret is in its text — and `ArrowLeft`/`ArrowRight` at the box's first/last text position step out to `before`/`after` the node. Without both, a box that starts its paragraph is a trap: there is no text position beside it, so the browser pulls the caret meant for the box's own place in the line into the frame and everything typed there lands inside it. The frame then has to place the caret itself on the click that enters it (`onFrameMouseDown`, `stopEvent`), since a non-editable one gets none from the browser. A nested `contentEditable` island is not the way — ProseMirror's `hasFocus` is `activeElement == view.dom`, and an island holding focus makes it read no selection at all. Commands: `insertTextBox()` (at the caret, cursor inside; refused inside a box), `setTextBoxAttrs()` (NodeSelection *or* cursor inside; helper `findTextBox`), `setTextBoxAlign()`. **Two guards** the schema cannot express live in the extension's plugin: a `filterTransaction` refuses a box inside a box — both word processors refuse it and neither format's writer has a place for one — and an `appendTransaction` grows a selection ending inside a box it did not start in out to the box's own bounds, since deleting such a range dissolves the frame and spills its text into the body. `TextBoxToolbar.svelte` (wired in `Editor.svelte` like the image toolbar, also shown while the caret is inside a box) sets wrap/shape/fill/stroke, the alignment and `textVertical` — the box's text running **top-to-bottom, right-to-left** (`writing-mode: vertical-rl` on the content area, which is all the browser needs). It round-trips as the style's own writing mode in ODF — a plain box is a Writer text frame (its `TbxFr` style chains to `Frame`; see `src/lib/export/CLAUDE.md`) and takes it in the graphic properties, a drawing *shape* only in its style's paragraph properties (both probed) — and Word's `w:bodyPr vert`; ODF's bottom-to-top and Word's `vert270` read as the same one direction the browser lays out. **DOCX autofit:** the shape body writes ``, which is what LibreOffice writes for the same frame — read as `spAutoFit` it gives the frame `fo:min-height="0"` and draws its text detached from the shape, over whatever follows (probed). The cost is the ODF leg's auto-grow: content taller than the declared height is clipped there, DOCX having no `fo:min-height`. **LibreOffice puts a `wrapTopAndBottom` anchor's band above its anchor paragraph** whatever the anchor says — probed against `posOffset` 0/635, `align top`, `relativeFrom` line/column, `allowOverlap`, and against a file LibreOffice exported itself; `wp:inline` is the one form it places right, and a band-wrapped box keeps `wp:anchor` anyway (Word is the reference for `.docx`). **Where it sits across the column:** only a **band-wrapped** box has a place of its own — `wrapAlign` (`null`/`center`/`right`, drawn by `placeInBand`), and the toolbar's three buttons show for that mode alone, dropping any `wrapOffset` when one is picked. A side wrap names the side itself; a box **in the line** is a character its paragraph places, exactly as a picture is, so the ordinary paragraph alignment does it and a second control on the frame toolbar would only be the same thing under a frame icon. That ordinary command needs one correction, though (`TextAlignInFrames`): a **selected** box is one node whose range covers its own paragraphs, so it would align the box's contents along with the block holding it — with the frame selected only that block is meant (`setTextBoxAlign`), while the caret inside the box still aligns that text. An as-char frame must name the **baseline pair** explicitly in ODF (`style:vertical-pos="top" style:vertical-rel="baseline"`): the `Frame` parent hangs a frame that names none below the line, where a style-less as-char frame (an image) stands on it — probed, and the measured difference between the two formats until it was written. ## A box in static HTML diff --git a/docs/architecture/pagination.md b/docs/architecture/pagination.md index d51db7a..0495910 100644 --- a/docs/architecture/pagination.md +++ b/docs/architecture/pagination.md @@ -124,6 +124,20 @@ it: the block is as tall as before, so nothing below it moved. A break inside th still forces the pass, since its line has rewrapped. The dom run (`tests/dom/run.mjs`) holds the keystroke budget and checks that a letter typed and taken back runs no pass. +**What a pass costs.** At the top of the dom run's 124-page document (Chromium) the +measuring and placing take ~40 ms — `docPosBeforeElement` is the largest part, ProseMirror +walks the parent's children for every position, so it is memoised per pass — and the +decoration dispatch a few ms. Everything above that was the browser's, and three things +each multiplied it: a **`:has()` left of a sibling combinator** under `.tiptap` makes +Chromium restyle every element under it (67 000 with the squiggles, ~150 ms) on each child +inserted there — a spacer, a split paragraph — so the band-clearing rule keys on an +attribute `image.ts` writes; a **live `Range` per text node** in `getLineRects` — a Range +is a document listener Chromium updates on every DOM removal, and a pass left hundreds +behind until the next collection, so one Range is reused; and TipTap's Placeholder +**hit-testing the viewport** twice per keystroke (`placeholder.ts` replaces it). The pass +after a split went from 410 to 150 ms, the keystroke that splits from 219 to 50 ms; in a +document of real words, where few squiggles split the text nodes, 47 and 18 ms. + **Layout constants** (must stay in sync between `pageBreaks.ts`, `Editor.svelte`, and `editor.css`): - `PAGE_HEIGHT = 1123px` (A4 portrait), `PAGE_GAP = 20px`, `CYCLE = PAGE_HEIGHT + PAGE_GAP = 1143px`. - Page height/width and margins are read **live** from CSS custom properties (`--user-page-height`, `--user-page-width`, `--user-margin-*`) so orientation/margin changes don't require new constants. `getCycle()` in `Editor.svelte` reads `--user-page-height` at runtime. diff --git a/package-lock.json b/package-lock.json index ab20441..d957283 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,6 @@ "@tiptap/extension-list-item": "^3.23.6", "@tiptap/extension-ordered-list": "^3.23.6", "@tiptap/extension-paragraph": "^3.23.6", - "@tiptap/extension-placeholder": "^3.23.6", "@tiptap/extension-strike": "^3.23.6", "@tiptap/extension-subscript": "^3.23.6", "@tiptap/extension-superscript": "^3.23.6", @@ -997,19 +996,6 @@ "@tiptap/core": "3.23.6" } }, - "node_modules/@tiptap/extension-placeholder": { - "version": "3.23.6", - "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.23.6.tgz", - "integrity": "sha512-8I6b2aevF74aLgymKMxbDxSLxWA2y+2dh0zZDeI8sRZ2m6WHHes+Kyuuwkq1HIPcR+ZLpbec74cmf6lcL/yvqQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/extensions": "3.23.6" - } - }, "node_modules/@tiptap/extension-strike": { "version": "3.23.6", "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.23.6.tgz", diff --git a/package.json b/package.json index 660b035..6fbd856 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,6 @@ "@tiptap/extension-list-item": "^3.23.6", "@tiptap/extension-ordered-list": "^3.23.6", "@tiptap/extension-paragraph": "^3.23.6", - "@tiptap/extension-placeholder": "^3.23.6", "@tiptap/extension-strike": "^3.23.6", "@tiptap/extension-subscript": "^3.23.6", "@tiptap/extension-superscript": "^3.23.6", diff --git a/src/lib/editor/extensions.ts b/src/lib/editor/extensions.ts index 0dcdde2..a5cb2df 100644 --- a/src/lib/editor/extensions.ts +++ b/src/lib/editor/extensions.ts @@ -25,7 +25,7 @@ import { Table, TableHeader, TableCell } from '@tiptap/extension-table'; import { ResizableTableRow } from './extensions/tableRow'; import History from '@tiptap/extension-history'; import { inNote } from './extensions/notes'; -import Placeholder from '@tiptap/extension-placeholder'; +import { Placeholder } from './extensions/placeholder'; import { t } from '../i18n/i18n.svelte'; import { PageBreaks } from './extensions/pageBreaks'; diff --git a/src/lib/editor/extensions/CLAUDE.md b/src/lib/editor/extensions/CLAUDE.md index c158fe9..28a7206 100644 --- a/src/lib/editor/extensions/CLAUDE.md +++ b/src/lib/editor/extensions/CLAUDE.md @@ -43,6 +43,7 @@ Five topics are large enough to have their own deep-dive — read the file befor - **`indexEntry.ts`** — one place marked for the alphabetical index: an inline **atom**, not a mark, because that is what both formats write (a point `text:alphabetical-index-mark`, a Word `XE` field). It carries the term, so the marked text and the word the index lists can differ, plus `key1` for the heading it files under. `indexRows` merges the marks into the index's rows — one per term, its pages de-duplicated and sorted, the terms sorted case-insensitively by locale. - **`bibliographyEntry.ts`** — one citation, an inline **atom** carrying the whole source record (`identifier`, `type`, and every remaining ODF field in one `fields` record, so a file's fields we have no UI for still round-trip). ODF puts that record on the mark itself, which is what lets the document travel without a database; Word keeps it in a custom-XML part and cites it by tag, so the same attrs feed both. The **citation style** (`utils/citationStyle.ts` — LibreOffice's cite-by-key, numbered, APA, MLA, Chicago) rides the bibliography index's node, which is where both formats keep it: a row is a token list, exactly what an ODF entry template is, so LibreOffice regenerates the same rows (it fills the types the document does not cite with its own default, so the import takes the first template reading as anything but `key`). Word gets the style as `b:Sources StyleName`. Each citation's label is derived, kept true by an `appendTransaction` off the undo stack like the caption numbers. `bibliographyRows` is the list: one row per source, **in document order** — LibreOffice's own default is document position, not alphabetical — reading "key: author, title, year", the shape its entry templates produce. `DOCX_SOURCE_TYPE`/`DOCX_BIB_FIELD` map onto Word's smaller set, and the fields it has no slot for survive only the ODF leg. - **`tableOfContents.ts`** — a generated index. The `index` attr picks the family: `toc` lists every heading down to `maxLevel` (ODF `text:outline-level`, Word's `TOC \o` range, default 5 — listing deeper than the file asks inflates the block by pages). `title: ''` is an index that deliberately has none (its heading is a separate paragraph, as Word and LibreOffice write it); only a missing title falls back to "Table of Contents". `levelStyles` is the named paragraph style each level's entries use (ODF's per-level entry template, which is what LibreOffice regenerates from — the cached body paragraphs point at automatic styles derived from it): the rows carry it as `data-style`, so the document stylesheet gives them the file's own indent, space-before and font, and `editor.css`'s 0.5cm-per-level fallback applies only to an index that names none. `figures`/`tables` instead list every caption paragraph counting that category (`caption.ts`), single-level as LibreOffice's are — ODF `text:illustration-index`/`text:table-index` (found by `text:caption-sequence-name`, not by outline level), Word the same TOC field with `\c "Figure"`. `bibliography` lists the sources cited (`bibliographyEntry.ts`) and is the one family that **never** has a page number or a leader — a bibliography row is the source and nothing else, which is how both word processors print one. Any other family drops both where the file says so (`pageNumbers: false` — Word's `TOC \n`, an ODF entry template naming no ``; the export writes the stop out with the number, which is the pair LibreOffice's own dialog switches together). +- **`placeholder.ts`** — the hint an empty document shows, as one node decoration on the empty block the caret is in. Replaces TipTap's Placeholder, whose plugin view hit-tests the viewport twice and dispatches after every keystroke that changes the document's size — 30 ms and an extra state update per key in a 124-page document, for viewport state its `showOnlyCurrent` mode never reads. - **`trailingNode.ts`** — keeps an empty paragraph after a trailing table, text box or columns section (they are isolating, so the caret would otherwise have nowhere to go). Also the trap at the start: Enter at the very first position of a table with nothing before it inserts a paragraph above and moves the caret there. - **`headerFooter.ts`** (`hfExtensions`) — the header/footer schema: `Document` constrained to one paragraph, the body's marks, `HardBreak`, `TextAlign`, and the `pageField.ts` atoms. No image, no date field, no table. - **`pageField.ts`** — inline atoms `pageNumber`/`pageCount`/`chapterField`, rendered as ``; `HeaderFooterLayer.svelte` patches each span's text to the real per-page value. `chapterField` is the running head (ODF `text:chapter`, Word `STYLEREF`): its `level` is the outline level to show, `text` the file's cached name. The page's chapter follows LibreOffice (probed): a **header** shows the one in force at the page's top (a heading counts once it opens a page — the `atTop` bit), a **footer** the last one begun on the page (`Editor.svelte` collects the heading→page map on each pagination pass, only when a zone shows one). diff --git a/src/lib/editor/extensions/headerFooter.ts b/src/lib/editor/extensions/headerFooter.ts index 0afe58a..73a33be 100644 --- a/src/lib/editor/extensions/headerFooter.ts +++ b/src/lib/editor/extensions/headerFooter.ts @@ -10,7 +10,7 @@ import Highlight from '@tiptap/extension-highlight'; import { TextStyle, FontFamily, FontSize } from '@tiptap/extension-text-style'; import HardBreak from '@tiptap/extension-hard-break'; import History from '@tiptap/extension-history'; -import Placeholder from '@tiptap/extension-placeholder'; +import { Placeholder } from './placeholder'; import TextAlign from '@tiptap/extension-text-align'; import { FontWeight } from './fontWeight'; import { FontColor } from './fontColor'; diff --git a/src/lib/editor/extensions/image.ts b/src/lib/editor/extensions/image.ts index 23cb734..2a1aeb5 100644 --- a/src/lib/editor/extensions/image.ts +++ b/src/lib/editor/extensions/image.ts @@ -311,6 +311,9 @@ function imageLineDecorations(doc: PMNode): DecorationSet { if (!node.isTextblock) return true; let images: number[] = []; let text = false; + // 'true': a full-width float rides this block, so its successor clears the band; + // 'anchored': every such float is page-anchored — the successor must not clear. + let band: 'true' | 'anchored' | null = null; const flush = () => { if (!text) for (const at of images) decos.push(Decoration.node(at, at + 1, { class: 'image-line' })); images = []; @@ -319,10 +322,15 @@ function imageLineDecorations(doc: PMNode): DecorationSet { node.forEach((child, offset) => { if (child.type.name === 'hardBreak') flush(); else if (child.type.name === 'image') { - if ((child.attrs.wrap ?? 'inline') === 'inline') images.push(pos + 1 + offset); + const wrap = child.attrs.wrap ?? 'inline'; + if (wrap === 'inline') images.push(pos + 1 + offset); + if (wrap === 'topBottom' && band !== 'true') band = child.attrs.anchorPage ? 'anchored' : 'true'; } else if (child.isText ? child.text?.trim() : true) text = true; }); flush(); + // The attribute editor.css keys the clearing on — an attribute, since a `:has()` + // there makes Chromium restyle the whole document on every insertion. + if (band) decos.push(Decoration.node(pos, pos + node.nodeSize, { 'data-wrap-band': band })); return false; }); return DecorationSet.create(doc, decos); diff --git a/src/lib/editor/extensions/pageBreaks.ts b/src/lib/editor/extensions/pageBreaks.ts index 5f45838..f3bbbf8 100644 --- a/src/lib/editor/extensions/pageBreaks.ts +++ b/src/lib/editor/extensions/pageBreaks.ts @@ -551,16 +551,24 @@ export const PageBreaks = Extension.create({ }; } + // Memoised per pass: a leaf is asked twice (its spacer, its page-top rule), and + // each answer walks the parent's children linearly on both sides of posAtDOM. + let posMemo = new Map(); function docPosBeforeElement(el: HTMLElement): number | null { + const memo = posMemo.get(el); + if (memo !== undefined) return memo; const parent = el.parentNode; if (!parent) return null; - const childIndex = Array.from(parent.childNodes).indexOf(el as ChildNode); - if (childIndex < 0) return null; + let childIndex = 0; + for (let n = parent.firstChild; n && n !== el; n = n.nextSibling) childIndex++; + let pos: number | null; try { - return editorView.posAtDOM(parent as Node, childIndex); + pos = editorView.posAtDOM(parent as Node, childIndex); } catch { - return null; + pos = null; } + posMemo.set(el, pos); + return pos; } // The display scale (.paper has `transform: scale()`): the ratio of .tiptap's @@ -660,6 +668,10 @@ export const PageBreaks = Extension.create({ return { docPos: preLeafDocPos(leaf.el), row: null }; } + // One Range for every measurement: a live Range is a document listener, and a pass + // that leaves hundreds behind slows every DOM removal until the next collection. + const lineRange = document.createRange(); + // Walks text nodes and inline images inside `el`, skipping any that live inside // a spacer widget, and returns one rect per visual line (in viewport coords). function getLineRects(el: HTMLElement): { top: number; bottom: number }[] { @@ -690,9 +702,8 @@ export const PageBreaks = Extension.create({ let textNode: Node | null; while ((textNode = walker.nextNode())) { if (!textNode.textContent || textNode.textContent.length === 0) continue; - const range = document.createRange(); - range.selectNodeContents(textNode); - for (const rect of Array.from(range.getClientRects())) { + lineRange.selectNodeContents(textNode); + for (const rect of Array.from(lineRange.getClientRects())) { if (rect.width > 0 && rect.height > 0) allRects.push(rect); } } @@ -850,14 +861,14 @@ export const PageBreaks = Extension.create({ // A leaf's border-box top within .tiptap, in document px. Summing offsetTop up // the offsetParent chain is unaffected by .paper's transform:scale, so it's the // same at every zoom; the chain ends at .tiptap so the origin is the page top. - function topWithin(el: HTMLElement): number { + function topWithin(el: HTMLElement, elStyle?: CSSStyleDeclaration): number { let top = 0; let node: HTMLElement | null = el; while (node && node !== dom) { // A block spaced above single is drawn half a leading above its flow // position (editor.css), and offsetTop reports where it is drawn — so the // shift comes back off, or the flow inherits a paint correction. - const cs = getComputedStyle(node); + const cs = node === el && elStyle ? elStyle : getComputedStyle(node); const shift = cs.position === 'relative' ? parseFloat(cs.top) : 0; top += node.offsetTop - (Number.isFinite(shift) ? shift : 0); node = node.offsetParent as HTMLElement | null; @@ -868,8 +879,8 @@ export const PageBreaks = Extension.create({ // A leaf's top in the undecorated document: the rendered top with the spacers // above it taken out and the space they swallowed put back, so a pass never // measures its own previous answer. Every leaf must be born through this. - function naturalTopOf(el: HTMLElement): number { - return topWithin(el) - cumulativeSpacerHeight + cumulativeDropped; + function naturalTopOf(el: HTMLElement, elStyle?: CSSStyleDeclaration): number { + return topWithin(el, elStyle) - cumulativeSpacerHeight + cumulativeDropped; } // The footnote anchors this leaf holds, offset from its own top with the @@ -1125,7 +1136,7 @@ export const PageBreaks = Extension.create({ leaves.push({ el: child, kind: isAtomic ? 'atomic' : 'splittable', - naturalTop: naturalTopOf(child), + naturalTop: naturalTopOf(child, cs), naturalHeight: Math.max(child.offsetHeight, floatBottom) - intraSpacerHeight + dropped, spaceAfter: inTableCell ? 0 : parseFloat(cs.marginBottom) || 0, spaceAbove, @@ -1173,6 +1184,7 @@ export const PageBreaks = Extension.create({ if (isUpdating || !editorView.dom.isConnected) return; isUpdating = true; editBlock = null; + posMemo = new Map(); const dom = editorView.dom; void dom.offsetHeight; // force reflow @@ -1597,10 +1609,11 @@ export const PageBreaks = Extension.create({ : null, naturalTop: leaf.naturalTop, naturalHeight: leaf.naturalHeight, - textPreview: (leaf.el.textContent ?? '').slice(0, 120), - intraSpacerHeights: Array.from( - leaf.el.querySelectorAll('[data-page-break-spacer]'), - ).map((sp) => sp.offsetHeight), + // Read when the snapshot is, not by every pass for every leaf. + get textPreview() { return (leaf.el.textContent ?? '').slice(0, 120); }, + get intraSpacerHeights() { + return Array.from(leaf.el.querySelectorAll('[data-page-break-spacer]')).map((sp) => sp.offsetHeight); + }, effectiveTop, effectiveBottom, pageOfTop: page, diff --git a/src/lib/editor/extensions/placeholder.ts b/src/lib/editor/extensions/placeholder.ts new file mode 100644 index 0000000..41a2042 --- /dev/null +++ b/src/lib/editor/extensions/placeholder.ts @@ -0,0 +1,37 @@ +import { Extension } from '@tiptap/core'; +import { Plugin } from '@tiptap/pm/state'; +import { Decoration, DecorationSet } from '@tiptap/pm/view'; + +// The hint an empty document shows (editor.css `p.is-editor-empty::before`). TipTap's +// Placeholder hit-tests the viewport twice per keystroke and dispatches the result — +// 30 ms in a long document — for a decoration that never leaves the one empty block. +export const Placeholder = Extension.create<{ placeholder: string | (() => string) }>({ + name: 'placeholder', + + addOptions() { + return { placeholder: '' }; + }, + + addProseMirrorPlugins() { + const { editor, options } = this; + return [ + new Plugin({ + props: { + decorations(state) { + const { doc } = state; + if (!editor.isEditable) return null; + let empty = true; + doc.forEach((n) => { if (!n.isTextblock || n.childCount !== 0) empty = false; }); + const $anchor = doc.resolve(state.selection.anchor); + if (!empty || $anchor.depth === 0) return null; + const text = typeof options.placeholder === 'function' ? options.placeholder() : options.placeholder; + return DecorationSet.create(doc, [Decoration.node($anchor.before(1), $anchor.after(1), { + class: 'is-empty is-editor-empty', + 'data-placeholder': text, + })]); + }, + }, + }), + ]; + }, +}); diff --git a/src/styles/editor.css b/src/styles/editor.css index 446fb81..a4b2785 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -1,3 +1,6 @@ +/* No `:has()` left of `+` or `~` under .tiptap: Chromium then restyles every element under + .tiptap (67 000 in a long document, ~150 ms) whenever a child is inserted there — a page + spacer, a split paragraph. Mark the block with an attribute instead (image.ts). */ .editor { flex: 1; /* Top padding clears the toolbar island overlaying this scroll area (height set @@ -402,11 +405,10 @@ .paper .tiptap .image-node[data-wrap]:not([data-anchor-page]) { z-index: 1; } -/* No text beside a topBottom band means the next block too: a short one would slot - into the clearance gap above the sunk float. A spacer already starts a new page, - and a block holding the band-sharing partner frame (wrapAlign pair) must not clear. */ -.paper .tiptap :is(p, h1, h2, h3, h4, h5, h6, h7, h8, h9, h10, blockquote):has(.image-node[data-wrap='topBottom']:not([data-anchor-page])) - + :not([data-page-break-spacer], :has(.image-node[data-wrap='topBottom'])) { +/* No text beside a band means the next block too, or a short one would slot into the + clearance gap above the sunk float; a spacer starts a new page, and a block holding + the partner frame (wrapAlign pair) shares the band. image.ts writes the attribute. */ +.paper .tiptap [data-wrap-band='true'] + :not([data-page-break-spacer], [data-wrap-band]) { clear: both; } .paper .tiptap .image-rotor { diff --git a/tests/dom/run.mjs b/tests/dom/run.mjs index 007cadf..52a912c 100644 --- a/tests/dom/run.mjs +++ b/tests/dom/run.mjs @@ -237,6 +237,23 @@ try { const passes = await page.evaluate(() => window.__passes); check(passes === 0 && idle < 100, `a letter typed and taken back runs no pass and its pause is free (${passes} passes, ${Math.round(idle)} ms blocked at most)`); + + // The block after a band-wrapped frame clears it; one after a page-anchored frame does + // not. Keyed on an attribute image.ts writes (a `:has()` restyles the whole document). + const PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNwaDgAAAKEAYEml6crAAAAAElFTkSuQmCC'; + const frame = (attrs) => ({ type: 'image', attrs: { src: PNG, width: 120, height: 60, ...attrs } }); + const block = (...content) => ({ type: 'paragraph', content }); + const words = (t) => ({ type: 'text', text: t }); + await page.evaluate((d) => document.querySelector('.tiptap').editor.commands.setContent(d), { type: 'doc', content: [ + block(words('above')), block(words('with '), frame({ wrap: 'topBottom' })), block(words('below')), + block(frame({ wrap: 'topBottom', anchorPage: 1 })), block(words('after anchored')), + block(frame({ wrap: 'topBottom' })), block(frame({ wrap: 'topBottom' })), block(words('after two')), + ] }); + await settle(page, true); + const bands = await page.evaluate(() => Array.from(document.querySelectorAll('.tiptap > p'), + (p) => `${p.getAttribute('data-wrap-band') ?? '-'}/${getComputedStyle(p).clear}`).join(' ')); + check(bands === '-/none true/none -/both anchored/none -/none true/none true/none -/both', + `the block after a band frame clears it, after an anchored one it does not (${bands})`); } catch (err) { check(false, `dom run threw: ${err.message ?? err}`); } finally { diff --git a/tests/unit/placeholder.test.ts b/tests/unit/placeholder.test.ts new file mode 100644 index 0000000..2721f7d --- /dev/null +++ b/tests/unit/placeholder.test.ts @@ -0,0 +1,47 @@ +// The empty-document hint rides the one empty block the caret is in and leaves with +// the first character. A small schema, so the decoration reaches the DOM in jsdom. +import { describe, it, expect } from 'vitest'; +import { Editor } from '@tiptap/core'; +import Document from '@tiptap/extension-document'; +import Paragraph from '@tiptap/extension-paragraph'; +import Text from '@tiptap/extension-text'; +import { Placeholder } from '../../src/lib/editor/extensions/placeholder'; + +function makeEditor(content?: object) { + const el = document.createElement('div'); + document.body.appendChild(el); + return new Editor({ + element: el, + extensions: [Document, Paragraph, Text, Placeholder.configure({ placeholder: () => 'Start typing…' })], + content, + }); +} + +const hint = (editor: Editor, nth = 0) => { + const p = editor.view.dom.querySelectorAll('p')[nth]; + return p.classList.contains('is-editor-empty') ? p.getAttribute('data-placeholder') : null; +}; + +describe('the placeholder', () => { + it('shows on the empty document and leaves with the first character', () => { + const editor = makeEditor(); + expect(hint(editor)).toBe('Start typing…'); + editor.view.dispatch(editor.state.tr.insertText('a', 1)); + expect(hint(editor)).toBeNull(); + editor.view.dispatch(editor.state.tr.delete(1, 2)); + expect(hint(editor)).toBe('Start typing…'); + editor.destroy(); + }); + + it('follows the caret between empty blocks, and stays off a document with text', () => { + const editor = makeEditor({ type: 'doc', content: [{ type: 'paragraph' }, { type: 'paragraph' }] }); + editor.commands.focus(3); + expect([hint(editor, 0), hint(editor, 1)]).toEqual([null, 'Start typing…']); + editor.commands.focus(1); + expect([hint(editor, 0), hint(editor, 1)]).toEqual(['Start typing…', null]); + editor.commands.setContent({ type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'x' }] }, { type: 'paragraph' }] }); + editor.commands.focus(4); + expect([hint(editor, 0), hint(editor, 1)]).toEqual([null, null]); + editor.destroy(); + }); +});