fix: redraw tab stops when the document is replaced
The measuring pass keeps the layout it last drew as a string key and skips the redraw when a pass measures the same again. Replacing the document maps every decoration away, so an identical layout has to be drawn again — the guard now compares the live decoration count as well. Two documents with the same tab layout otherwise left every tab on the default grid.
This commit is contained in:
1 parent
c115b11c8d
commit
b892accdd5
2 files changed
+6
-2
No files matched your search
@@ -6,7 +6,7 @@ list markers, paragraph boxes, date/time fields and cross-references.
|
||||
- **`textEffects.ts`** (`TextEffects`, `UnderlineStyled`, `StrikeStyled`) — the character effects beyond bold/italic that both formats carry and CSS can draw: `caps` (`uppercase`/`lowercase`/`capitalize`/`smallCaps` → `text-transform`/`font-variant-caps`) and `textPosition` (pt above the baseline → `vertical-align`, independent of sub/superscript) as TextStyle attrs, plus `lineStyle`/`lineColor` on the **underline** mark and `lineStyle` on **strike** (the `<u>`/`<s>` element draws the line, so its shape belongs there). Replaces TipTap's bare Underline/Strike in `extensions.ts` **and** `hfExtensions`. `caps` is also a `TextProps` key, so a named style can carry it. **DOCX**: `w:caps`/`w:smallCaps` (toggles — a run may switch a style's off), `w:u w:val`+`w:color`, `w:dstrike`, `w:position` (`ST_SignedHpsMeasure`: a bare number is half-points, else a measure with its unit). **ODF**: `fo:text-transform`/`fo:font-variant`, `style:text-underline-style`/`-type`/`-color`, `style:text-line-through-type`, `style:text-position` (a percentage of the font size, so the pt value is converted against the run's own size). odf-kit's run formatting has a field for none of them, so `markTextEffects` prefixes the `TEF` sentinel (U+E00F) on the run and `applyTextEffects` folds the attributes into that run's automatic text style — before `applyCharacterStyles`, which then clones the style that already carries them. `tests/lo-roundtrip.test.ts` is what proves LibreOffice reads each one back. **Sub- and superscript** are TipTap's own marks, but their `<sup>`/`<sub>` need `editor.css`'s `font-size: 58%; line-height: 0` on `.paper` (a header/footer zone carries the marks too, and its height is what pagination reserves) — the size both exporters write as `style:text-position`, and the zero line height keeps the raised run out of the line box the way LibreOffice does (measured: its raised run stays within 0.25pt of the plain line's box). The browser's `smaller` + `vertical-align: super` instead grew every such line by ~4px, which is enough to push a block that ends near the page edge onto the next page.
|
||||
- **`paragraphBox.ts`** (`ParagraphBox`) — paragraph/heading **background** (`backgroundColor`, "colored field") + per-side **borders** (`borderTop/Right/Bottom/Left`, "rule line"), as in Word/LibreOffice letterheads. Borders reuse table cells' canonical `'<W>pt solid #RRGGBB'` (from `tableCellBorders.ts`), but here `null`/absent = no border. Commands `setParagraphBackground`/`setParagraphBorders(preset, spec)`; `activeParagraphBorderPresets` drives the picker states. An input rule turns a paragraph typed as `---`/`___`/`===` into an empty bottom-rule line (the border-line AutoCorrect); attrs are `keepOnSplit: false` so the box doesn't cascade onto the next paragraph on Enter. In both `extensions.ts` (body) and `hfExtensions` (header/footer). **ODF**: import reads `fo:background-color`/`fo:border-*` in `blockAttrs` (body) and merges them across the collapsed header/footer paragraphs (`convertHfZone`, first/last heuristic — bottom rule from the last line); export emits them via `ParaStyle`/`paraStyleProps` for cells/lists, a `PBX`-sentinel post-process (`applyParagraphBoxes`, odf-kit has no such options) for top-level body paragraphs, and `applyHfPostProcess` for the Header/Footer styles. **DOCX**: `w:shd`/`w:pBdr` (import `readParaBox`, export `paraShadingOf`/`paraBordersOf`). A *style's* box is not the block's: it goes to the **style registry** (`ParaProps.borderTop…`, `stylePara`/`DocxStyles.paragraphBorders`), so every paragraph the style governs draws it. `ParaProps.borderPadding` (pt) is the gap the file keeps between the text and its rule — Word's per-side `w:pBdr w:space`, ODF's `fo:padding-*` — and `declarations` puts it on the **ruled sides only**: a bottom rule that also padded the top would push the paragraph's first line down (measured: a Title's next block sat 1.4mm low). Where no file value exists, `editor.css`'s flat 0.04/0.08cm on a bordered block still applies. UI: a paragraph-shading `ColorPicker` + `ParagraphBorderPicker.svelte` in `ToolbarExpanded.svelte` (route via `activeEditor`, so body and the active HF zone).
|
||||
- **`indent.ts`** (`Indent`) — `indent` attr in **cm** (rendered as `margin-left`), on paragraph/heading **and** bulletList/orderedList. `indentMore`/`indentLess` step a paragraph/heading by `INDENT_STEP_CM = 1.25` → `fo:margin-left` (odf-kit `indentLeft`); `indentListMore`/`indentListLess` step the innermost list's `indent`, shifting the whole list (export adds it to the L# list-style level margins, see `applyListIndents`). On a list the attr is **one level's step past the level above**, not its absolute position: both formats store a level's indent absolutely (`w:lvl/w:pPr/w:ind w:left`, the label-alignment `fo:margin-left`) while the editor nests one 1.27cm step per level, so import subtracts the level above and the ODF export adds the running sum back per level. That is what lets a file's own level geometry survive at any depth — measured, a 0.5cm/−0.6cm pair puts LibreOffice's two levels at 1.77cm and 2.44cm, where the editor draws them. `indentListForward`/`indentListBackward` apply one Word/LibreOffice step to the list point (bullet + text): **forward** shifts the whole list when the cursor is in the first item or the whole list is selected (`indentListMore`), otherwise nests the item (`sinkListItem`); **backward** is the inverse — it peels back the innermost list's own `indent` first (`indentListLess`), then lifts a *nested* item out one level (`liftListItem`), and at the base level (no margin, not nested) does nothing. So a list point steps left one tab-stop at a time and never gets dissolved into a paragraph — Shift-Tab keeps list points as list points. They return false outside a list. `indentRight` is the mirror of `indent` (`fo:margin-right` / `w:ind w:right`; ODF export rides the PBX sentinel, since odf-kit has no right-indent option). `setIndent(cm)`/`setIndentRight(cm)`/`setIndentFirst(cm)` write one indent outright (what the ruler drags; the buttons step relatively) — all go through the same `write` walk. The exported `listContext(state)` (cursor in a list? whole list targeted? innermost list indent? list nested inside a parent list item?) is their shared helper. Both the toolbar's `changeIndent` and the **Tab**/**Shift-Tab** keymap call these: in a list Tab/Shift-Tab run `indentListForward`/`indentListBackward`; outside a list Tab inserts a real tab character (`\t`, rendered via the document's `tab-size`) and Shift-Tab runs `indentLess` (`changeIndent` uses `indentMore`/`indentLess`). The keymap bails inside table cells so the Table extension's Tab keeps navigating between cells. `Indent` sets `priority: 1000` so its Tab/Shift-Tab win over `ListItem`'s built-in sink/lift bindings — without it, TipTap's reverse-then-priority-sort lets the later-registered `ListItem` handle the key first (its `liftListItem` would dissolve a top-level list item into a paragraph on Shift-Tab).
|
||||
- **`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. **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 else (`ODF_IMPLIED_TAB_CM`, `DOCX_IMPLIED_TAB_CM`).
|
||||
- **`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 **absolutely positioned** at that indent, because in flow it grew every list line by a pixel. `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.
|
||||
|
||||
@@ -439,7 +439,11 @@ export const TabStops = Extension.create({
|
||||
try { layout = measure(view); } catch { return; }
|
||||
const { widths, breaks } = layout;
|
||||
const next = widths.map((w) => `${w.pos}:${w.width}:${w.leader ?? ''}`).join(',') + `|${breaks.join(',')}`;
|
||||
if (next === key) return;
|
||||
// Replacing the document maps every decoration away, so a layout identical to
|
||||
// the last one — the two forms of one letter template — has to be dispatched
|
||||
// again rather than recognised as already applied.
|
||||
const live = tabStopsKey.getState(view.state)?.find().length ?? 0;
|
||||
if (next === key && live === widths.length + breaks.length) return;
|
||||
key = next;
|
||||
const decos: Decoration[] = widths.map((w) =>
|
||||
// margin-LEFT: the gap is the tab's own advance, so a caret placed after
|
||||
|
||||
Reference in new issue
Block a user