fix(editor): prevent scroll-to-top when clicking toolbar buttons#2236
fix(editor): prevent scroll-to-top when clicking toolbar buttons#2236
Conversation
…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.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: dbce0f64b7
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| win.requestAnimationFrame(() => { | ||
| if (win.scrollX !== beforeX || win.scrollY !== beforeY) { | ||
| win.scrollTo(beforeX, beforeY); | ||
| } |
There was a problem hiding this comment.
Skip RAF scroll reset after deliberate viewport moves
The new unconditional requestAnimationFrame callback always restores window to beforeX/beforeY, which also undoes legitimate scrolling triggered immediately after view.focus(). In this codebase, commands like search navigation call editor.view.focus() and then intentionally move the viewport (search.js goToFirstMatch uses presentationEditor.scrollToPosition(...) right after focus), so on pages where window is the scroll container the next-frame reset snaps the user back to the old position and breaks navigation-to-match behavior.
Useful? React with 👍 / 👎.
Problem
When SuperDoc is embedded in a page where the window is the scroll container (not a div with
overflow: auto), clicking any toolbar button scrolls the page to the top. This affects the Version Tester app and any external integration where the editor lives in a scrollable page.Fixes SD-1780 and SD-1785.
How we found it
The bug was reported through the Version Tester app. It was not reproducible in the SuperDoc dev app because the dev app uses a div with
overflow: autoas its scroll container, keepingwindow.scrollYat 0 at all times. The Version Tester loads SuperDoc inside a page where the window itself scrolls, which exposed the problem.The dev app has a built-in scroll test mode (
?scrolltest=1) that sets.dev-app__maintooverflow: visible, making the window the scroll container. This mode reproduces the exact same behavior.Root cause
The hidden ProseMirror editor is positioned at
position: fixed; top: 0; left: -9999px. When a toolbar button is clicked:view.focus()to refocus the PM editoronFocushandler runsupdateStatethenselectionToDOM, which manipulateswindow.getSelection()top: 0wrapHiddenEditorFocuswrapper finishes its synchronous scroll restorationSolution
Two complementary changes:
1. Toolbar mousedown prevention (primary fix)
Added a
@mousedownhandler on the toolbar root div that callspreventDefault()for non-input elements. This prevents the browser from transferring focus when clicking toolbar buttons. The PM editor stays focused, no blur/refocus cycle occurs, and no scroll happens.Input elements (font size input, search bar) are excluded so they still receive native focus and cursor placement.
This is the standard pattern used by ProseMirror's own example editor, Tiptap, and most WYSIWYG editors to keep the editor focused during toolbar interaction.
2. RAF backup in wrapHiddenEditorFocus (safety net)
After the synchronous scroll restoration in the focus wrapper, a
requestAnimationFramecallback now checks and restores scroll positions again. This catches async browser scroll triggered byselectionToDOMfrom any code path, not just toolbar clicks.How to reproduce
pnpm devhttp://localhost:9096/?scrolltest=1Why this approach
The mousedown prevention is the correct fix because it eliminates the problem at the source. Preventing focus transfer means ProseMirror never goes through a blur/focus cycle, so
selectionToDOMnever fires in the context of a focus event, and the browser never has a reason to scroll.The RAF backup is a defense-in-depth measure. Other code paths (programmatic focus calls, browser extensions, accessibility tools) could still trigger the same async scroll behavior. The RAF callback catches these cases without adding complexity to every call site.
Test plan
?scrolltest=1: Bold, Italic, Underline, Color buttons all maintain scroll position