diff --git a/src/App.svelte b/src/App.svelte index 0e7ae2d..4766061 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -128,6 +128,9 @@ let pageFormat: PageFormat = $state(loadPageFormat()); let tabIntervalCm = $state(loadTabInterval()); let spacingModel: SpacingModel = $state(loadSpacingModel()); + // Bumped for each document opened (import, new); Editor hides the page until that + // document's pagination holds still. + let documentEpoch = $state(0); // The document's spell-check language; round-trips through the .odt. The effect // below persists it and switches the shared spell controller (loads the dict). @@ -447,6 +450,7 @@ if (!editor) return; if (isDocNonEmpty() && !confirm(t().dialogs.confirmNew)) return; editor.commands.setContent('

'); // onUpdate fires → autosave + documentEpoch++; resetHistory(); // Reset everything to defaults; the $effects persist these. hfActive = null; @@ -511,6 +515,7 @@ void saveEmbeddedFonts(result.fonts); editor.commands.setContent(result.content); // onUpdate fires → autosave + documentEpoch++; resetHistory(); // Adopt the opened file's name as the document name (drives the save filename). if (sourceName) documentName = stripOdtExtension(sourceName).replace(/\.docx$/i, ''); @@ -1098,6 +1103,7 @@ onchange={handleImportFile} /> = 0 && ...)` to re-evaluate `isActive`/value checks reactively without subscribing to ProseMirror directly. +## The settle gate (`Editor.svelte`) + +A pagination pass measures the DOM the previous one changed, so a freshly opened document +re-flows a few times before it holds still — a columns chain longest (measured: 12 layouts +over 550ms). `.paper.settling` hides the page's contents (`visibility`, so every pass still +measures the same boxes) until `layoutSignature()` repeats twice or `SETTLE_CAP_MS` passes; +the reader gets one layout instead of the search for it. It re-arms on the `documentEpoch` +prop, which `App.svelte` bumps per document it opens — never on an edit. The signature samples +at most `SETTLE_SAMPLES` blocks: reading every one each frame starves the layout it waits for +(the 458-page guide then never settled at all). + ## Zoom (`Editor.svelte`) Zoom is a CSS `transform: scale()` on `.paper` (layout and pagination always run at 100%, so they stay stable across zoom — this replaced an earlier CSS `zoom` approach that re-ran layout at every scale). A transform reserves no layout space, so `.paper-scaler` reserves the scaled footprint to drive scrollbars and horizontal centering — it's sized in the `$effect.pre`, so it reaches the DOM in the same flush as the transform and the anchor pass below measures the right geometry. The applied zoom is throttled to one DOM write per animation frame. Range 20–300% (`MIN_ZOOM`/`MAX_ZOOM`/`clampZoom` in `utils/zoom.ts`), persisted in `localStorage['odf-editor-zoom']`. diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index 6125574..971744f 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -39,7 +39,7 @@ let { editor = $bindable(), tick = $bindable(0), currentPage = $bindable(1), numPages = $bindable(1), zoom = 100, onZoom, showFormattingMarks = false, showRuler = true, pageMargins = DEFAULT_MARGINS, orientation = 'portrait', - pageFormat = 'A4', tabIntervalCm = DEFAULT_TAB_INTERVAL_CM, spacingModel = 'add', + pageFormat = 'A4', tabIntervalCm = DEFAULT_TAB_INTERVAL_CM, spacingModel = 'add', documentEpoch = 0, headerDoc = $bindable(null), footerDoc = $bindable(null), hfDistances = DEFAULT_HF_DISTANCES, headerFirstDoc = $bindable(null), footerFirstDoc = $bindable(null), differentFirstPage = false, headerEvenDoc = $bindable(null), footerEvenDoc = $bindable(null), differentOddEven = false, @@ -49,6 +49,8 @@ editor: Editor | null; tick: number; currentPage: number; numPages: number; zoom: number; onZoom?: (zoom: number) => void; showFormattingMarks?: boolean; showRuler?: boolean; pageMargins?: PageMargins; orientation?: Orientation; pageFormat?: PageFormat; tabIntervalCm?: number; spacingModel?: SpacingModel; + /** Bumped by App for each document it opens; re-arms the settle gate. */ + documentEpoch?: number; headerDoc?: HfDoc; footerDoc?: HfDoc; hfDistances?: HfDistances; headerFirstDoc?: HfDoc; footerFirstDoc?: HfDoc; differentFirstPage?: boolean; headerEvenDoc?: HfDoc; footerEvenDoc?: HfDoc; differentOddEven?: boolean; @@ -766,6 +768,57 @@ reader.readAsDataURL(file); } + // A pagination pass measures the DOM the previous one changed, so a freshly opened + // document re-flows a few times before it holds still (a columns chain the longest). + // The reader gets the page it settles on, not the search for it. + const SETTLE_CAP_MS = 1500; + let settling = $state(true); + let settleRaf = 0; + + // The document height pagination derives, plus a bounded sample of block placements + // — reading all of them each frame starves the very layout this waits for on a long + // document, and a reflow that moves nothing in SETTLE_SAMPLES places moves nothing. + const SETTLE_SAMPLES = 48; + + function layoutSignature(): string { + const tip = element?.querySelector('.tiptap') as HTMLElement | null; + if (!tip) return ''; + const kids = tip.children; + let sig = `${tip.style.minHeight}|${kids.length}`; + const stride = Math.max(1, Math.ceil(kids.length / SETTLE_SAMPLES)); + for (let i = 0; i < kids.length; i += stride) { + const child = kids[i] as HTMLElement; + sig += `|${child.offsetTop}:${child.offsetHeight}`; + } + return sig; + } + + function waitForSettled(): void { + cancelAnimationFrame(settleRaf); + settling = true; + const deadline = performance.now() + SETTLE_CAP_MS; + let last = ''; + let stable = 0; + const tick = () => { + const sig = layoutSignature(); + stable = sig && sig === last ? stable + 1 : 0; + last = sig; + // Two repeats, so a pass that only re-dispatches decorations doesn't count. + if (stable >= 2 || performance.now() > deadline) { + settling = false; + return; + } + settleRaf = requestAnimationFrame(tick); + }; + settleRaf = requestAnimationFrame(tick); + } + + // Re-arms on every document the app loads (mount included), never on an edit. + $effect(() => { + documentEpoch; + waitForSettled(); + }); + onMount(() => { // Start with empty history lists — loaded content is not an undoable edit. resetHistoryLog(); @@ -877,6 +930,7 @@ } onDestroy(() => { + cancelAnimationFrame(settleRaf); if (zoomRaf !== null) cancelAnimationFrame(zoomRaf); cancelAnimationFrame(marginRecalcRaf); cancelAnimationFrame(marksRecalcRaf); @@ -900,7 +954,7 @@
-
+
diff --git a/src/lib/editor/extensions/columnsFlow.ts b/src/lib/editor/extensions/columnsFlow.ts index 75cb27e..b81da3b 100644 --- a/src/lib/editor/extensions/columnsFlow.ts +++ b/src/lib/editor/extensions/columnsFlow.ts @@ -201,6 +201,10 @@ export const ColumnsFlow = Extension.create({ } function dispatchFlow(tr: Transaction): void { + // A split or join replaces the nodes the height decorations sit on, so + // ProseMirror drops them; the cache key would keep them from coming back + // and leave the fragment measuring its balanced height instead of its slot. + lastDecoKey = ''; editorView.dispatch(tr.setMeta('addToHistory', false).setMeta(FLOW_TX, true)); } @@ -318,7 +322,10 @@ export const ColumnsFlow = Extension.create({ // are judged from the content itself (per-column share of the flow). const usedPerColumn = contentHeightPx(children, scale) / count; - if (usedPerColumn > available + 1) { + // 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 + // the decoration pins the height — split with the budget's own headroom. + if (usedPerColumn > available - SAFETY_PX) { // Overflow. Same first-block fit test (incl. margin) as pageBreaks' // push decision — the two must agree, or split/push/join cycle. const first = Math.max(blockHeightPx(children[0], scale) / count, MIN_BLOCK_PX); diff --git a/src/styles/editor.css b/src/styles/editor.css index 553af79..675db86 100644 --- a/src/styles/editor.css +++ b/src/styles/editor.css @@ -51,6 +51,13 @@ background-repeat: repeat-y; } +/* While a freshly opened document is still finding its pagination (Editor.svelte). + Hidden, not removed: every pass measures the same boxes either way, and the sheet + .paper paints stays up, so the page appears once instead of shuffling into place. */ +.paper.settling > * { + visibility: hidden; +} + .paper .tiptap { /* Driven by --user-margin-* set on :root from the Layout panel (pageMargins.ts). Fallbacks preserve the original hardcoded layout when unset. */