Skip to content

fix(editor): prevent scroll-to-top when clicking toolbar buttons#2236

Open
tupizz wants to merge 1 commit intomainfrom
tadeu/sd-1780-fix-toolbar-scroll-to-top
Open

fix(editor): prevent scroll-to-top when clicking toolbar buttons#2236
tupizz wants to merge 1 commit intomainfrom
tadeu/sd-1780-fix-toolbar-scroll-to-top

Conversation

@tupizz
Copy link
Contributor

@tupizz tupizz commented Mar 2, 2026

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: auto as its scroll container, keeping window.scrollY at 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__main to overflow: 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:

  1. The browser transfers focus from the PM editor to the toolbar button (default mousedown behavior)
  2. The toolbar calls view.focus() to refocus the PM editor
  3. ProseMirror's internal onFocus handler runs updateState then selectionToDOM, which manipulates window.getSelection()
  4. The browser asynchronously scrolls to make the DOM selection visible in the hidden editor at top: 0
  5. This async scroll fires AFTER the wrapHiddenEditorFocus wrapper finishes its synchronous scroll restoration

Solution

Two complementary changes:

1. Toolbar mousedown prevention (primary fix)

Added a @mousedown handler on the toolbar root div that calls preventDefault() 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 requestAnimationFrame callback now checks and restores scroll positions again. This catches async browser scroll triggered by selectionToDOM from any code path, not just toolbar clicks.

How to reproduce

  1. Run pnpm dev
  2. Open http://localhost:9096/?scrolltest=1
  3. Scroll down past the spacer to reach the editor
  4. Click any toolbar button (Bold, Italic, etc.)
  5. Before fix: page scrolls to top
  6. After fix: scroll position stays put

Why 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 selectionToDOM never 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

  • All existing tests pass (733 files, 7411 tests)
  • New test verifies RAF scheduling in focus wrapper
  • Verified in browser with ?scrolltest=1: Bold, Italic, Underline, Color buttons all maintain scroll position
  • Font size input still receives focus and accepts text input
  • Verify in Version Tester app

…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.
Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +750 to +753
win.requestAnimationFrame(() => {
if (win.scrollX !== beforeX || win.scrollY !== beforeY) {
win.scrollTo(beforeX, beforeY);
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants