From 41a9dec4beec5155121d83ae9228b65c48f9b684 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Mon, 2 Mar 2026 15:12:16 -0300 Subject: [PATCH 1/3] fix(editor): prevent focus loss when typing in header/footer editors (SD-1993) Replace unconditional appendChild calls in DomPainter.updateVirtualWindow() with cursor-based DOM reconciliation that skips moves for elements already in the correct position. Add focus save/restore safety net in PresentationEditor.#flushRerenderQueue for edge cases. --- .../painters/dom/src/renderer.ts | 17 ++++++++++++--- .../presentation-editor/PresentationEditor.ts | 21 +++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index f045773d33..0cb8a5d43f 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -1709,8 +1709,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'); @@ -1721,10 +1726,16 @@ 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); + 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 3baaf3d5f4..eba105fcdb 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -3165,6 +3165,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 { @@ -3172,6 +3180,19 @@ export class PresentationEditor extends EventEmitter { if (this.#pendingDocChange) { this.#scheduleRerender(); } + + // Restore focus if the H/F editor lost it during rerender. + if (hadHfFocus && activeHfEditor?.view) { + 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 + } + } + } } } From 796c23a6e27bb399f2ac8e44c7dac6ce7bc55088 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Tue, 3 Mar 2026 19:01:48 -0300 Subject: [PATCH 2/3] fix(editor): guard H/F focus restore against stale session Check that the H/F session is still active with the same editor before restoring focus. Prevents stealing focus back to a stale editor if the user exits H/F mode during the async rerender. --- .../src/core/presentation-editor/PresentationEditor.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index eba105fcdb..f4f13f1232 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -3182,7 +3182,9 @@ export class PresentationEditor extends EventEmitter { } // Restore focus if the H/F editor lost it during rerender. - if (hadHfFocus && activeHfEditor?.view) { + // 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)) { From 68d0e2e5732207df6f0aa0e4d66dbb89b7daf7c9 Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Thu, 5 Mar 2026 07:56:14 -0300 Subject: [PATCH 3/3] fix(painter-dom): add clarifying comment for cursor reconciliation Address review feedback: clarify why cursor is not advanced when inserting a gap spacer in updateVirtualWindow. --- packages/layout-engine/painters/dom/src/renderer.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 0cb8a5d43f..61739a8ecb 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -1726,6 +1726,8 @@ 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); + // 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)!;