Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions packages/layout-engine/painters/dom/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1826,8 +1826,13 @@ export class DomPainter {
}
this.mount.appendChild(this.bottomSpacerEl);

// Ensure mounted pages are ordered (with gap spacers) before bottom spacer.
// Ensure mounted pages are ordered (with gap spacers).
// Use cursor-based reconciliation to skip DOM moves for elements already in
// the correct position. Moving an element via appendChild/insertBefore triggers
// a browser blur event on any focused descendant, which breaks header/footer
// in-place editing where a PM editor lives inside a page element (SD-1993).
let prevIndex: number | null = null;
let cursor: ChildNode | null = this.virtualPagesEl.firstChild;
for (const idx of mounted) {
if (prevIndex != null && idx > prevIndex + 1) {
const gap = this.doc!.createElement('div');
Expand All @@ -1838,10 +1843,18 @@ export class DomPainter {
this.topOfIndex(idx) - this.topOfIndex(prevIndex) - this.virtualHeights[prevIndex] - this.virtualGap * 2;
gap.style.height = `${Math.max(0, Math.floor(gapHeight))}px`;
this.virtualGapSpacers.push(gap);
this.virtualPagesEl.appendChild(gap);
// Insert gap before cursor. cursor is NOT advanced because it still
// points at the next page element that needs to be reconciled.
this.virtualPagesEl.insertBefore(gap, cursor);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cursor not moving here is intentional but easy to misread as a bug — a short comment would help.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 68d0e2e — added a clarifying comment explaining why cursor is intentionally not advanced when inserting a gap spacer (inserted before cursor, so cursor still points at the next page element to reconcile).

}
const state = this.pageIndexToState.get(idx)!;
this.virtualPagesEl.appendChild(state.element);
if (state.element === cursor) {
// Already in the correct position. Skip the DOM mutation.
cursor = state.element.nextSibling;
} else {
// Out of order. Move to the correct position.
this.virtualPagesEl.insertBefore(state.element, cursor);
}
prevIndex = idx;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3310,13 +3310,36 @@ export class PresentationEditor extends EventEmitter {
}
this.#pendingDocChange = false;
this.#isRerendering = true;

// Capture H/F editor focus state before rerender so we can restore it if
// a DOM mutation (e.g. page re-ordering in updateVirtualWindow) causes the
// browser to blur the active header/footer editor (SD-1993).
const sessionMode = this.#headerFooterSession?.session?.mode ?? 'body';
const activeHfEditor = sessionMode !== 'body' ? this.#headerFooterSession?.activeEditor : null;
const hadHfFocus = activeHfEditor?.view?.hasFocus?.() ?? false;

try {
await this.#rerender();
} finally {
this.#isRerendering = false;
if (this.#pendingDocChange) {
this.#scheduleRerender();
}

// Restore focus if the H/F editor lost it during rerender.
// Guard: only restore if the session is still active with the same editor
// (user may have exited H/F mode during the async rerender).
if (hadHfFocus && activeHfEditor?.view && this.#headerFooterSession?.activeEditor === activeHfEditor) {
const doc = this.#visibleHost.ownerDocument;
const editorDom = activeHfEditor.view.dom;
if (doc && !editorDom.contains(doc.activeElement)) {
try {
activeHfEditor.view.focus();
} catch {
// Ignore focus errors during recovery
}
}
}
}
}

Expand Down
Loading