From db62256a99d2261d495bff1546440f90c3e7cac9 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Mon, 10 Aug 2026 08:11:13 +0000 Subject: [PATCH] fix: a tab's advance is decided in one pass, not raced into place Two causes: rangeWidth added a rect per inline box *and* per text node, so a segment inside a span measured double; and the pen was read back from the DOM, where it already carried the previous pass's advance. Carrying the pen along the line settles both, and the layout no longer depends on how many frames pagination takes. --- src/lib/editor/extensions/pageBreaks.ts | 4 +- src/lib/editor/extensions/tabStops.ts | 61 +++++++++++++++++-------- 2 files changed, 46 insertions(+), 19 deletions(-) diff --git a/src/lib/editor/extensions/pageBreaks.ts b/src/lib/editor/extensions/pageBreaks.ts index 8513213..e76544a 100644 --- a/src/lib/editor/extensions/pageBreaks.ts +++ b/src/lib/editor/extensions/pageBreaks.ts @@ -189,7 +189,9 @@ function getPageForY(y: number, cycle: number): number { // answer to the last pass from a fresh change. type PageBreakState = { recalc: number; edit: number }; -const pageBreakKey = new PluginKey('pageBreaks'); +// Exported for tabStops.ts: a placement change moves lines, so the tab advances +// measured before it are stale. +export const pageBreakKey = new PluginKey('pageBreaks'); // Transaction meta flag: set it (e.g. when page margins change) to force a // pagination recompute even though the document content is unchanged. diff --git a/src/lib/editor/extensions/tabStops.ts b/src/lib/editor/extensions/tabStops.ts index fcaaa5f..247ea18 100644 --- a/src/lib/editor/extensions/tabStops.ts +++ b/src/lib/editor/extensions/tabStops.ts @@ -3,7 +3,7 @@ import { Plugin, PluginKey } from '@tiptap/pm/state'; import { Decoration, DecorationSet, type EditorView } from '@tiptap/pm/view'; import type { EditorState } from '@tiptap/pm/state'; import type { Node as PmNode } from '@tiptap/pm/model'; -import { FORCE_PAGE_RECALC } from './pageBreaks'; +import { FORCE_PAGE_RECALC, pageBreakKey } from './pageBreaks'; import { PX_PER_CM } from '../../storage/pageMargins'; // Per-paragraph tab stops. CSS only has the fixed `tab-size` grid, so a tab that @@ -178,36 +178,50 @@ function measure(view: EditorView): TabLayout { if (!stops.length) return false; const blockEnd = pos + node.nodeSize - 1; + // Where the pen stands (cm from the text margin) after the tab before this one. + // Carrying it along the line is what makes one pass self-consistent: read from the + // DOM instead and every tab but the line's first measures the advance the *previous* + // pass gave the one before it, so the layout takes another pass to settle — and on a + // long document, where pagination keeps moving lines, it may never get a stable one. + let pen: number | null = null; + let lineTop = NaN; for (let t = 0; t < tabs.length; t++) { const tabPos = tabs[t]; - // Side -1 measures at the end of the content BEFORE the tab, outside the tab's - // own span — so the margin this pass applies can't contaminate the next one's - // reading of where the pen stands. + // Side -1 measures at the end of the content BEFORE the tab. A new line — a hard + // break, or a wrap — starts the pen over at what the DOM shows there. const start = view.coordsAtPos(tabPos, -1); - const xCm = (start.left - originX) / scale / PX_PER_CM; - // Custom stops replace the default grid to their left; past the last one the - // CSS grid already does the right thing, so that tab stays undecorated. - const stop = stops.find((s) => s.pos > xCm + 0.01); - if (!stop) continue; - let width = (stop.pos - xCm) * PX_PER_CM; + if (pen == null || Math.abs(start.top - lineTop) > 1) pen = (start.left - originX) / scale / PX_PER_CM; + lineTop = start.top; + // Custom stops replace the default grid to their left; past the last one the CSS + // grid already does the right thing, so that tab stays undecorated — and the pen + // has to be read off the DOM again at the next one. + const stop = stops.find((s) => s.pos > (pen as number) + 0.01); + if (!stop) { pen = null; continue; } + let width = (stop.pos - pen) * PX_PER_CM; + const segEnd = t + 1 < tabs.length ? tabs[t + 1] : blockEnd; + const segCm = segEnd > tabPos + 1 ? rangeWidth(view, tabPos + 1, segEnd) / scale / PX_PER_CM : 0; if (stop.align !== 'left') { - let segEnd = t + 1 < tabs.length ? tabs[t + 1] : blockEnd; - if (stop.align === 'decimal') segEnd = decimalPos(node, pos, tabPos + 1, segEnd) ?? segEnd; - let back = segEnd > tabPos + 1 ? rangeWidth(view, tabPos + 1, segEnd) / scale : 0; + const alignEnd = stop.align === 'decimal' + ? decimalPos(node, pos, tabPos + 1, segEnd) ?? segEnd + : segEnd; + let back = alignEnd > tabPos + 1 ? rangeWidth(view, tabPos + 1, alignEnd) / scale : 0; if (stop.align === 'center') back /= 2; width -= back; } out.push({ pos: tabPos, width: Math.max(0, Math.round(width * 100) / 100), leader: normalizeLeader(stop.leader) }); + // The segment sits behind a left stop, astride a centred one and ahead of the rest. + pen = stop.align === 'left' ? stop.pos + segCm : stop.align === 'center' ? stop.pos + segCm / 2 : stop.pos; } return false; }); return { widths: out, breaks }; } -// Natural width of a doc range: the sum of its client rects, so a segment pushed onto -// the next line still reads its own width. Measuring its span end-to-end instead would -// feed a wrapped line back in, and the alignment could never recover. +// Natural width of a doc range: the extent of each line it covers, added up — so a +// segment pushed onto the next line still reads its own width. Not the sum of the rects: +// a range crossing an inline element yields one for the element's box and one for the +// text inside it, and adding those counts the text twice. function rangeWidth(view: EditorView, from: number, to: number): number { // Biased outwards, or the range swallows the tab's own span at either end. const a = view.domAtPos(from, 1); @@ -215,8 +229,16 @@ function rangeWidth(view: EditorView, from: number, to: number): number { const range = document.createRange(); range.setStart(a.node, a.offset); range.setEnd(b.node, b.offset); + const rows = new Map(); + for (const rect of Array.from(range.getClientRects())) { + if (!rect.width) continue; + const key = Math.round(rect.top); + const row = rows.get(key); + if (row) { row.left = Math.min(row.left, rect.left); row.right = Math.max(row.right, rect.right); } + else rows.set(key, { left: rect.left, right: rect.right }); + } let width = 0; - for (const rect of Array.from(range.getClientRects())) width += rect.width; + for (const row of rows.values()) width += row.right - row.left; return width; } @@ -371,7 +393,10 @@ export const TabStops = Extension.create({ apply(tr, old) { const next = tr.getMeta(tabStopsKey) as DecorationSet | undefined; if (next) return next; - if (tr.getMeta(FORCE_PAGE_RECALC)) forced++; + // A pagination placement change moves lines, so the advances measured before + // it are stale — and pagination settles over many frames on a long document, + // long after two passes of ours have agreed on the layout of the moment. + if (tr.getMeta(FORCE_PAGE_RECALC) || tr.getMeta(pageBreakKey)) forced++; return old.map(tr.mapping, tr.doc); }, },