fix: a document opens on the page it settles on
A pagination pass measures the DOM the last one changed, so a fresh document searched for its layout in public — 12 of them over 550ms on a two-column file, a phantom third page among them. Two real causes: the flow's overflow test had no headroom, so it read its own last answer (880px fits undecorated, 913px overflows once the decoration pins the column height), and a split dropped the height decorations without clearing their cache key. The rest is inherent to measuring rendered DOM, so the page stays hidden until its layout repeats: one visible layout instead of 12, and 21-139ms where there are no columns.
This commit is contained in:
1 parent
efd4e9c8a7
commit
b35551acd0
5 files changed
+88
-3
No files matched your search
@@ -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('<p></p>'); // 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}
|
||||
/>
|
||||
<EditorComponent
|
||||
{documentEpoch}
|
||||
bind:editor
|
||||
bind:tick
|
||||
bind:currentPage
|
||||
|
||||
@@ -37,6 +37,17 @@ App.svelte – owns app-level state (theme, zoom, margins, or
|
||||
|
||||
`Editor.svelte` exposes `editor`, `tick`, `currentPage`, and `numPages` as bindable props to `App.svelte`, and takes `zoom`, `showFormattingMarks`, `pageMargins`, and `orientation` as inputs. `tick` is incremented on every TipTap transaction; toolbar components use `$derived(tick >= 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']`.
|
||||
|
||||
@@ -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 @@
|
||||
<!-- Reserves the scaled scroll footprint; the transform on .paper reserves none.
|
||||
Before the first measure (size 0) it's left unsized so .paper isn't clipped. -->
|
||||
<div class="paper-scaler" style={scaledWidth ? `width: ${scaledWidth}px; height: ${scaledHeight}px;` : ''}>
|
||||
<div bind:this={paperEl} class="paper" data-spacing-model={spacingModel} class:show-formatting-marks={showFormattingMarks} class:hf-editing={hfActive} style="transform: scale({appliedZoom / 100});">
|
||||
<div bind:this={paperEl} class="paper" data-spacing-model={spacingModel} class:show-formatting-marks={showFormattingMarks} class:hf-editing={hfActive} class:settling style="transform: scale({appliedZoom / 100});">
|
||||
<!-- Dedicated mount point that TipTap fully owns — keeping it free of Svelte
|
||||
content avoids Svelte and ProseMirror fighting over the same parent's DOM. -->
|
||||
<div bind:this={element} class="tiptap-host"></div>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user