perf: the load passes batch their field work and flow a section whole
Cross-references resolve in one batch per pass, the index reads every leader gap before filling one, and a zone's tabs lay out with the other zones' — and only where a field asks for the page count or the chapter map. List marker and list style decorations are rebuilt only for a transaction reaching into a list; the unsaved dot compares documents structurally, and the autosave serializes when it writes rather than per keystroke. The columns flow applies every op it can judge in a pass, so a section pages in one pass. Two long files that took 33 s and 20 s now settle in 9 s and 2 s.
This commit is contained in:
1 parent
3af5a00fed
commit
da857fcbb8
16 files changed
+478
-230
No files matched your search
@@ -9,6 +9,6 @@ list markers, paragraph boxes, date/time fields and cross-references.
|
||||
- **`tabStops.ts`** (`TabStops`) — per-paragraph tab stops (Word's `w:tabs`, ODF `style:tab-stops`). Global attr `tabStops` on paragraph/heading, rendered as `data-tab-stops`; canonical string `'<cm><align code>'` per stop, `;`-separated (`'6c;12r;16d'`, codes `l|c|r|d`), parsed only by the exported `parseTabStops`/`formatTabStops`. Positions are **cm from the left text margin** — the origin Word and ODF share, verified by probe (a Word stop at 100mm in a 30mm-indented paragraph stays 100mm in ODF and LibreOffice renders it there), so neither I/O side shifts them by the paragraph indent. **Rendering**: CSS only has the fixed `tab-size` grid, so a rAF measuring plugin (shaped like `pageBreaks.ts`, whose `FORCE_PAGE_RECALC` it both listens to and fires) computes each tab's advance from `coordsAtPos` against `.tiptap`'s content edge and emits `Decoration.inline(tab, tab+1, {style:'tab-size:0;margin-left:Npx'})` — `tab-size:0` collapses the tab's own advance, the inline margin supplies the exact width, so no `display` change and no baseline/line-height side effects. The margin is on the **left** because the gap is the tab's own advance: as `margin-right` a caret placed after the tab rendered before the gap, i.e. at the pre-tab x until the next keystroke. The pen is read from the DOM only at the **first tab of a line** (side −1, the end of the content before it) and then **carried along the line**: stop + the natural width of the segment that follows it. Reading each tab off the DOM instead measures the advance the *previous* pass gave the tab before it, so the layout takes another pass to settle — and on a long document, where pagination keeps moving lines for seconds, it may never get a stable one; the Math Guide's contributor columns landed 18mm out in one run of three. `nextStop` takes the first custom stop right of the tab, else leaves the tab undecorated (past the last custom stop the CSS grid already does the right thing, which is also why custom stops suppress the default ones to their left). A **negative `indentFirst` implies a stop at the block's indent** (the hanging-indent stop). Centre/right/decimal take back the segment up to the next tab, measured by `rangeWidth` as the **extent of each line it covers**, added up — a range crossing an inline element yields a rect for the element's box *and* for the text inside it, and adding those counts the text twice. The scale comes from the editor's **width**: `offsetHeight` is rounded, and a one-line zone is short enough for that to be half a percent. Passes converge (one more tab settles per pass, `MAX_PASSES`), since a stop can rewrap the line. The measured layout is kept as a string key and a pass matching it dispatches nothing — but that key alone is **not** enough to decide the decorations are still applied: replacing the document maps them all away, so the guard also compares the live decoration count, or two documents with the same tab layout (the two DIN 5008 forms, an undo, a reopened file) would leave every tab on the CSS grid. **UI**: `Ruler.svelte`. A **header/footer zone** carries stops too — the left⇥centre⇥right layout rides on them — but an inactive one is static `generateHTML` output no plugin reaches, so `layOutZoneTabs` measures it straight on the DOM (`HeaderFooterLayer.svelte`, `use:layOutTabs`), wrapping each tab in a span of its own. Advances are layout px, so the zoom transform doesn't invalidate them. A decimal stop there behaves as right. Three things that zone needs and the body doesn't: the wrap marker is matched with `hasAttribute` (its value is `''`, which reads as falsy, and every pass nested another span with its own advance), the segment is measured with wrapping off (one already wrapped reads short by the space its break swallowed, which keeps it wrapped), and a stop past the line is drawn at the line's end — LibreOffice's own footer style puts its right stop at 18cm in a 17cm column. The body clamps the same way. A stop may carry a **leader** (`w:tab w:leader` / `style:leader-text`, one of `. - _ ·`), drawn as a `::before` clipped to the measured gap so the fill stays out of the document's text; on the ODF side odf-kit has no leader option and mints one automatic style per option set, so the leader rides `style:type` behind a `LEAD` sentinel that `applyTabLeaders` unpacks — a bare position+type lookup would give two stops that agree on both the same leader. Past the last custom stop the grid is the document's own **default tab interval** (`storage/tabInterval.ts`, 1.25cm for a new document — LibreOffice's; Word's is 1.27): imported from `w:defaultTabStop` / the paragraph default-style's `style:tab-stop-distance`, applied as the `--tab-interval` CSS var, and **always** written back on export. A file that declares none falls back to its format's own value — measured, LibreOffice puts a bare `A⇥X` at **2cm**, not at its UI default — so leaving ours implied would render differently everywhere Line truncated
|
||||
|
||||
A **run of tabs wider than the line** continues on the next one with the grid starting over, as LibreOffice lays it out; Chromium instead hangs the leftover tabs and starts the next line at the margin. `runBreaks` walks the run analytically from the pen *before* it — the one thing the fix can't move, so the answer is the same whether or not the break is already in place — and emits a `<br>` widget where the line runs out. Only runs of two or more are considered: reading a block's geometry forces a reflow, and doing it for every tab in a 48-page document slowed pagination enough to skew the parity harness.
|
||||
- **`listMarker.ts`** (`ListMarker`) — the marker's **family, weight, slant, size and color**, taken from the item's **first text portion** (`markerFormat`): a mark lives inside the item's paragraph and never reaches `::marker`, which only inherits the `li`'s own font, so a bold numbered heading would keep a light number. A **character style** on that portion is resolved through the registry (`charStyleProps`, handed in by the caller: the plugin from its `sheet` option, the exporters from their `exportSheet`); what a **named paragraph style** provides reaches the marker through `styleCss`'s `li:has(> …)` rule instead. First portion, not the whole line, is LibreOffice's own numbering rule (verified by probe: bold-then-plain gets a bold number, plain-then-bold does not; Word instead takes the paragraph mark). **Rendering**: a node decoration puts `--marker-family/-weight/-style/-size/-color` on **every** `listItem` — they inherit, so an item without its own format resets them to `initial` (the guaranteed-invalid value, so the `var(…, inherit)` in `editor.css` falls back; `inherit` would pull the parent item's value in), and `editor.css` has one rule reading them on the item block's `::before` — which is what **draws** the marker: `::marker` cannot be positioned, and Chromium sets it flush against the text, ~4mm right of the level's hanging indent (`LIST_HANGING_CM` 0.635cm, what both exports write). The pseudo is **floated** into that indent (`min-width` the indent, negative `margin-left`): in flow it grew every list line by a pixel, and absolutely positioned a label wider than the indent — `1.1.1.`, `viii.` — printed over the text instead of pushing it. Word and LibreOffice push too, on to the next tab stop; the float stops flush after the label. `list-style: none` drops only the marker box, so the `list-item` counter and `<ol start>` keep numbering, and `content: counter(list-item, …)`/`counters(list-item, '.')` per `data-eff-list-style` replaces `list-style-type`; a `bulletChar` rides `--bullet` (an `attr()` inside the pseudo would read the block, not the list). `export/pdf.ts` switches the pseudo off and injects the same marker as a real span, since html2canvas cannot paint counters. The bullet shim keeps the symbol font first and takes `--marker-family` as its fallback. The pass also re-runs on `FORCE_PAGE_RECALC`, so editing a character style repaints the markers. **Export**: both formats carry marker formatting **per level**, so `listMarkerFormat` returns one only when *every* item of the list agrees — ODF mints a **named** `style:family="text"` style **in styles.xml** and points the level definition at it via `text:style-name` (`collectListMarkerFormats`/`applyListMarkerFormats`) — LibreOffice ignores the reference when it resolves to an automatic style in content.xml (probed), DOCX writes the level's `w:rPr` (`markerRunProps` → `ILevelsOptions.style.run`). A **mixed** list gets no level format on purpose: LibreOffice then applies its first-portion rule and matches the editor, Word falls back to the paragraph mark (which only carries the size). Import needs nothing — the runs already carry the formatting. Nested lists that odf-kit restyles (`applyNestedListTypes`) and in-cell lists keep a plain marker. A character style **inside a list item** is handled by an ODF pre-pass (`bakeListCharStyles`) instead: odf-kit's list builder reads marks itself and knows nothing about `charStyle`, and `applyRuns` never sees a list paragraph, so the pre-pass does both of its jobs — bake the resolved props into direct marks (which is what gets the run a `<text:span>` at all) and prefix the `CST` sentinel, so `applyCharacterStyles` re-points that span at the named style like everywhere else. Formatting and name both round-trip. `tests/unit/list-marker.test.ts` covers the four LibreOffice cases, the reset, and both export legs. **Label alignment** (`markerAlign` on the list node, Word's `w:lvlJc`, ODF's `fo:text-align` on the level properties): `right` sets the label's *far* end against the hanging indent, so a wide number — the Roman numerals Word's built-in numberings use it for — grows into the margin instead of over the text. `tests/unit/marker-align.test.ts` round-trips it.
|
||||
- **`listMarker.ts`** (`ListMarker`) — the marker's **family, weight, slant, size and color**, taken from the item's **first text portion** (`markerFormat`): a mark lives inside the item's paragraph and never reaches `::marker`, which only inherits the `li`'s own font, so a bold numbered heading would keep a light number. A **character style** on that portion is resolved through the registry (`charStyleProps`, handed in by the caller: the plugin from its `sheet` option, the exporters from their `exportSheet`); what a **named paragraph style** provides reaches the marker through `styleCss`'s `li:has(> …)` rule instead. First portion, not the whole line, is LibreOffice's own numbering rule (verified by probe: bold-then-plain gets a bold number, plain-then-bold does not; Word instead takes the paragraph mark). **Rendering**: a node decoration puts `--marker-family/-weight/-style/-size/-color` on **every** `listItem` — they inherit, so an item without its own format resets them to `initial` (the guaranteed-invalid value, so the `var(…, inherit)` in `editor.css` falls back; `inherit` would pull the parent item's value in), and `editor.css` has one rule reading them on the item block's `::before` — which is what **draws** the marker: `::marker` cannot be positioned, and Chromium sets it flush against the text, ~4mm right of the level's hanging indent (`LIST_HANGING_CM` 0.635cm, what both exports write). The pseudo is **floated** into that indent (`min-width` the indent, negative `margin-left`): in flow it grew every list line by a pixel, and absolutely positioned a label wider than the indent — `1.1.1.`, `viii.` — printed over the text instead of pushing it. Word and LibreOffice push too, on to the next tab stop; the float stops flush after the label. `list-style: none` drops only the marker box, so the `list-item` counter and `<ol start>` keep numbering, and `content: counter(list-item, …)`/`counters(list-item, '.')` per `data-eff-list-style` replaces `list-style-type`; a `bulletChar` rides `--bullet` (an `attr()` inside the pseudo would read the block, not the list). `export/pdf.ts` switches the pseudo off and injects the same marker as a real span, since html2canvas cannot paint counters. The bullet shim keeps the symbol font first and takes `--marker-family` as its fallback. The pass also re-runs on `FORCE_PAGE_RECALC`, so editing a character style repaints the markers. **Export**: both formats carry marker formatting **per level**, so `listMarkerFormat` returns one only when *every* item of the list agrees — ODF mints a **named** `style:family="text"` style **in styles.xml** and points the level definition at it via `text:style-name` (`collectListMarkerFormats`/`applyListMarkerFormats`) — LibreOffice ignores the reference when it resolves to an automatic style in content.xml (probed), DOCX writes the level's `w:rPr` (`markerRunProps` → `ILevelsOptions.style.run`). A **mixed** list gets no level format on purpose: LibreOffice then applies its first-portion rule and matches the editor, Word falls back to the paragraph mark (which only carries the size). Import needs nothing — the runs already carry the formatting. Nested lists that odf-kit restyles (`applyNestedListTypes`) and in-cell lists keep a plain marker. A character style **inside a list item** is handled by an ODF pre-pass (`bakeListCharStyles`) instead: odf-kit's list builder reads marks itself and knows nothing about `charStyle`, and `applyRuns` never sees a list paragraph, so the pre-pass does both of its jobs — bake the resolved props into direct marks (which is what gets the run a `<text:span>` at all) and prefix the `CST` sentinel, so `applyCharacterStyles` re-points that span at the named style like everywhere else. Formatting and name both round-trip. `tests/unit/list-marker.test.ts` covers the four LibreOffice cases, the reset, and both export legs. Both this set and `listStyle.ts`’s are rebuilt only for a transaction reaching into a list (`touchesList` — a step by its own `from`/`to` or `pos`, since a mark step’s position map is empty, and by what its map moved) or for the stylesheet’s own signal (`SHEET_CHANGED`, dispatched by `Editor.svelte`’s stylesheet effect); anything else maps the old decorations forward, so an edit anywhere in a long document no longer walks it whole. `tests/unit/list-marker-rebuild.test.ts` covers which transactions count. **Label alignment** (`markerAlign` on the list node, Word's `w:lvlJc`, ODF's `fo:text-align` on the level properties): `right` sets the label's *far* end against the hanging indent, so a wide number — the Roman numerals Word's built-in numberings use it for — grows into the margin instead of over the text. `tests/unit/marker-align.test.ts` round-trips it.
|
||||
- **`dateTimeField.ts`** (`DateTimeField`) — inline atom for an inserted date/time field (Word's "Insert Date and Time"). Attrs: `kind` (`'date'|'time'`), `format` (a catalog key), `fixed`, `value` (ISO local datetime captured at insert). A **fixed** field renders its stored `value`; an **auto** field renders the *current* moment on every load (`renderHTML`/`renderText` via `dateTimeFieldText`) — no plugin, Word/LibreOffice likewise only refresh on open. As an inline atom with no children it can't inherit the surrounding run's font from a sibling span, so it **carries that run's marks** on the node: insert adopts the cursor's stored marks, and both importers attach the field run's resolved marks (else it falls back to the editor default font). Command `insertDateTimeField({kind,format,fixed})`. Formats live in `utils/dateTime.ts` as a **token model** (one `Token[]` per format) so the three consumers stay in agreement: `renderFormat` (locale display via `Intl`), `odfNumberStyle` (`<number:date-style>`/`<number:time-style>` body), `docxPicture` (Word field switch). `DateTimePicker.svelte` (a dropdown next to `SpecialCharPicker` in `ToolbarExpanded.svelte`) lists live samples + an "update automatically" checkbox. Not in the header/footer schema (there a `<text:date>`/DATE field stays plain text). **ODF**: `replaceDateTimeFields`/`applyDateTimeFields` (sentinel U+E00A, mirrors images) emit `<text:date text:date-value text:fixed style:data-style-name>`/`<text:time text:time-value …>` + a minted per-format number style (language from the export doc language; `ensureNumberNamespace` adds `xmlns:number`); import matches the referenced number style back to a format via `parseNumberStyleTokens` + `matchFormat` (unknown ⇒ keep the shown text). **DOCX**: auto → `SimpleField` `DATE`/`TIME \@ "picture"` with a cached run; fixed → a plain text run (Word has no fixed-date field); import maps a `DATE`/`TIME` `fldSimple`/complex field's picture back to a format (`dateTimeFieldFromInstr`).
|
||||
- **`bookmark.ts`** (`Bookmark`) + **`crossReference.ts`** (`CrossReference`) — a named range of text and the references pointing at it. A bookmark is a **mark**, not a point node: both formats store a range, and only a range lets a `REF` recompute its text. It carries one `name` (`data-bookmark`), is `inclusive: false`, and names are sanitized to Word's rules on insert (`sanitizeBookmarkName`: `[A-Za-z0-9_.]`, no leading digit, 40 chars) — which also keeps the ODF sentinel pass free of XML escaping. A mark type excludes itself, so **overlapping ranges collapse to the outermost** and both importers keep the first. Word's `_GoBack` and every point bookmark (ODF `<text:bookmark/>`, a zero-length Word range) are dropped — there is no range to mark. `bookmarks(doc)`/`findBookmark`/`bookmarkNames` are the shared lookups (dialogs, node view, `Editor.svelte`'s click handler). A cross-reference is an inline atom with `name`, `format` (`'text'|'page'`) and `text`, the last being the **cached display** exactly as a Word field caches its result. Its node view resolves live on each `pm-pagecount` like `TocView` does — the bookmark's own text, or its page via `pageOfElement`/`readVerticalMargins` (both from `pageBreaks.ts`, shared with the TOC) — and **leaves the cached text alone when the target is gone**, which is what a reference to a deleted bookmark shows. Ctrl/Cmd+click on a reference or on an internal `#name` link jumps to the bookmark and selects it. Not in the header/footer schema. **ODF**: `replaceBookmarks` splices the `BMS`/`BME` sentinels (U+E013/U+E014) into the run text and swaps each `crossRef` for an `XRF` run (U+E015) — splicing into the text, not into a run emitter, is what makes them ride every odf-kit path, cells and custom paragraphs included; `applyBookmarks` rewrites all three to `<text:bookmark-start/>`, `<text:bookmark-end/>` and `<text:bookmark-ref text:reference-format>`. **DOCX**: the `docx` package covers both natively — `Bookmark` around the marked runs, `SimpleField` `REF`/`PAGEREF … \h` with the cached result, `InternalHyperlink` for a `#name` href. A range spanning paragraphs is emitted per paragraph (Word tolerates the repeated name). Import tracks open ranges in `Ctx` across both the block and the inline walk (Word writes a `w:bookmarkStart` beside paragraphs as well as beside runs) and reads `REF`/`PAGEREF` out of the `fldChar` state machine and `w:fldSimple` alike, including a field Word never resolved (no `w:separate`, so no cached result — the node view fills it in).
|
||||
- **`bookmark.ts`** (`Bookmark`) + **`crossReference.ts`** (`CrossReference`) — a named range of text and the references pointing at it. A bookmark is a **mark**, not a point node: both formats store a range, and only a range lets a `REF` recompute its text. It carries one `name` (`data-bookmark`), is `inclusive: false`, and names are sanitized to Word's rules on insert (`sanitizeBookmarkName`: `[A-Za-z0-9_.]`, no leading digit, 40 chars) — which also keeps the ODF sentinel pass free of XML escaping. A mark type excludes itself, so **overlapping ranges collapse to the outermost** and both importers keep the first. Word's `_GoBack` and every point bookmark (ODF `<text:bookmark/>`, a zero-length Word range) are dropped — there is no range to mark. `bookmarks(doc)`/`findBookmark`/`bookmarkNames` are the shared lookups (dialogs, node view, `Editor.svelte`'s click handler). A cross-reference is an inline atom with `name`, `format` (`'text'|'page'`) and `text`, the last being the **cached display** exactly as a Word field caches its result. Every reference of an editor resolves in one batch (`CrossRefBatch`) on each `pm-pagecount` and on a change to any of them — one bookmark scan, every page read before any text is written, one transaction for what changed; the bookmark's own text, or its page via `pageOfElement`/`readVerticalMargins` (both from `pageBreaks.ts`, shared with the TOC) — and **leaves the cached text alone when the target is gone**, which is what a reference to a deleted bookmark shows. Ctrl/Cmd+click on a reference or on an internal `#name` link jumps to the bookmark and selects it. Not in the header/footer schema. **ODF**: `replaceBookmarks` splices the `BMS`/`BME` sentinels (U+E013/U+E014) into the run text and swaps each `crossRef` for an `XRF` run (U+E015) — splicing into the text, not into a run emitter, is what makes them ride every odf-kit path, cells and custom paragraphs included; `applyBookmarks` rewrites all three to `<text:bookmark-start/>`, `<text:bookmark-end/>` and `<text:bookmark-ref text:reference-format>`. **DOCX**: the `docx` package covers both natively — `Bookmark` around the marked runs, `SimpleField` `REF`/`PAGEREF … \h` with the cached result, `InternalHyperlink` for a `#name` href. A range spanning paragraphs is emitted per paragraph (Word tolerates the repeated name). Import tracks open ranges in `Ctx` across both the block and the inline walk (Word writes a `w:bookmarkStart` beside paragraphs as well as beside runs) and reads `REF`/`PAGEREF` out of the `fldChar` state machine and `w:fldSimple` alike, including a field Word never resolved (no `w:separate`, so no cached result — the node view fills it in).
|
||||
@@ -145,4 +145,4 @@ document of real words, where few squiggles split the text nodes, 47 and 18 ms.
|
||||
|
||||
## Multi-column sections (`columns.ts` + `columnsFlow.ts`)
|
||||
|
||||
- **`columns.ts`** (`Columns`) + **`columnsFlow.ts`** (`ColumnsFlow`) — multi-column (newspaper) layout with **cross-page flow** and **Word's sequential fill order**: column 1 fills to the page bottom, then column 2, then the next page — via per-fragment node decorations carrying `height:…;column-fill:auto` (full page slot for fragments with a continuation; content height + `OPEN_HEIGHT_SLACK_PX` for the open last fragment of a document-final chain, so typing grows column 1 first without one-frame flashes into column 2). A chain followed by real content keeps its last fragment undecorated → CSS `column-fill: balance` (Word balances the end of a continuous section) — which only works because the paragraph mark's `::after` is `position: static` inside `.columns-node` (`editor.css`): the absolutely-positioned mark made Chromium give up balancing entirely and stack every block into column 1 (probed; a list's absolute `::before` markers do not trigger this). Because the box height is the plugin's own decoration, overflow/join decisions use content-based estimates (`contentHeightPx` = block rects + collapsed margins), not the rendered height. A section is a block region (`content: '(paragraph|heading|bulletList|orderedList)+'`) rendered as CSS multi-column (`column-count`/`column-gap` inline via `renderHTML`; no NodeView, no visual affordance — it must read as plain flowing text, so NOT isolating/selectable: selection, Backspace, and joins cross its boundary). Attrs: `count` (2–3) and `gapCm` (default `DEFAULT_COLUMN_GAP_CM` 0.5, clamp 0–5). Own group like textBox (Document is `'(block | textBox | columns)+'`). **Chain model:** the document may hold a section as several *adjacent equal-attr fragments* — the user-visible section is the maximal run (`columnsChain`); commands, export, and import all treat a chain as one section. **Cross-page flow (`columnsFlow.ts`):** a rAF layout pass (mirroring pageBreaks' geometry via the exported `readVerticalMargins`) keeps the fragmentation in sync with the page grid — an overflowing fragment is **split** at a block boundary so the maximal prefix fills its page (balanced-height estimate = Σ block `getClientRects` heights / count), and a fragment with room **joins** the adjacent continuation back (the split pass then re-splits at the right point). One op per pass, `addToHistory: false` (layout-only; undo of user edits just re-flows) — **but prosemirror-history rebases its own steps over that op**, so an undo reaching back past one can leave a wrap standing that nothing can undo; `tests/monkey/` counts the flow's ops (`columnsFlowTx`) and leaves the history unchecked for such a run rather than assert a ceiling the design carries, composition-guarded, pass-budgeted (`MAX_FLOW_PASSES`, reset per external change). **pageBreaks.ts cooperation:** a `.columns-node` leaf carries `columnsFragment` (block count + first-block fit estimate); on bottom overflow pageBreaks *waits for the flow* when the fragment is splittable and its first block fits the remaining space, pushes it whole when mid-page (even taller-than-page), and at a page top does nothing (`columns-line-split-pending` — pushing a page-tall fragment just moves the problem down forever). Both sides use the shared `COLUMNS_FIT_MARGIN_PX` for the first-block fit test — differing thresholds make split/push/join cycle at the boundary; the flow's join `HYSTERESIS_PX` must exceed the estimate error for the same reason. The **overflow** test carries the same kind of headroom (`SAFETY_PX`): the estimate averages the flow over the columns, but a decorated column ends on a whole line, so a fragment measuring exactly its slot spills once the decoration pins the height — without it the flow reads its own last answer, measuring "fits" undecorated and "overflows" decorated forever. Every split or join therefore also clears the decoration cache key (`dispatchFlow`): ProseMirror drops node decorations whose node was replaced, and a stale key leaves the fragment at its balanced height instead of its page slot. **Page boundaries cut mid-paragraph at line boundaries** (`splitParagraphInBlock`): the flow measures a paragraph's rendered lines (`groupLines` — document-order rects banded by *overlap*: a rect joins the line it overlaps by half the taller of the two, so a raised run like a verse number is no line of its own and no outsized rect swallows the line below it), converts the page's leftover content budget into a line count by summing the **real** top-to-top advances (`linesWithin` — a line carrying a raised run is taller than the rest, so one median advance for all of them runs ~15% short; the median stands in only where the tops reset at a column break), finds the boundary text position by probing **both** edges of the line's first rect and keeping the earlier position (`lineStartPos` — on a right-to-left line the logical start is the right edge, and `left + 1` alone keeps a word too many, which costs oneLine truncated
|
||||
- **`columns.ts`** (`Columns`) + **`columnsFlow.ts`** (`ColumnsFlow`) — multi-column (newspaper) layout with **cross-page flow** and **Word's sequential fill order**: column 1 fills to the page bottom, then column 2, then the next page — via per-fragment node decorations carrying `height:…;column-fill:auto` (full page slot for fragments with a continuation; content height + `OPEN_HEIGHT_SLACK_PX` for the open last fragment of a document-final chain, so typing grows column 1 first without one-frame flashes into column 2). A chain followed by real content keeps its last fragment undecorated → CSS `column-fill: balance` (Word balances the end of a continuous section) — which only works because the paragraph mark's `::after` is `position: static` inside `.columns-node` (`editor.css`): the absolutely-positioned mark made Chromium give up balancing entirely and stack every block into column 1 (probed; a list's absolute `::before` markers do not trigger this). Because the box height is the plugin's own decoration, overflow/join decisions use content-based estimates (`contentHeightPx` = block rects + collapsed margins), not the rendered height. A section is a block region (`content: '(paragraph|heading|bulletList|orderedList)+'`) rendered as CSS multi-column (`column-count`/`column-gap` inline via `renderHTML`; no NodeView, no visual affordance — it must read as plain flowing text, so NOT isolating/selectable: selection, Backspace, and joins cross its boundary). Attrs: `count` (2–3) and `gapCm` (default `DEFAULT_COLUMN_GAP_CM` 0.5, clamp 0–5). Own group like textBox (Document is `'(block | textBox | columns)+'`). **Chain model:** the document may hold a section as several *adjacent equal-attr fragments* — the user-visible section is the maximal run (`columnsChain`); commands, export, and import all treat a chain as one section. **Cross-page flow (`columnsFlow.ts`):** a rAF layout pass (mirroring pageBreaks' geometry via the exported `readVerticalMargins`) keeps the fragmentation in sync with the page grid — an overflowing fragment is **split** at a block boundary so the maximal prefix fills its page (estimate = the flow poured into one column / count — `flowSpans`, block rects plus collapsed margins, the same measure the overflow test uses: counted by rects alone, one block too many stays and moves down a pass at a time), and a fragment with room **joins** the adjacent continuation back (the split pass then re-splits at the right point). A pass fixes every boundary it can judge, first to last (`MAX_FLOW_OPS`): after an op only the op's own fragment and chained continuations are judged — a continuation lands at a page top, so a full page is its room wherever it renders at that moment — and any other fragment waits for the next pass, so a multi-page section pages in one pass. Every op is `addToHistory: false` (layout-only; undo of user edits just re-flows) — **but prosemirror-history rebases its own steps over that op**, so an undo reaching back past one can leave a wrap standing that nothing can undo; `tests/monkey/` counts the flow's ops (`columnsFlowTx`) and leaves the history unchecked for such a run rather than assert a ceiling the design carries, composition-guarded, pass-budgeted (`MAX_FLOW_PASSES`, reset per external change). **pageBreaks.ts cooperation:** a `.columns-node` leaf carries `columnsFragment` (block count + first-block fit estimate); on bottom overflow pageBreaks *waits for the flow* when the fragment is splittable and its first block fits the remaining space, pushes it whole when mid-page (even taller-than-page), and at a page top does nothing (`columns-line-split-pending` — pushing a page-tall fragment just moves the problem down forever). Both sides use the shared `COLUMNS_FIT_MARGIN_PX` for the first-block fit test — differing thresholds make split/push/join cycle at the boundary; the flow's join `HYSTERESIS_PX` must exceed the estimate error for the same reason. The **overflow** test carries the same kind of headroom (`SAFETY_PX`): the estimate averages the flow over the columns, but a decorated column ends on a whole line, so a fragment measuring exactly its slot spills once the decoration pins the height — without it the flow reads its own last answer, measuring "fits" undecorated and "overflows" decorated forever. Every split or join therefore also clears the decoration cache key (`dispatchFlow`): ProseMirror drops node decorations whose node was replaced, and a stale key leaves the fragment at its balanced height instead of its page slot. **Page boundaries cut mid-paragraph at line boundaries** (`splitParagraphInBlock`): the flow measures a paragraph's rendered lines (`groupLines` — document-order rects banded by *overlap*: a rect joins the line it overlaps by half the taller of the two, so a raised run like a verse number is no line of its own and no outsized rect swallows the line below it), converts the page's leftover content budget into a line couLine truncated
|
||||
+10
-7
@@ -164,7 +164,10 @@
|
||||
// Everything the file holds counts: the text, and the page setup, styles and zones
|
||||
// beside it, which is what the exporter is handed. Same beat as the word count.
|
||||
let dirty = $state(false);
|
||||
let cleanSum: number | null = null;
|
||||
// The text as saved, compared as a tree: every unchanged node is the very object the
|
||||
// saved tree holds, so the comparison is cheap however many pictures ride along.
|
||||
let cleanDoc: PmNode | null = null;
|
||||
let cleanRest = 0;
|
||||
let sumTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
// Whether there is a file to lose those changes from — a saved file makes the dot,
|
||||
// and the unload warning, follow the changes rather than the document's existence.
|
||||
@@ -172,15 +175,15 @@
|
||||
|
||||
// The page count rides in with the zones but is a layout result, not an edit: a
|
||||
// late-loading font repaginating the document must not mark it changed.
|
||||
function documentSum(): number {
|
||||
if (!editor) return 0;
|
||||
function restSum(): number {
|
||||
const [margins, orientation, hf, ...rest] = exportArgs();
|
||||
const { pageCount: _pages, ...zones } = hf;
|
||||
return fnv1a(JSON.stringify([editor.getJSON(), documentName, margins, orientation, zones, ...rest]));
|
||||
return fnv1a(JSON.stringify([documentName, margins, orientation, zones, ...rest]));
|
||||
}
|
||||
|
||||
function markSaved(): void {
|
||||
cleanSum = documentSum();
|
||||
cleanDoc = editor?.state.doc ?? null;
|
||||
cleanRest = restSum();
|
||||
dirty = false;
|
||||
}
|
||||
|
||||
@@ -191,13 +194,13 @@
|
||||
// The document the editor comes up with is the file's, taken here and not in the
|
||||
// timer: under a repagination the timer can be pushed out past the first edit,
|
||||
// which would make that edit the baseline.
|
||||
if (cleanSum === null) {
|
||||
if (cleanDoc === null) {
|
||||
markSaved();
|
||||
return;
|
||||
}
|
||||
sumTimer ??= setTimeout(() => {
|
||||
sumTimer = undefined;
|
||||
dirty = documentSum() !== cleanSum;
|
||||
dirty = !editor || !cleanDoc || !editor.state.doc.eq(cleanDoc) || restSum() !== cleanRest;
|
||||
}, 300);
|
||||
});
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
|
||||
import { MAX_PAGE_COLUMNS } from '../storage/theme';
|
||||
import { DEFAULT_HF_DISTANCES, HF_ZONE_KEYS, hfIsEmpty, hfUsesChapterField, type HfDoc, type HfZone, type HfDistances, type HfSet, type HfZoneKey } from '../storage/headerFooter';
|
||||
import { FORCE_PAGE_RECALC, PAGE_GAP, pageOfElement, readVerticalMargins, topInEditor, type TableBreakBand } from '../editor/extensions/pageBreaks';
|
||||
import { SHEET_CHANGED } from '../editor/extensions/listMarker';
|
||||
import { findBookmark } from '../editor/extensions/bookmark';
|
||||
import { recordTransaction, resetHistoryLog } from '../utils/historyLog.svelte';
|
||||
import { fitPagesZoom, wheelZoomFactor } from '../utils/zoom';
|
||||
@@ -130,10 +131,20 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
|
||||
return out;
|
||||
}
|
||||
|
||||
// Kept by value: a fresh array per pass would re-lay every zone's fields and tabs.
|
||||
let chapterKey = '';
|
||||
function refreshChapterStarts(): void {
|
||||
const next = wantsChapters ? collectChapterStarts() : [];
|
||||
const key = JSON.stringify(next);
|
||||
if (key === chapterKey) return;
|
||||
chapterKey = key;
|
||||
chapterStarts = next;
|
||||
}
|
||||
|
||||
// Pagination refreshes the starts; inserting a chapter field into a zone isn't a
|
||||
// pagination event, so collect them as soon as a zone asks for them.
|
||||
$effect(() => {
|
||||
chapterStarts = wantsChapters ? collectChapterStarts() : [];
|
||||
refreshChapterStarts();
|
||||
});
|
||||
|
||||
// The page border wraps the header/footer band too (both word processors draw it
|
||||
@@ -409,7 +420,7 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
|
||||
// A table style's fill/borders live in cell attrs, so an edited registry has to be
|
||||
// painted into the document — this effect is the choke point every change passes.
|
||||
ed.commands.refreshTableStyles();
|
||||
ed.view.dispatch(ed.state.tr.setMeta('addToHistory', false).setMeta(FORCE_PAGE_RECALC, true));
|
||||
ed.view.dispatch(ed.state.tr.setMeta('addToHistory', false).setMeta(FORCE_PAGE_RECALC, true).setMeta(SHEET_CHANGED, true));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1011,7 +1022,7 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
|
||||
if (typeof detail.docHeight === 'number') docHeightDoc = detail.docHeight;
|
||||
tableBandsDoc = detail.tableBreakBands ?? [];
|
||||
sectionStartPages = detail.sectionStartPages ?? [];
|
||||
chapterStarts = wantsChapters ? collectChapterStarts() : [];
|
||||
refreshChapterStarts();
|
||||
// The document height changed → resize the scaled scroll footprint.
|
||||
recomputeScaledSize();
|
||||
updateCurrentPage();
|
||||
@@ -1291,7 +1302,7 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
|
||||
} catch { /* ignore */ }
|
||||
},
|
||||
onUpdate: ({ editor: e, transaction }) => {
|
||||
saveDocument(e.getJSON());
|
||||
saveDocument(() => e.getJSON());
|
||||
const ui = transaction.getMeta('uiEvent');
|
||||
if (ui === 'paste' || ui === 'drop') void inlineRemoteImages(e);
|
||||
},
|
||||
|
||||
@@ -258,14 +258,25 @@
|
||||
return v === 'first' ? b.footerFirst : v === 'even' ? b.footerEven : b.footer;
|
||||
}
|
||||
|
||||
// Page, page count, the zone's HTML, the chapter map and the zone: everything the two
|
||||
// actions below re-run on (the map's identity changes with each pagination pass).
|
||||
// Page, the page count and chapter map (only where the zone shows them), its HTML and
|
||||
// the zone: everything the two actions below re-run on.
|
||||
type ZoneParams = [number, number, string, unknown, HfZone];
|
||||
|
||||
// The zone's tabs: static HTML no ProseMirror plugin reaches. The advances are layout
|
||||
// px, so only a content change invalidates them — not the zoom transform.
|
||||
// px, so only a content change invalidates them — not the zoom transform. The zones of
|
||||
// one flush lay out together, after their fields are patched: one layout, not one each.
|
||||
const tabQueue = new Set<HTMLElement>();
|
||||
function layOutTabs(node: HTMLElement, _params: ZoneParams) {
|
||||
const apply = () => layOutZoneTabs(node);
|
||||
const apply = () => {
|
||||
if (!tabQueue.size) {
|
||||
queueMicrotask(() => {
|
||||
const zones = [...tabQueue].filter((z) => z.isConnected);
|
||||
tabQueue.clear();
|
||||
layOutZoneTabs(zones);
|
||||
});
|
||||
}
|
||||
tabQueue.add(node);
|
||||
};
|
||||
apply();
|
||||
return { update: apply };
|
||||
}
|
||||
@@ -452,6 +463,8 @@
|
||||
{#each ['header', 'footer'] as const as zone}
|
||||
{#if !(interactive && hfActive === zone && editingPage === p)}
|
||||
{@const html = zoneHtml(zone, p)}
|
||||
{@const total = html.includes('data-page-field="count"') ? numPages : 0}
|
||||
{@const chapters = html.includes('data-page-field="chapter"') ? chapterStarts : null}
|
||||
<div
|
||||
class="hf-zone hf-{zone}"
|
||||
class:hf-empty={!html}
|
||||
@@ -460,8 +473,8 @@
|
||||
ondblclick={() => interactive && startEdit(zone, p)}
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
use:patchFields={[p, numPages, html, chapterStarts, zone]}
|
||||
use:layOutTabs={[p, numPages, html, chapterStarts, zone]}
|
||||
use:patchFields={[p, total, html, chapters, zone]}
|
||||
use:layOutTabs={[p, total, html, chapters, zone]}
|
||||
>
|
||||
{@html html}
|
||||
</div>
|
||||
|
||||
@@ -29,9 +29,11 @@ const OPEN_HEIGHT_SLACK_PX = 40;
|
||||
// Below this many lines of leftover room, a boundary block splits at the block
|
||||
// boundary instead of pulling a line or two over (not worth a paragraph split).
|
||||
const MIN_TAIL_LINES = 2;
|
||||
// Pass budget per external change: each pass performs one split/join, so a paste
|
||||
// spanning many pages needs several; bounded against estimate-driven oscillation.
|
||||
// Pass budget per external change: a pass fixes what it can judge, the next measures
|
||||
// the geometry it changed; bounded against estimate-driven oscillation. The op budget
|
||||
// within a pass pages a whole section in one go — a split per page.
|
||||
const MAX_FLOW_PASSES = 48;
|
||||
const MAX_FLOW_OPS = 64;
|
||||
|
||||
type Fragment = { pos: number; node: PMNode };
|
||||
export type LineRect = { left: number; right: number; top: number; height: number };
|
||||
@@ -71,20 +73,31 @@ function blockHeightPx(el: Element, scale: number): number {
|
||||
return h / scale;
|
||||
}
|
||||
|
||||
// Total content height of a fragment's blocks as poured into one column flow:
|
||||
// block rects plus the collapsed inter-block margins (computed style, unscaled).
|
||||
function contentHeightPx(children: Element[], scale: number): number {
|
||||
// Where each block starts and ends in the flow as poured into one column: block rects
|
||||
// plus the collapsed inter-block margins (computed style, unscaled). The split counts
|
||||
// blocks by the same measure the overflow test uses, or it leaves one block too many.
|
||||
function flowSpans(children: Element[], scale: number): { start: number[]; end: number[] } {
|
||||
const start: number[] = [];
|
||||
const end: number[] = [];
|
||||
let sum = 0;
|
||||
let prevMb = 0;
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const cs = getComputedStyle(children[i]);
|
||||
const mt = parseFloat(cs.marginTop) || 0;
|
||||
const mb = parseFloat(cs.marginBottom) || 0;
|
||||
sum += blockHeightPx(children[i], scale);
|
||||
if (i > 0) sum += Math.max(prevMb, mt);
|
||||
start.push(sum);
|
||||
sum += blockHeightPx(children[i], scale);
|
||||
end.push(sum);
|
||||
prevMb = mb;
|
||||
}
|
||||
return sum;
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
// Total content height of a fragment's blocks as poured into one column flow.
|
||||
function contentHeightPx(children: Element[], scale: number): number {
|
||||
const { end } = flowSpans(children, scale);
|
||||
return end.length ? end[end.length - 1] : 0;
|
||||
}
|
||||
|
||||
// The first rect of each rendered line, in document order — column 1's lines
|
||||
@@ -274,18 +287,26 @@ export const ColumnsFlow = Extension.create({
|
||||
return true;
|
||||
}
|
||||
|
||||
// One reflow step: fix the first out-of-sync boundary (merge a stray
|
||||
// joinPrev part, split an overflowing fragment, or pull a continuation
|
||||
// back) and dispatch. Convergence comes from re-running until quiescent.
|
||||
// One reflow pass: fix each out-of-sync boundary (merge a stray joinPrev part,
|
||||
// split an overflowing fragment, or pull a continuation back) and dispatch.
|
||||
// Convergence comes from re-running until quiescent.
|
||||
function reflow(): boolean {
|
||||
if (editorView.composing || !editorView.dom.isConnected) return false;
|
||||
const frags = fragments();
|
||||
if (!frags.length) return false;
|
||||
|
||||
const vm = readVerticalMargins(editorView.dom);
|
||||
const scale = getScale();
|
||||
let ops = 0;
|
||||
let fixedAt = -1;
|
||||
let i = 0;
|
||||
let frags = fragments();
|
||||
// An op changes the fragments; `again` keeps the index on the one it changed.
|
||||
const done = (again: boolean) => { ops++; frags = fragments(); if (again) { fixedAt = i; i--; } };
|
||||
|
||||
for (let i = 0; i < frags.length; i++) {
|
||||
pass: for (; i < frags.length && ops < MAX_FLOW_OPS; i++) {
|
||||
// After an op, only its own fragment and chained continuations are judged: a
|
||||
// continuation lands at a page top (a full page of room wherever it renders now),
|
||||
// anything else still sits where the layout before the op had left it.
|
||||
const continued = i > 0 && hasNextInChain(frags, i - 1);
|
||||
if (ops && !continued && i !== fixedAt) break;
|
||||
const { pos, node } = frags[i];
|
||||
|
||||
// A joinPrev paragraph inside a fragment (left over after a fragment
|
||||
@@ -299,7 +320,8 @@ export const ColumnsFlow = Extension.create({
|
||||
canJoin(editorView.state.doc, childPos)
|
||||
) {
|
||||
dispatchFlow(editorView.state.tr.join(childPos));
|
||||
return true;
|
||||
done(true);
|
||||
continue pass;
|
||||
}
|
||||
childPos += child.nodeSize;
|
||||
}
|
||||
@@ -307,13 +329,16 @@ export const ColumnsFlow = Extension.create({
|
||||
const el = editorView.nodeDOM(pos) as HTMLElement | null;
|
||||
if (!el || !el.classList.contains('columns-node')) continue;
|
||||
|
||||
const top = topWithin(el);
|
||||
const page = Math.floor(top / vm.cycle) + 1;
|
||||
const contentStart = (page - 1) * vm.cycle + vm.top;
|
||||
const contentEnd = contentStart + vm.contentHeight;
|
||||
// Mid-move (pageBreaks hasn't repositioned it yet) — measure next pass.
|
||||
if (top < contentStart - 0.5 || top >= contentEnd) continue;
|
||||
const available = contentEnd - top;
|
||||
let available = vm.contentHeight;
|
||||
if (!continued) {
|
||||
const top = topWithin(el);
|
||||
const page = Math.floor(top / vm.cycle) + 1;
|
||||
const contentStart = (page - 1) * vm.cycle + vm.top;
|
||||
const contentEnd = contentStart + vm.contentHeight;
|
||||
// Mid-move (pageBreaks hasn't repositioned it yet) — measure next pass.
|
||||
if (top < contentStart - 0.5 || top >= contentEnd) continue;
|
||||
available = contentEnd - top;
|
||||
}
|
||||
const count = Math.max(1, node.attrs.count as number);
|
||||
const children = Array.from(el.children).filter(
|
||||
(c) => !(c as HTMLElement).dataset?.pageBreakSpacer,
|
||||
@@ -321,7 +346,8 @@ export const ColumnsFlow = Extension.create({
|
||||
if (children.length !== node.childCount) continue;
|
||||
// The rendered box height is our own decoration, so overflow and room
|
||||
// are judged from the content itself (per-column share of the flow).
|
||||
const usedPerColumn = contentHeightPx(children, scale) / count;
|
||||
const spans = flowSpans(children, scale);
|
||||
const usedPerColumn = (spans.end[spans.end.length - 1] ?? 0) / count;
|
||||
|
||||
// The estimate averages the flow over the columns, but each column ends on
|
||||
// a whole line, so a fragment measuring exactly its slot still spills once
|
||||
@@ -338,31 +364,30 @@ export const ColumnsFlow = Extension.create({
|
||||
available >= vm.contentHeight - 1 &&
|
||||
splitParagraphInBlock(frags[i], children, 0, count * (available - SAFETY_PX), scale, true)
|
||||
) {
|
||||
return true;
|
||||
done(false);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (node.childCount < 2) continue;
|
||||
let sum = 0;
|
||||
let k = 0;
|
||||
for (let j = 0; j < children.length - 1; j++) {
|
||||
const h = blockHeightPx(children[j], scale);
|
||||
if ((sum + h) / count > available - SAFETY_PX && j > 0) break;
|
||||
sum += h;
|
||||
if (spans.end[j] / count > available - SAFETY_PX && j > 0) break;
|
||||
k = j + 1;
|
||||
}
|
||||
// Leftover room after k whole blocks: fill it with the boundary
|
||||
// block's first lines (line split) rather than leaving it empty.
|
||||
const tailBudget = count * (available - SAFETY_PX) - sum;
|
||||
const tailBudget = count * (available - SAFETY_PX) - spans.start[k];
|
||||
if (k < node.childCount && splitParagraphInBlock(frags[i], children, k, tailBudget, scale, false)) {
|
||||
return true;
|
||||
done(false);
|
||||
continue;
|
||||
}
|
||||
let boundary = pos + 1;
|
||||
for (let j = 0; j < k; j++) boundary += node.child(j).nodeSize;
|
||||
const typesAfter = [{ type: node.type, attrs: node.attrs }];
|
||||
if (!canSplit(editorView.state.doc, boundary, 1, typesAfter)) continue;
|
||||
dispatchFlow(editorView.state.tr.split(boundary, 1, typesAfter));
|
||||
return true;
|
||||
done(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fits: pull the adjacent continuation back when its head would
|
||||
@@ -396,11 +421,12 @@ export const ColumnsFlow = Extension.create({
|
||||
continue;
|
||||
}
|
||||
dispatchFlow(tr);
|
||||
return true;
|
||||
done(true);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
return ops > 0;
|
||||
}
|
||||
|
||||
// Sequential-fill heights: a fragment with a continuation spans its page slot;
|
||||
@@ -416,12 +442,16 @@ export const ColumnsFlow = Extension.create({
|
||||
const { pos, node } = frags[i];
|
||||
const el = editorView.nodeDOM(pos) as HTMLElement | null;
|
||||
if (!el || !el.classList.contains('columns-node')) continue;
|
||||
const top = topWithin(el);
|
||||
const page = Math.floor(top / vm.cycle) + 1;
|
||||
const contentStart = (page - 1) * vm.cycle + vm.top;
|
||||
const contentEnd = contentStart + vm.contentHeight;
|
||||
if (top < contentStart - 0.5 || top >= contentEnd) continue;
|
||||
const available = contentEnd - top;
|
||||
// A chained continuation spans a full page wherever it renders right now.
|
||||
let available = vm.contentHeight;
|
||||
if (!(i > 0 && hasNextInChain(frags, i - 1))) {
|
||||
const top = topWithin(el);
|
||||
const page = Math.floor(top / vm.cycle) + 1;
|
||||
const contentStart = (page - 1) * vm.cycle + vm.top;
|
||||
const contentEnd = contentStart + vm.contentHeight;
|
||||
if (top < contentStart - 0.5 || top >= contentEnd) continue;
|
||||
available = contentEnd - top;
|
||||
}
|
||||
const count = Math.max(1, node.attrs.count as number);
|
||||
const children = Array.from(el.children).filter(
|
||||
(c) => !(c as HTMLElement).dataset?.pageBreakSpacer,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import type { Node as PMNode } from '@tiptap/pm/model';
|
||||
import { findBookmark } from './bookmark';
|
||||
import { readVerticalMargins, pageOfElement } from './pageBreaks';
|
||||
import type { EditorView } from '@tiptap/pm/view';
|
||||
import { bookmarks, findBookmark, type BookmarkRef } from './bookmark';
|
||||
import { readVerticalMargins, pageOfElement, type PageGrid } from './pageBreaks';
|
||||
|
||||
// A cross-reference: an inline atom showing either the text of a bookmark or the page it
|
||||
// sits on, kept live by the node view the way the TOC keeps its page numbers. Round-trips
|
||||
@@ -86,16 +87,91 @@ export const CrossReference = Node.create({
|
||||
},
|
||||
});
|
||||
|
||||
// Node view: repaints from the live bookmark on each pagination settle (pm-pagecount on
|
||||
// the .paper ancestor) and on doc change, writing the resolved text back to the attr —
|
||||
// guarded against a loop by comparing with what the node already holds.
|
||||
// The reference's text now: the bookmark's own text, or the page it renders on. A
|
||||
// bookmark that no longer exists leaves the cached text alone (Word shows its cached
|
||||
// result too until the field is updated).
|
||||
function resolveText(view: EditorView, node: PMNode, target: BookmarkRef | undefined, grid: () => PageGrid): string {
|
||||
const cached = String(node.attrs.text ?? '');
|
||||
if (!target) return cached;
|
||||
if (node.attrs.format !== 'page') return target.text;
|
||||
const at = view.domAtPos(target.from).node;
|
||||
const el = (at.nodeType === 1 ? at : at.parentElement) as HTMLElement | null;
|
||||
return el ? String(pageOfElement(view, el, grid())) : cached;
|
||||
}
|
||||
|
||||
// Every reference of one editor resolves in one go, on each pagination settle
|
||||
// (pm-pagecount) and on a change to any of them: one bookmark scan, every page read
|
||||
// before any text is written, and one transaction for the texts that changed.
|
||||
class CrossRefBatch {
|
||||
private views = new Set<CrossRefView>();
|
||||
private scheduled = false;
|
||||
private listening = false;
|
||||
|
||||
constructor(private editor: Editor) {}
|
||||
|
||||
add(view: CrossRefView): void {
|
||||
this.views.add(view);
|
||||
this.schedule();
|
||||
}
|
||||
|
||||
remove(view: CrossRefView): void {
|
||||
this.views.delete(view);
|
||||
}
|
||||
|
||||
schedule(): void {
|
||||
if (this.scheduled) return;
|
||||
this.scheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
this.scheduled = false;
|
||||
this.run();
|
||||
});
|
||||
}
|
||||
|
||||
private run(): void {
|
||||
const { editor } = this;
|
||||
if (editor.isDestroyed) return;
|
||||
const view = editor.view;
|
||||
if (!this.listening) {
|
||||
this.listening = true;
|
||||
view.dom.addEventListener('pm-pagecount', () => this.schedule());
|
||||
}
|
||||
const targets = new Map<string, BookmarkRef>();
|
||||
for (const b of bookmarks(editor.state.doc)) if (!targets.has(b.name)) targets.set(b.name, b);
|
||||
let grid: PageGrid | null = null;
|
||||
const gridOf = () => (grid ??= readVerticalMargins(view.dom as HTMLElement).grid);
|
||||
const jobs: { ref: CrossRefView; node: PMNode; pos: number; text: string }[] = [];
|
||||
for (const ref of this.views) {
|
||||
const pos = ref.pos();
|
||||
const node = pos === null ? null : editor.state.doc.nodeAt(pos);
|
||||
if (pos === null || !node || node.type.name !== 'crossRef' || !ref.dom.isConnected) continue;
|
||||
jobs.push({ ref, node, pos, text: resolveText(view, node, targets.get(String(node.attrs.name ?? '')), gridOf) });
|
||||
}
|
||||
for (const j of jobs) j.ref.paint(j.node, j.text);
|
||||
const tr = editor.state.tr.setMeta('addToHistory', false);
|
||||
let changed = false;
|
||||
for (const j of jobs) {
|
||||
if (j.text === j.node.attrs.text) continue;
|
||||
tr.setNodeAttribute(j.pos, 'text', j.text);
|
||||
changed = true;
|
||||
}
|
||||
if (changed) view.dispatch(tr);
|
||||
}
|
||||
}
|
||||
|
||||
const batches = new WeakMap<Editor, CrossRefBatch>();
|
||||
|
||||
function batchFor(editor: Editor): CrossRefBatch {
|
||||
let batch = batches.get(editor);
|
||||
if (!batch) batches.set(editor, (batch = new CrossRefBatch(editor)));
|
||||
return batch;
|
||||
}
|
||||
|
||||
// Node view: shows what the batch resolves; a modifier-click jumps to the bookmark.
|
||||
class CrossRefView {
|
||||
dom: HTMLElement;
|
||||
private editor: Editor;
|
||||
private getPos: () => number;
|
||||
private scheduled = false;
|
||||
private paper: HTMLElement | null = null;
|
||||
private onPageCount = () => this.schedule();
|
||||
private batch: CrossRefBatch;
|
||||
|
||||
constructor(editor: Editor, getPos: () => number) {
|
||||
this.editor = editor;
|
||||
@@ -112,64 +188,29 @@ class CrossRefView {
|
||||
this.goTo();
|
||||
});
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.paper = this.dom.closest('.paper') as HTMLElement | null;
|
||||
this.paper?.addEventListener('pm-pagecount', this.onPageCount);
|
||||
this.render();
|
||||
});
|
||||
this.batch = batchFor(editor);
|
||||
this.batch.add(this);
|
||||
}
|
||||
|
||||
private schedule(): void {
|
||||
if (this.scheduled) return;
|
||||
this.scheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
this.scheduled = false;
|
||||
this.render();
|
||||
});
|
||||
pos(): number | null {
|
||||
const pos = this.getPos();
|
||||
return typeof pos === 'number' ? pos : null;
|
||||
}
|
||||
|
||||
private node(): PMNode | null {
|
||||
const pos = this.getPos();
|
||||
const node = typeof pos === 'number' ? this.editor.state.doc.nodeAt(pos) : null;
|
||||
const pos = this.pos();
|
||||
const node = pos === null ? null : this.editor.state.doc.nodeAt(pos);
|
||||
return node?.type.name === 'crossRef' ? node : null;
|
||||
}
|
||||
|
||||
// The reference's text now: the bookmark's own text, or the page it renders on. A
|
||||
// bookmark that no longer exists leaves the cached text alone (Word shows its cached
|
||||
// result too until the field is updated).
|
||||
private resolve(node: PMNode): string {
|
||||
const found = findBookmark(this.editor.state.doc, String(node.attrs.name ?? ''));
|
||||
if (!found) return String(node.attrs.text ?? '');
|
||||
if (node.attrs.format !== 'page') return found.text;
|
||||
const at = this.editor.view.domAtPos(found.from).node;
|
||||
const el = (at.nodeType === 1 ? at : at.parentElement) as HTMLElement | null;
|
||||
if (!el) return String(node.attrs.text ?? '');
|
||||
const { grid } = readVerticalMargins(this.editor.view.dom as HTMLElement);
|
||||
return String(pageOfElement(this.editor.view, el, grid));
|
||||
}
|
||||
|
||||
// Mirror what renderHTML would emit, so the DOM reads the same with or without the view.
|
||||
private paint(node: PMNode | null, text = String(node?.attrs?.text ?? '')): void {
|
||||
paint(node: PMNode | null, text = String(node?.attrs?.text ?? '')): void {
|
||||
this.dom.dataset.crossRef = String(node?.attrs?.name ?? '');
|
||||
this.dom.dataset.format = node?.attrs?.format === 'page' ? 'page' : 'text';
|
||||
this.dom.dataset.text = text;
|
||||
this.dom.textContent = text;
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
if (this.editor.isDestroyed || !this.dom.isConnected) return;
|
||||
const node = this.node();
|
||||
if (!node) return;
|
||||
const text = this.resolve(node);
|
||||
this.paint(node, text);
|
||||
if (text === node.attrs.text) return;
|
||||
const pos = this.getPos();
|
||||
if (typeof pos !== 'number') return;
|
||||
this.editor.view.dispatch(
|
||||
this.editor.state.tr.setNodeAttribute(pos, 'text', text).setMeta('addToHistory', false),
|
||||
);
|
||||
}
|
||||
|
||||
// Scroll the bookmark into view and drop the cursor into it.
|
||||
private goTo(): void {
|
||||
const node = this.node();
|
||||
@@ -183,7 +224,7 @@ class CrossRefView {
|
||||
|
||||
update(node: PMNode): boolean {
|
||||
if (node.type.name !== 'crossRef') return false;
|
||||
this.schedule();
|
||||
this.batch.schedule();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -199,6 +240,6 @@ class CrossRefView {
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.paper?.removeEventListener('pm-pagecount', this.onPageCount);
|
||||
this.batch.remove(this);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Extension } from '@tiptap/core';
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||
import { Plugin, PluginKey, type Transaction } from '@tiptap/pm/state';
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
||||
import type { Node as ProseMirrorNode } from '@tiptap/pm/model';
|
||||
import { cssFontFamily, resolveStyle, type StyleSheet, type TextProps } from '../../styles/styleSheet';
|
||||
import { FORCE_PAGE_RECALC } from './pageBreaks';
|
||||
|
||||
// A list marker's direct formatting. CSS values, since that is what two of the three
|
||||
// consumers want; the DOCX exporter converts. The family is the plain name, not a CSS
|
||||
@@ -90,6 +89,43 @@ export function listMarkerDecos(doc: ProseMirrorNode, charProps?: CharStyleProps
|
||||
return DecorationSet.create(doc, decos);
|
||||
}
|
||||
|
||||
// Set on the transaction Editor.svelte's stylesheet effect dispatches: an edited registry
|
||||
// changes what a character or list style means, which no document change shows.
|
||||
export const SHEET_CHANGED = 'sheetChanged';
|
||||
|
||||
const LIST_TYPES = new Set(['bulletList', 'orderedList', 'listItem']);
|
||||
|
||||
// Whether a transaction's changes reach into a list. A step is taken by its own range
|
||||
// (`from`/`to` or `pos`, which is all a mark step has — its position map is empty) and
|
||||
// by what its map moved, in the document before and after it. Anything else leaves every
|
||||
// marker as it was, so the old decorations are mapped instead of rebuilt.
|
||||
export function touchesList(tr: Transaction): boolean {
|
||||
for (let i = 0; i < tr.steps.length; i++) {
|
||||
const step = tr.steps[i] as unknown as { from?: number; to?: number; pos?: number };
|
||||
const before = tr.docs[i];
|
||||
const after = tr.docs[i + 1] ?? tr.doc;
|
||||
if (typeof step.from === 'number' && inList(before, step.from, step.to ?? step.from)) return true;
|
||||
if (typeof step.pos === 'number' && inList(before, step.pos, step.pos + 1)) return true;
|
||||
let hit = false;
|
||||
tr.steps[i].getMap().forEach((oldStart, oldEnd, newStart, newEnd) => {
|
||||
hit ||= inList(before, oldStart, oldEnd) || inList(after, newStart, newEnd);
|
||||
});
|
||||
if (hit) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// A list node in the range, or one around it — a marker's format is read from the item's
|
||||
// first text portion, so a change inside an item counts as much as one to the list.
|
||||
function inList(doc: ProseMirrorNode, from: number, to: number): boolean {
|
||||
if (from > doc.content.size) return true; // out of this document: rebuild rather than miss
|
||||
const $from = doc.resolve(from);
|
||||
for (let d = $from.depth; d > 0; d--) if (LIST_TYPES.has($from.node(d).type.name)) return true;
|
||||
let hit = false;
|
||||
doc.nodesBetween(from, Math.min(to, doc.content.size), (node) => { hit ||= LIST_TYPES.has(node.type.name); return !hit; });
|
||||
return hit;
|
||||
}
|
||||
|
||||
function itemMarkerFormat(item: ProseMirrorNode, charProps?: CharStyleProps): MarkerFormat | null {
|
||||
const block = item.firstChild;
|
||||
const first = block?.firstChild;
|
||||
@@ -132,10 +168,9 @@ export const ListMarker = Extension.create<{ sheet: () => StyleSheet }>({
|
||||
key: listMarkerKey,
|
||||
state: {
|
||||
init: (_, state) => listMarkerDecos(state.doc, props()),
|
||||
// An edited registry can change what a character style means, and that
|
||||
// arrives as FORCE_PAGE_RECALC (Editor.svelte's stylesheet effect).
|
||||
apply: (tr, old) =>
|
||||
tr.docChanged || tr.getMeta(FORCE_PAGE_RECALC) ? listMarkerDecos(tr.doc, props()) : old,
|
||||
tr.getMeta(SHEET_CHANGED) || (tr.docChanged && touchesList(tr)) ? listMarkerDecos(tr.doc, props())
|
||||
: tr.docChanged ? old.map(tr.mapping, tr.doc) : old,
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { childCycle, defaultOrderedTypeAt, ROOT_ORDERED_CYCLE, type OrderedCycle
|
||||
import { effectiveListLevel } from '../../styles/listStyles';
|
||||
import type { ListStyle as ListStyleDef } from '../../styles/listStyles';
|
||||
import type { StyleSheet } from '../../styles/styleSheet';
|
||||
import { FORCE_PAGE_RECALC } from './pageBreaks';
|
||||
import { SHEET_CHANGED, touchesList } from './listMarker';
|
||||
|
||||
const EMPTY_SHEET: StyleSheet = { paragraph: {}, character: {}, table: {}, list: {} };
|
||||
|
||||
@@ -175,10 +175,9 @@ export const ListStyle = Extension.create<{ sheet: () => StyleSheet }>({
|
||||
key: listStyleKey,
|
||||
state: {
|
||||
init: (_, state) => listStyleDecos(state.doc, sheet()),
|
||||
// An edited registry changes what a list style means; that arrives as
|
||||
// FORCE_PAGE_RECALC (Editor.svelte's stylesheet effect), as in listMarker.ts.
|
||||
apply: (tr, old) =>
|
||||
tr.docChanged || tr.getMeta(FORCE_PAGE_RECALC) ? listStyleDecos(tr.doc, sheet()) : old,
|
||||
tr.getMeta(SHEET_CHANGED) || (tr.docChanged && touchesList(tr)) ? listStyleDecos(tr.doc, sheet())
|
||||
: tr.docChanged ? old.map(tr.mapping, tr.doc) : old,
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
|
||||
@@ -291,90 +291,115 @@ function wrapZoneTabs(para: HTMLElement): HTMLElement[] {
|
||||
return Array.from(para.querySelectorAll<HTMLElement>('span[data-zone-tab]'));
|
||||
}
|
||||
|
||||
type ZoneJob = {
|
||||
para: HTMLElement; tabs: HTMLElement[]; wrapping: string; align: string;
|
||||
scale: number; originX: number; stops: TabStop[];
|
||||
};
|
||||
|
||||
// An inactive header/footer zone is generateHTML output no ProseMirror plugin reaches,
|
||||
// so its tabs are measured straight on the DOM — same rule, left to right, each advance
|
||||
// applied before the next is read.
|
||||
export function layOutZoneTabs(zone: HTMLElement): void {
|
||||
const para = zone.querySelector<HTMLElement>('[data-tab-stops]');
|
||||
if (!para || !parseTabStops(para.getAttribute('data-tab-stops')).length) return;
|
||||
const tabs = wrapZoneTabs(para);
|
||||
for (const t of tabs) {
|
||||
t.className = '';
|
||||
t.removeAttribute('data-leader');
|
||||
t.style.cssText = 'tab-size:0';
|
||||
// applied before the next is read. Zones go together: a round's reads before its writes.
|
||||
export function layOutZoneTabs(zones: HTMLElement[]): void {
|
||||
const jobs: ZoneJob[] = [];
|
||||
for (const zone of zones) {
|
||||
const para = zone.querySelector<HTMLElement>('[data-tab-stops]');
|
||||
if (!para || !parseTabStops(para.getAttribute('data-tab-stops')).length) continue;
|
||||
const tabs = wrapZoneTabs(para);
|
||||
for (const t of tabs) {
|
||||
t.className = '';
|
||||
t.removeAttribute('data-leader');
|
||||
t.style.cssText = 'tab-size:0';
|
||||
}
|
||||
// Measured with wrapping off: a segment that has already wrapped reads short by the
|
||||
// space its break swallowed, so the advance computed from it keeps it wrapped — a
|
||||
// footer stayed two lines over three pixels.
|
||||
const wrapping = para.style.whiteSpace;
|
||||
para.style.whiteSpace = 'pre';
|
||||
// Laid out from the left, whatever the paragraph's alignment: LibreOffice positions
|
||||
// the tabbed segments on the stops and only then shifts the whole line by what is
|
||||
// left over (probed — a right-aligned head with two stops starts at the left edge).
|
||||
const align = para.style.textAlign;
|
||||
para.style.textAlign = 'left';
|
||||
jobs.push({ para, tabs, wrapping, align, scale: 1, originX: 0, stops: [] });
|
||||
}
|
||||
// Measured with wrapping off: a segment that has already wrapped reads short by the
|
||||
// space its break swallowed, so the advance computed from it keeps it wrapped — the
|
||||
// Math Guide's footer stayed two lines over three pixels.
|
||||
const wrapping = para.style.whiteSpace;
|
||||
para.style.whiteSpace = 'pre';
|
||||
// Laid out from the left, whatever the paragraph's alignment: LibreOffice positions
|
||||
// the tabbed segments on the stops and only then shifts the whole line by what is
|
||||
// left over (probed — a right-aligned head with two stops starts at the left edge,
|
||||
// and moves right by exactly the gap when its last stop sits inside the column).
|
||||
const align = para.style.textAlign;
|
||||
para.style.textAlign = 'left';
|
||||
const rect = para.getBoundingClientRect();
|
||||
const scale = para.offsetWidth ? rect.width / para.offsetWidth : 1;
|
||||
const cs = getComputedStyle(para);
|
||||
const padLeft = parseFloat(cs.paddingLeft || '0');
|
||||
// The line's own width, not clientWidth: that is rounded up to whole px, and half a pixel
|
||||
// is enough to wrap the run a right-aligned stop puts at the end. One px of slack, since
|
||||
// a run ending exactly on the boundary wraps and a pixel short of it shows on nothing.
|
||||
const lineCm = (rect.width / (scale || 1) - padLeft - parseFloat(cs.paddingRight || '0') - 1) / PX_PER_CM;
|
||||
const stops = clampStops(parseTabStops(para.getAttribute('data-tab-stops')), lineCm);
|
||||
if (!scale) { para.style.whiteSpace = wrapping; para.style.textAlign = align; return; }
|
||||
const originX = rect.left + padLeft * scale;
|
||||
|
||||
for (let i = 0; i < tabs.length; i++) {
|
||||
const tab = tabs[i];
|
||||
const xCm = (tab.getBoundingClientRect().left - originX) / scale / PX_PER_CM;
|
||||
const stop = stops.find((s) => s.pos > xCm + 0.01);
|
||||
if (!stop) continue;
|
||||
let width = (stop.pos - xCm) * PX_PER_CM;
|
||||
// A decimal stop takes the whole segment back, i.e. behaves as right — a zone is one
|
||||
// paragraph of running text, where a separator to align on is not a case that arises.
|
||||
if (stop.align !== 'left') {
|
||||
const range = document.createRange();
|
||||
range.setStartAfter(tab);
|
||||
if (i + 1 < tabs.length) range.setEndBefore(tabs[i + 1]);
|
||||
else range.setEnd(para, para.childNodes.length);
|
||||
// The extent, not the sum: a range crossing inline elements yields a rect for the
|
||||
// element's box as well as for the text inside it. Its own line only — a zone
|
||||
// ending in a hard break has a rect on the next one, and that starts at the
|
||||
// paragraph's left edge, which would inflate the segment by its own offset.
|
||||
const segment = () => {
|
||||
const rects = Array.from(range.getClientRects()).filter((r) => r.width);
|
||||
if (!rects.length) return 0;
|
||||
const top = Math.min(...rects.map((r) => r.top));
|
||||
const own = rects.filter((r) => r.top < top + 1);
|
||||
return Math.max(...own.map((r) => r.right)) - Math.min(...own.map((r) => r.left));
|
||||
};
|
||||
const take = (seg: number) => (stop.align === 'center' ? seg / 2 : seg) / scale;
|
||||
width -= take(segment());
|
||||
// A segment too long to reach the stop wraps, and what the stop aligns is the part
|
||||
// that stays on the line — measured with the wrapping the zone really has, and
|
||||
// from this tab at zero, so the break falls where the layout will put it.
|
||||
if (width < 0) {
|
||||
const own = tab.style.marginLeft;
|
||||
tab.style.marginLeft = '0px';
|
||||
para.style.whiteSpace = wrapping;
|
||||
width = (stop.pos - xCm) * PX_PER_CM - take(segment());
|
||||
para.style.whiteSpace = 'pre';
|
||||
tab.style.marginLeft = own;
|
||||
for (const job of jobs) {
|
||||
const { para } = job;
|
||||
const rect = para.getBoundingClientRect();
|
||||
job.scale = para.offsetWidth ? rect.width / para.offsetWidth : 1;
|
||||
const cs = getComputedStyle(para);
|
||||
const padLeft = parseFloat(cs.paddingLeft || '0');
|
||||
// The line's own width, not clientWidth: that is rounded up to whole px, and half a pixel
|
||||
// is enough to wrap the run a right-aligned stop puts at the end. One px of slack, since
|
||||
// a run ending exactly on the boundary wraps and a pixel short of it shows on nothing.
|
||||
const lineCm = (rect.width / (job.scale || 1) - padLeft - parseFloat(cs.paddingRight || '0') - 1) / PX_PER_CM;
|
||||
job.stops = clampStops(parseTabStops(para.getAttribute('data-tab-stops')), lineCm);
|
||||
job.originX = rect.left + padLeft * job.scale;
|
||||
}
|
||||
for (let i = 0; jobs.some((j) => i < j.tabs.length); i++) {
|
||||
const round: ZoneAdvance[] = [];
|
||||
for (const job of jobs) {
|
||||
if (i >= job.tabs.length || !job.scale) continue;
|
||||
const advance = zoneTabAdvance(job, i);
|
||||
if (advance) round.push(advance);
|
||||
}
|
||||
for (const { tab, width, leader } of round) {
|
||||
tab.style.marginLeft = `${width}px`;
|
||||
if (leader) {
|
||||
tab.className = 'tab-leader';
|
||||
tab.dataset.leader = leader;
|
||||
tab.style.setProperty('--leader-w', `${width}px`);
|
||||
}
|
||||
}
|
||||
width = Math.max(0, Math.round(width * 100) / 100);
|
||||
tab.style.marginLeft = `${width}px`;
|
||||
const leader = normalizeLeader(stop.leader);
|
||||
if (leader) {
|
||||
tab.className = 'tab-leader';
|
||||
tab.dataset.leader = leader;
|
||||
tab.style.setProperty('--leader-w', `${width}px`);
|
||||
}
|
||||
for (const { para, wrapping, align } of jobs) {
|
||||
para.style.whiteSpace = wrapping;
|
||||
para.style.textAlign = align;
|
||||
}
|
||||
}
|
||||
|
||||
type ZoneAdvance = { tab: HTMLElement; width: number; leader: ReturnType<typeof normalizeLeader> };
|
||||
|
||||
// One tab's advance to its stop, read off the layout of the moment (the tabs before it
|
||||
// already carry theirs); null where no stop is left on the line.
|
||||
function zoneTabAdvance(job: ZoneJob, i: number): ZoneAdvance | null {
|
||||
const { para, tabs, stops, scale, originX, wrapping } = job;
|
||||
const tab = tabs[i];
|
||||
const xCm = (tab.getBoundingClientRect().left - originX) / scale / PX_PER_CM;
|
||||
const stop = stops.find((s) => s.pos > xCm + 0.01);
|
||||
if (!stop) return null;
|
||||
let width = (stop.pos - xCm) * PX_PER_CM;
|
||||
// A decimal stop takes the whole segment back, i.e. behaves as right — a zone is one
|
||||
// paragraph of running text, where a separator to align on is not a case that arises.
|
||||
if (stop.align !== 'left') {
|
||||
const range = document.createRange();
|
||||
range.setStartAfter(tab);
|
||||
if (i + 1 < tabs.length) range.setEndBefore(tabs[i + 1]);
|
||||
else range.setEnd(para, para.childNodes.length);
|
||||
// The extent, not the sum: a range crossing inline elements yields a rect for the
|
||||
// element's box as well as for the text inside it. Its own line only — a zone ending
|
||||
// in a hard break has a rect on the next line, starting at the paragraph's left edge.
|
||||
const segment = () => {
|
||||
const rects = Array.from(range.getClientRects()).filter((r) => r.width);
|
||||
if (!rects.length) return 0;
|
||||
const top = Math.min(...rects.map((r) => r.top));
|
||||
const own = rects.filter((r) => r.top < top + 1);
|
||||
return Math.max(...own.map((r) => r.right)) - Math.min(...own.map((r) => r.left));
|
||||
};
|
||||
const take = (seg: number) => (stop.align === 'center' ? seg / 2 : seg) / scale;
|
||||
width -= take(segment());
|
||||
// A segment too long to reach the stop wraps, and what the stop aligns is the part
|
||||
// that stays on the line — measured with the wrapping the zone really has, and
|
||||
// from this tab at zero, so the break falls where the layout will put it.
|
||||
if (width < 0) {
|
||||
const own = tab.style.marginLeft;
|
||||
tab.style.marginLeft = '0px';
|
||||
para.style.whiteSpace = wrapping;
|
||||
width = (stop.pos - xCm) * PX_PER_CM - take(segment());
|
||||
para.style.whiteSpace = 'pre';
|
||||
tab.style.marginLeft = own;
|
||||
}
|
||||
}
|
||||
para.style.whiteSpace = wrapping;
|
||||
para.style.textAlign = align;
|
||||
return { tab, width: Math.max(0, Math.round(width * 100) / 100), leader: normalizeLeader(stop.leader) };
|
||||
}
|
||||
|
||||
export const TabStops = Extension.create({
|
||||
|
||||
@@ -207,6 +207,7 @@ class TocView {
|
||||
private getPos: () => number;
|
||||
private scheduled = false;
|
||||
private lastKey = '';
|
||||
private lastLook = '';
|
||||
private wasPaginated = false;
|
||||
private paper: HTMLElement | null = null;
|
||||
private onPageCount = () => this.schedule();
|
||||
@@ -326,6 +327,7 @@ class TocView {
|
||||
// (leader, page numbers, title, level styles). Its cached entries are what syncAttr
|
||||
// writes back, so keying on them too would chase this pass's own result.
|
||||
const { entries: _cached, ...look } = this.node()?.attrs ?? {};
|
||||
this.lastLook = JSON.stringify(look);
|
||||
const key = JSON.stringify([entries, look]);
|
||||
if (key !== this.lastKey) {
|
||||
this.lastKey = key;
|
||||
@@ -344,15 +346,15 @@ class TocView {
|
||||
const view = this.editor.view;
|
||||
const vm = readVerticalMargins(view.dom as HTMLElement);
|
||||
for (const row of rows) row.style.marginTop = '';
|
||||
// Read every natural top first: applying a gap moves each row below it, and one
|
||||
// reflow for the whole index beats one per row.
|
||||
const tops = rows.map(row => topInEditor(view, row));
|
||||
// Read every natural top and height first: applying a gap moves each row below it,
|
||||
// and one reflow for the whole index beats one per row.
|
||||
const boxes = rows.map(row => [topInEditor(view, row), row.offsetHeight]);
|
||||
let shift = 0;
|
||||
let moved = false;
|
||||
rows.forEach((row, i) => {
|
||||
const top = tops[i] + shift;
|
||||
const top = boxes[i][0] + shift;
|
||||
const page = Math.max(1, Math.floor(top / vm.cycle) + 1);
|
||||
if (top + row.offsetHeight <= (page - 1) * vm.cycle + vm.top + vm.contentHeight) return;
|
||||
if (top + boxes[i][1] <= (page - 1) * vm.cycle + vm.top + vm.contentHeight) return;
|
||||
const gap = page * vm.cycle + vm.top - top;
|
||||
if (gap <= 0) return;
|
||||
row.style.marginTop = `${gap}px`;
|
||||
@@ -439,24 +441,27 @@ class TocView {
|
||||
// As many leader dots as the gap holds. The row clips the rest on screen, but nothing
|
||||
// else does: 200 of them reach the PDF, the clipboard and every measurement as text.
|
||||
private fillLeaders(): void {
|
||||
const range = document.createRange();
|
||||
const leaders = Array.from(this.dom.querySelectorAll<HTMLElement>('.toc-leader')).filter(el => el.textContent);
|
||||
// One dot's advance, probed once per font: the levels have styles of their own, and
|
||||
// measuring level 1's dot for a smaller level 3 leaves its row short of the number.
|
||||
const advances = new Map<string, number>();
|
||||
for (const el of this.dom.querySelectorAll<HTMLElement>('.toc-leader')) {
|
||||
const fill = el.textContent?.[0];
|
||||
if (!fill) continue;
|
||||
const keys = leaders.map((el) => {
|
||||
const cs = getComputedStyle(el);
|
||||
const key = `${fill}|${cs.fontSize}|${cs.fontFamily}|${cs.fontWeight}|${cs.fontStyle}`;
|
||||
let one = advances.get(key);
|
||||
if (one == null) {
|
||||
el.textContent = fill.repeat(LEADER_PROBE);
|
||||
range.selectNodeContents(el);
|
||||
one = range.getBoundingClientRect().width / LEADER_PROBE;
|
||||
advances.set(key, one);
|
||||
}
|
||||
el.textContent = one > 0 ? fill.repeat(Math.max(0, Math.floor(el.getBoundingClientRect().width / one))) : '';
|
||||
}
|
||||
return `${el.textContent![0]}|${cs.fontSize}|${cs.fontFamily}|${cs.fontWeight}|${cs.fontStyle}`;
|
||||
});
|
||||
// Every gap is read before any row is written: a write between two reads is a layout.
|
||||
const widths = leaders.map(el => el.getBoundingClientRect().width);
|
||||
const advances = new Map<string, number>();
|
||||
const range = document.createRange();
|
||||
leaders.forEach((el, i) => {
|
||||
if (advances.has(keys[i])) return;
|
||||
el.textContent = keys[i][0].repeat(LEADER_PROBE);
|
||||
range.selectNodeContents(el);
|
||||
advances.set(keys[i], range.getBoundingClientRect().width / LEADER_PROBE);
|
||||
});
|
||||
leaders.forEach((el, i) => {
|
||||
const one = advances.get(keys[i]) ?? 0;
|
||||
el.textContent = one > 0 ? keys[i][0].repeat(Math.max(0, Math.floor(widths[i] / one))) : '';
|
||||
});
|
||||
}
|
||||
|
||||
// Scroll the heading into view and drop the cursor into it.
|
||||
@@ -483,7 +488,9 @@ class TocView {
|
||||
|
||||
update(node: PMNode): boolean {
|
||||
if (node.type.name !== 'tableOfContents') return false;
|
||||
this.schedule();
|
||||
// The entries are what syncAttr just wrote back; only a changed look is news.
|
||||
const { entries: _cached, ...look } = node.attrs;
|
||||
if (JSON.stringify(look) !== this.lastLook) this.schedule();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ copy lands on the same document — the one case still shared. The app's own key
|
||||
zoom, chrome, AutoCorrect, AutoText, word completion, recent files/fonts/chars, markup
|
||||
view, user dictionary, number recognition) stay unscoped.
|
||||
|
||||
- **Document:** `edentext-doc` — TipTap JSON, debounced 1 s on every `onUpdate` (`autosave.ts`); hiding the page (`visibilitychange`/`pagehide`) writes what is pending at once, and writes queue behind each other so a slow picture round trip never stores an older document over a newer one. Loading it raises `edentext-doc-loading`, which only a completed startup clears (`markDocumentLoaded`, two frames after `Editor.svelte`'s mount). Still set at the next load = that document hung or crashed the editor, or a reload cut its start short: the user is asked whether to try again; declined, it moves to `edentext-doc-broken` and the kept versions are offered before the app starts empty, so a freeze is escaped instead of repeated. **Pictures do not live in it:** `imageStore.ts` swaps every data-URI past 4 KB for an `idb:<key>` before the write and keeps the bytes in the `edentext-images` IndexedDB store (a handful of photos is the whole localStorage quota). The key is a content hash of the data-URI's head, tail and length — sampled, because this runs on every autosave — so a picture used twice is stored once, across documents too — which is why the store is the one thing **not** scoped per document. Each save sweeps the keys no stored document names any more, found by scanning localStorage for `idb:` references rather than by a per-document list, which would go stale for every document no tab has opened since. Where IndexedDB is unavailable the data-URIs stay inline, exactly as before; a key the store has lost keeps its `idb:` src and is reported, rather than quietly dropping the reader's content. `loadDocument` is therefore **async**, and `Editor.svelte` awaits it before building the editor.
|
||||
- **Document:** `edentext-doc` — TipTap JSON, debounced 1 s on every `onUpdate` and serialized only when the write runs (`autosave.ts`); hiding the page (`visibilitychange`/`pagehide`) writes what is pending at once, and writes queue behind each other so a slow picture round trip never stores an older document over a newer one. Loading it raises `edentext-doc-loading`, which only a completed startup clears (`markDocumentLoaded`, two frames after `Editor.svelte`'s mount). Still set at the next load = that document hung or crashed the editor, or a reload cut its start short: the user is asked whether to try again; declined, it moves to `edentext-doc-broken` and the kept versions are offered before the app starts empty, so a freeze is escaped instead of repeated. **Pictures do not live in it:** `imageStore.ts` swaps every data-URI past 4 KB for an `idb:<key>` before the write and keeps the bytes in the `edentext-images` IndexedDB store (a handful of photos is the whole localStorage quota). The key is a content hash of the data-URI's head, tail and length — sampled, because this runs on every autosave — so a picture used twice is stored once, across documents too — which is why the store is the one thing **not** scoped per document. Each save sweeps the keys no stored document names any more, found by scanning localStorage for `idb:` references rather than by a per-document list, which would go stale for every document no tab has opened since. Where IndexedDB is unavailable the data-URIs stay inline, exactly as before; a key the store has lost keeps its `idb:` src and is reported, rather than quietly dropping the reader's content. `loadDocument` is therefore **async**, and `Editor.svelte` awaits it before building the editor.
|
||||
- **Earlier versions:** the `edentext-snapshots` IndexedDB database (`snapshots.ts`, one per document via `docKey`, as `edentext-fonts` is) — up to three earlier autosaves, one taken at most every five minutes, keyed by their timestamp. They hold the document **whole**, pictures inline: the image store is swept against the stored documents, and a snapshot is none of them. **Nothing in the UI points at them**: they are offered only where the browser copy has failed the reader — `loadDocument`'s recovery question, newest first, one confirm each (the newest may be the one that broke). A version taken up starts under the boot flag like any other document, so one that freezes the editor again brings the question back.
|
||||
- **Recent files:** `edentext-recent-files` — `{id, name, at}` per entry, newest first, eight kept (`recentFiles.ts`). Only the label lives here: the `FileSystemFileHandle` that reopens one is structured-cloneable but not JSON, so it sits in the `edentext-recent` IndexedDB store under the same id, and each write sweeps the handles no entry names. Two handles compare only through `isSameEntry`, which is async — hence the walk when recording a file already in the list. A file without a handle is never recorded (nothing could reopen it — no File System Access API, or a template), so the list simply stays empty in Firefox/Safari/Brave; a startup prune drops entries whose handle is gone. Reopening asks for `read` permission again (the API drops it on reload) — a declined prompt keeps the entry for the next try, only a moved or deleted file drops it (with a message). Save re-prompts for write on its own (Save-As fallback in `App.svelte`).
|
||||
- **Document protected:** `edentext-doc-protected` — `'1'` while the open document is password-protected; the password itself is never stored, so after a reload the first save asks for it again or lets the protection go (`documentName.ts`).
|
||||
|
||||
@@ -14,8 +14,9 @@ const BROKEN_KEY = docKey('edentext-doc-broken');
|
||||
const DEBOUNCE_MS = 1000;
|
||||
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
// The document as last handed in, until it is written.
|
||||
let pending: object | null = null;
|
||||
// Hands out the document as last handed in, until it is written. A function, so a
|
||||
// long document is serialized once per write, not once per keystroke.
|
||||
let pending: (() => object) | null = null;
|
||||
// Writes queue behind each other: an older write finishing after a newer one would
|
||||
// store the older document and sweep the pictures the newer one just added.
|
||||
let chain: Promise<void> = Promise.resolve();
|
||||
@@ -24,15 +25,16 @@ let chain: Promise<void> = Promise.resolve();
|
||||
// debounced timer doesn't surface an unhandled error.
|
||||
let quotaWarned = false;
|
||||
|
||||
export function saveDocument(json: object): void {
|
||||
export function saveDocument(json: () => object): void {
|
||||
pending = json;
|
||||
if (timeout) clearTimeout(timeout);
|
||||
timeout = setTimeout(() => { chain = chain.then(write); }, DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
async function write(): Promise<void> {
|
||||
const json = pending;
|
||||
if (!json) return;
|
||||
const take = pending;
|
||||
if (!take) return;
|
||||
const json = take();
|
||||
// Pictures go to IndexedDB and the JSON keeps a key; where that fails they stay
|
||||
// inline, which is the only thing localStorage ever held.
|
||||
const { json: slim, blobs } = stashImages(json);
|
||||
@@ -43,7 +45,7 @@ async function write(): Promise<void> {
|
||||
void keepSnapshot(json);
|
||||
// Pending until stored, so a flush during the round trip still has it; a newer
|
||||
// document handed in meanwhile stays pending for the write queued behind.
|
||||
if (pending === json) pending = null;
|
||||
if (pending === take) pending = null;
|
||||
}
|
||||
|
||||
// The tab is going away: no time for the IndexedDB round trip, so the JSON goes out
|
||||
@@ -51,7 +53,7 @@ async function write(): Promise<void> {
|
||||
// Timer and pending stay, so a page revived from the back/forward cache writes again.
|
||||
export function flushDocument(): void {
|
||||
if (!pending) return;
|
||||
const { json: slim, blobs } = stashImages(pending);
|
||||
const { json: slim, blobs } = stashImages(pending());
|
||||
void putImages(blobs);
|
||||
store(slim);
|
||||
}
|
||||
|
||||
@@ -258,6 +258,27 @@ try {
|
||||
(p) => `${p.getAttribute('data-wrap-band') ?? '-'}/${getComputedStyle(p).clear}`).join(' '));
|
||||
check(bands === '-/none true/none -/both anchored/none -/none true/none true/none -/both',
|
||||
`loaded from the autosave, the block after a band frame clears it, after an anchored one it does not (${bands})`);
|
||||
|
||||
// A two-column section over several pages pages in one pass: a continuation is judged
|
||||
// with a full page wherever it renders, and the split counts the blocks' margins as the
|
||||
// overflow test does — else one block moves down per pass, a pass per block.
|
||||
const lines = [];
|
||||
for (let i = 0; i < 300; i++) {
|
||||
lines.push({ type: 'paragraph', attrs: { spaceAfter: 6 }, content: [words(`Line ${i + 1}: ${LOREM.slice(0, 5 + (i % 5)).join(' ')}`)] });
|
||||
}
|
||||
await page.evaluate((d) => localStorage.setItem('edentext-doc', JSON.stringify(d)), { type: 'doc', content: [
|
||||
block(words('before the section')), { type: 'columns', attrs: { count: 2 }, content: lines }, block(words('after the section')),
|
||||
] });
|
||||
await page.addInitScript(() => {
|
||||
window.__passes = 0;
|
||||
document.addEventListener('pm-pagecount', () => { window.__passes++; });
|
||||
});
|
||||
await page.reload({ waitUntil: 'load' });
|
||||
await page.waitForSelector('.tiptap', { timeout: 15_000 });
|
||||
await settle(page, true);
|
||||
const flow = await page.evaluate(() => ({ passes: window.__passes, fragments: document.querySelectorAll('.tiptap > .columns-node').length }));
|
||||
check(flow.fragments >= 3 && flow.passes <= 8,
|
||||
`a two-column section over ${flow.fragments} pages settles in ${flow.passes} passes`);
|
||||
} catch (err) {
|
||||
check(false, `dom run threw: ${err.message ?? err}`);
|
||||
} finally {
|
||||
|
||||
@@ -43,7 +43,7 @@ describe('autosave crash recovery', () => {
|
||||
await loadDocument();
|
||||
expect(await loadDocument()).toBeNull(); // recovered: boot flag cleared, doc parked
|
||||
vi.useFakeTimers();
|
||||
saveDocument({ type: 'doc' });
|
||||
saveDocument(() => ({ type: 'doc' }));
|
||||
await vi.runAllTimersAsync();
|
||||
vi.useRealTimers();
|
||||
expect(await loadDocument()).toEqual({ type: 'doc' });
|
||||
@@ -97,7 +97,7 @@ describe('autosave flush on pagehide', () => {
|
||||
|
||||
it('writes the pending document when the page is hidden', () => {
|
||||
vi.useFakeTimers();
|
||||
saveDocument({ type: 'doc', content: [{ type: 'paragraph' }] });
|
||||
saveDocument(() => ({ type: 'doc', content: [{ type: 'paragraph' }] }));
|
||||
expect(localStorage.getItem('edentext-doc')).toBeNull();
|
||||
window.dispatchEvent(new Event('pagehide'));
|
||||
expect(JSON.parse(localStorage.getItem('edentext-doc')!)).toEqual({ type: 'doc', content: [{ type: 'paragraph' }] });
|
||||
@@ -106,7 +106,7 @@ describe('autosave flush on pagehide', () => {
|
||||
|
||||
it('writes nothing when nothing is pending', async () => {
|
||||
vi.useFakeTimers();
|
||||
saveDocument({ type: 'doc' });
|
||||
saveDocument(() => ({ type: 'doc' }));
|
||||
await vi.runAllTimersAsync();
|
||||
localStorage.removeItem('edentext-doc');
|
||||
window.dispatchEvent(new Event('pagehide'));
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// Which transactions the list-marker decorations are rebuilt for: a marker takes its
|
||||
// format from the item's first text portion, so an edit inside an item counts, and a
|
||||
// mark step counts by its own range — its position map is empty.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Editor } from '@tiptap/core';
|
||||
import { extensions } from '../../src/lib/editor/extensions';
|
||||
import { touchesList } from '../../src/lib/editor/extensions/listMarker';
|
||||
|
||||
const text = (t: string) => ({ type: 'text', text: t });
|
||||
const para = (t: string) => ({ type: 'paragraph', content: [text(t)] });
|
||||
const item = (t: string) => ({ type: 'listItem', content: [para(t)] });
|
||||
|
||||
// "outside" fills 1..8; the list starts at 9, its first item's text at 12.
|
||||
const content = {
|
||||
type: 'doc',
|
||||
content: [para('outside'), { type: 'bulletList', content: [item('alpha'), item('beta')] }, para('tail')],
|
||||
};
|
||||
|
||||
function makeEditor() {
|
||||
const el = document.createElement('div');
|
||||
document.body.appendChild(el);
|
||||
return new Editor({ element: el, extensions, content });
|
||||
}
|
||||
|
||||
describe('touchesList', () => {
|
||||
it('sees an edit in a list item and ignores one outside every list', () => {
|
||||
const editor = makeEditor();
|
||||
const { doc } = editor.state;
|
||||
const at = doc.resolve(1).after(1); // after the first paragraph = the list's position
|
||||
const inItem = at + 3; // inside the first item's paragraph
|
||||
|
||||
expect(touchesList(editor.state.tr.insertText('x', inItem))).toBe(true);
|
||||
expect(touchesList(editor.state.tr.insertText('x', 2))).toBe(false);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it('sees a mark applied inside an item, whose position map is empty', () => {
|
||||
const editor = makeEditor();
|
||||
const bold = editor.state.schema.marks.bold;
|
||||
const at = editor.state.doc.resolve(1).after(1);
|
||||
const inItem = at + 3;
|
||||
|
||||
const marked = editor.state.tr.addMark(inItem, inItem + 4, bold.create());
|
||||
expect(marked.steps[0].getMap().ranges.length).toBe(0);
|
||||
expect(touchesList(marked)).toBe(true);
|
||||
|
||||
expect(touchesList(editor.state.tr.addMark(2, 6, bold.create()))).toBe(false);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it('sees the list node itself change, and a list inserted where there was none', () => {
|
||||
const editor = makeEditor();
|
||||
const at = editor.state.doc.resolve(1).after(1);
|
||||
|
||||
expect(touchesList(editor.state.tr.setNodeAttribute(at, 'markerAlign', 'right'))).toBe(true);
|
||||
|
||||
const list = editor.state.schema.nodeFromJSON({ type: 'bulletList', content: [item('new')] });
|
||||
expect(touchesList(editor.state.tr.insert(1, list))).toBe(true);
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user