From 16cff7b0e03377d51f69ce2ac903ef18a7ab0b25 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Thu, 28 May 2026 18:50:47 +0200 Subject: [PATCH] fix: line shakes when text is entered --- src/lib/editor/pageBreaks.ts | 53 ++++++++++++++++++++++++------------ 1 file changed, 35 insertions(+), 18 deletions(-) diff --git a/src/lib/editor/pageBreaks.ts b/src/lib/editor/pageBreaks.ts index d197ba8..644748c 100644 --- a/src/lib/editor/pageBreaks.ts +++ b/src/lib/editor/pageBreaks.ts @@ -88,6 +88,7 @@ export const PageBreaks = Extension.create({ let decorations = DecorationSet.empty; let isUpdating = false; let rafId: number | null = null; + let lastPlacementsKey = ''; const plugin = new Plugin({ key: pageBreakKey, @@ -450,31 +451,47 @@ export const PageBreaks = Extension.create({ }); if (spacerHeight > 0 && spacerDocPos !== null) { - placements.push({ docPos: spacerDocPos, height: spacerHeight }); - placementsDebug.push({ leafIndex: i, docPos: spacerDocPos, height: spacerHeight, reason }); - cumulativeShift += spacerHeight; + // Round to integer px: the browser renders the spacer at integer + // offsetHeight regardless, so storing the unrounded value would + // make the model disagree with the DOM on the next pass and + // cause 1-px shake on every keystroke at non-100 % zoom. + const h = Math.round(spacerHeight); + if (h > 0) { + placements.push({ docPos: spacerDocPos, height: h }); + placementsDebug.push({ leafIndex: i, docPos: spacerDocPos, height: h, reason }); + cumulativeShift += h; + } } else if (reason !== 'fits') { placementsDebug.push({ leafIndex: i, docPos: spacerDocPos, height: spacerHeight, reason }); } } - const doc = editorView.state.doc; - const decoArray: Decoration[] = placements.map((p) => { - const spacerEl = document.createElement('div'); - spacerEl.dataset.pageBreakSpacer = 'true'; - spacerEl.style.height = `${p.height}px`; - spacerEl.style.pointerEvents = 'none'; - spacerEl.style.userSelect = 'none'; - spacerEl.setAttribute('contenteditable', 'false'); - return Decoration.widget(p.docPos, spacerEl, { side: -1 }); - }); + // Skip the decoration rebuild + transaction dispatch when the + // placement set is identical to the previous pass. Most keystrokes + // don't change pagination, and re-dispatching forces ProseMirror to + // recreate the spacer DOM nodes (no `key` on the widgets) for no + // visible gain. + const placementsKey = placements.map((p) => `${p.docPos}:${p.height}`).join('|'); + if (placementsKey !== lastPlacementsKey) { + lastPlacementsKey = placementsKey; + const doc = editorView.state.doc; + const decoArray: Decoration[] = placements.map((p) => { + const spacerEl = document.createElement('div'); + spacerEl.dataset.pageBreakSpacer = 'true'; + spacerEl.style.height = `${p.height}px`; + spacerEl.style.pointerEvents = 'none'; + spacerEl.style.userSelect = 'none'; + spacerEl.setAttribute('contenteditable', 'false'); + return Decoration.widget(p.docPos, spacerEl, { side: -1 }); + }); - decorations = decoArray.length > 0 - ? DecorationSet.create(doc, decoArray) - : DecorationSet.empty; + decorations = decoArray.length > 0 + ? DecorationSet.create(doc, decoArray) + : DecorationSet.empty; - const tr = editorView.state.tr.setMeta('addToHistory', false).setMeta(pageBreakKey, true); - editorView.dispatch(tr); + const tr = editorView.state.tr.setMeta('addToHistory', false).setMeta(pageBreakKey, true); + editorView.dispatch(tr); + } let numPages = 1; if (leaves.length > 0) {