From 33f759c282e5b6303055bda1c08a1a1ec8339a2e Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Thu, 19 Mar 2026 13:59:30 -0300 Subject: [PATCH] fix(super-editor): prevent cursor jump when changing font from toolbar When the user selects a font from the toolbar dropdown, the hidden ProseMirror editor loses focus. On re-focus, the browser places the DOM selection at an arbitrary position inside the off-screen contenteditable (left: -9999px). ProseMirror's DOMObserver reads this stale position via a selectionchange event and overwrites PM state, causing the cursor to jump to position 2 (near the top of the document). The wrapped focus in PresentationEditor only called view.dom.focus() without calling selectionToDOM(), unlike ProseMirror's original focus() which stops the observer, focuses, syncs the selection, then restarts. Fix: call view.domObserver.suppressSelectionUpdates() after focusing when the editor was not previously focused. This tells PM to re-apply its own selection to the DOM instead of reading the stale browser position. --- .../core/presentation-editor/PresentationEditor.ts | 11 +++++++++++ .../tests/PresentationEditor.draggableFocus.test.ts | 6 ++++++ .../tests/PresentationEditor.focusWrapping.test.ts | 6 ++++++ 3 files changed, 23 insertions(+) diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index de9ab24639..5716762247 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -753,6 +753,7 @@ export class PresentationEditor extends EventEmitter { const beforeX = win.scrollX; const beforeY = win.scrollY; + const alreadyFocused = view.hasFocus(); let focused = false; // Strategy 1: Try focus with preventScroll option (modern browsers) @@ -791,6 +792,16 @@ export class PresentationEditor extends EventEmitter { } } + // When the editor was not focused before, the browser places the DOM selection + // at an arbitrary position inside the off-screen contenteditable. ProseMirror's + // DOMObserver would read this stale position via a selectionchange event and + // overwrite PM state, causing the cursor to jump. Suppress selection updates + // for the next 50ms so PM re-applies its own selection to the DOM instead. + if (!alreadyFocused) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (view as any).domObserver.suppressSelectionUpdates(); + } + // Restore scroll position if any focus attempt changed it if (win.scrollX !== beforeX || win.scrollY !== beforeY) { win.scrollTo(beforeX, beforeY); diff --git a/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.draggableFocus.test.ts b/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.draggableFocus.test.ts index f4d8b95657..5183eaa622 100644 --- a/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.draggableFocus.test.ts +++ b/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.draggableFocus.test.ts @@ -118,6 +118,12 @@ vi.mock('../../Editor.js', () => { focus: function () { // Plain function that can be wrapped }, + hasFocus: function () { + return domElement === domElement.ownerDocument.activeElement; + }, + domObserver: { + suppressSelectionUpdates: vi.fn(), + }, dispatch: vi.fn(), }, options: { diff --git a/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.focusWrapping.test.ts b/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.focusWrapping.test.ts index 38ce60aadd..394f43d6de 100644 --- a/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.focusWrapping.test.ts +++ b/packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.focusWrapping.test.ts @@ -133,6 +133,12 @@ vi.mock('../../Editor.js', () => { focus: function () { // Plain function that can be wrapped }, + hasFocus: function () { + return domElement === domElement.ownerDocument.activeElement; + }, + domObserver: { + suppressSelectionUpdates: vi.fn(), + }, dispatch: vi.fn(), }, options: {