diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 0bfafda773..287bb0f288 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -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'); @@ -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); } 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; } diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index 5f72bb83dd..0d920ec64a 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -3310,6 +3310,14 @@ 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 { @@ -3317,6 +3325,21 @@ export class PresentationEditor extends EventEmitter { 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 + } + } + } } }