Skip to content

Commit dbce0f6

Browse files
committed
fix(editor): prevent scroll-to-top when clicking toolbar buttons (SD-1780)
Toolbar buttons had tabindex="0" but no mousedown prevention, so clicking them caused the browser to transfer focus away from the hidden ProseMirror editor. The subsequent refocus triggered ProseMirror's selectionToDOM, and the browser asynchronously scrolled to make the DOM selection visible inside the hidden editor at position:fixed top:0. This only manifested when the window was the scroll container (not a div with overflow:auto). Two fixes applied: 1. Toolbar mousedown preventDefault for non-input elements. This is the standard pattern used by ProseMirror's example editor, Tiptap, and most WYSIWYG editors. It keeps the PM editor focused throughout toolbar interactions. 2. requestAnimationFrame safety net in wrapHiddenEditorFocus. After the synchronous scroll restoration, a RAF callback catches any async browser scroll caused by layout reflow post-focus.
1 parent e925ef9 commit dbce0f6

3 files changed

Lines changed: 48 additions & 1 deletion

File tree

packages/super-editor/src/components/toolbar/Toolbar.vue

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,32 @@ const handleCommand = ({ item, argument, option }) => {
7878
const restoreSelection = () => {
7979
proxy.$toolbar.activeEditor?.commands?.restoreSelection();
8080
};
81+
82+
/**
83+
* Prevents the browser's default focus-transfer behavior when clicking toolbar buttons.
84+
*
85+
* Without this, clicking a toolbar button moves focus from the hidden ProseMirror editor
86+
* to the toolbar button element. The subsequent refocus of the PM editor can trigger
87+
* browser-native scroll adjustments that jump the page to the top — especially when
88+
* the window (not a div) is the scroll container.
89+
*
90+
* Input elements are excluded so they still receive native focus and cursor placement.
91+
*/
92+
const handleToolbarMousedown = (e) => {
93+
if (e.target.closest('input, textarea, [contenteditable="true"]')) return;
94+
e.preventDefault();
95+
};
8196
</script>
8297
8398
<template>
84-
<div class="superdoc-toolbar" :key="toolbarKey" role="toolbar" aria-label="Toolbar" data-editor-ui-surface>
99+
<div
100+
class="superdoc-toolbar"
101+
:key="toolbarKey"
102+
role="toolbar"
103+
aria-label="Toolbar"
104+
data-editor-ui-surface
105+
@mousedown="handleToolbarMousedown"
106+
>
85107
<n-config-provider abstract preflight-style-disabled>
86108
<ButtonGroup
87109
tabindex="0"

packages/super-editor/src/core/presentation-editor/PresentationEditor.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -743,6 +743,15 @@ export class PresentationEditor extends EventEmitter {
743743
if (win.scrollX !== beforeX || win.scrollY !== beforeY) {
744744
win.scrollTo(beforeX, beforeY);
745745
}
746+
747+
// Safety net: the browser may asynchronously scroll after ProseMirror's
748+
// selectionToDOM() modifies the DOM selection inside the hidden editor.
749+
// A single requestAnimationFrame catches this post-layout scroll.
750+
win.requestAnimationFrame(() => {
751+
if (win.scrollX !== beforeX || win.scrollY !== beforeY) {
752+
win.scrollTo(beforeX, beforeY);
753+
}
754+
});
746755
};
747756
}
748757

packages/super-editor/src/core/presentation-editor/tests/PresentationEditor.focusWrapping.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,22 @@ describe('PresentationEditor - Focus Wrapping (#wrapHiddenEditorFocus)', () => {
355355
editor.editor.view.focus();
356356
}).not.toThrow();
357357
});
358+
359+
it('schedules requestAnimationFrame as async scroll safety net', () => {
360+
editor = new PresentationEditor({
361+
element: container,
362+
documentId: 'test-doc',
363+
pageSize: { w: 612, h: 792 },
364+
});
365+
366+
const rafSpy = vi.spyOn(window, 'requestAnimationFrame');
367+
368+
editor.editor.view.focus();
369+
370+
// RAF should be scheduled to catch async browser scroll after focus
371+
expect(rafSpy).toHaveBeenCalledTimes(1);
372+
expect(rafSpy).toHaveBeenCalledWith(expect.any(Function));
373+
});
358374
});
359375

360376
describe('mock detection', () => {

0 commit comments

Comments
 (0)