Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion packages/super-editor/src/components/toolbar/Toolbar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,32 @@ const handleCommand = ({ item, argument, option }) => {
const restoreSelection = () => {
proxy.$toolbar.activeEditor?.commands?.restoreSelection();
};

/**
* Prevents the browser's default focus-transfer behavior when clicking toolbar buttons.
*
* Without this, clicking a toolbar button moves focus from the hidden ProseMirror editor
* to the toolbar button element. The subsequent refocus of the PM editor can trigger
* browser-native scroll adjustments that jump the page to the top — especially when
* the window (not a div) is the scroll container.
*
* Input elements are excluded so they still receive native focus and cursor placement.
*/
const handleToolbarMousedown = (e) => {
if (e.target.closest('input, textarea, [contenteditable="true"]')) return;
e.preventDefault();
};
</script>

<template>
<div class="superdoc-toolbar" :key="toolbarKey" role="toolbar" aria-label="Toolbar" data-editor-ui-surface>
<div
class="superdoc-toolbar"
:key="toolbarKey"
role="toolbar"
aria-label="Toolbar"
data-editor-ui-surface
@mousedown="handleToolbarMousedown"
>
<ButtonGroup
tabindex="0"
v-if="showLeftSide"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@ export class PresentationEditor extends EventEmitter {
#errorBannerMessage: HTMLElement | null = null;
#renderScheduled = false;
#pendingDocChange = false;
#focusScrollRafId: number | null = null;
#pendingMapping: Mapping | null = null;
#isRerendering = false;
#selectionSync = new SelectionSyncCoordinator();
Expand Down Expand Up @@ -775,6 +776,21 @@ export class PresentationEditor extends EventEmitter {
if (win.scrollX !== beforeX || win.scrollY !== beforeY) {
win.scrollTo(beforeX, beforeY);
}

// Safety net: the browser may asynchronously scroll after ProseMirror's
// selectionToDOM() modifies the DOM selection inside the hidden editor.
// A single requestAnimationFrame catches this post-layout scroll.
// The RAF ID is stored so scrollToPosition() can cancel it — otherwise
// intentional scrolls (e.g. search navigation) would be undone.
if (this.#focusScrollRafId != null) {
win.cancelAnimationFrame(this.#focusScrollRafId);
}
this.#focusScrollRafId = win.requestAnimationFrame(() => {
Comment thread
tupizz marked this conversation as resolved.
this.#focusScrollRafId = null;
if (win.scrollX !== beforeX || win.scrollY !== beforeY) {
win.scrollTo(beforeX, beforeY);
}
});
};
}

Expand Down Expand Up @@ -2148,6 +2164,14 @@ export class PresentationEditor extends EventEmitter {
pos: number,
options: { block?: 'start' | 'center' | 'end' | 'nearest'; behavior?: ScrollBehavior } = {},
): boolean {
// Cancel any pending focus-scroll RAF so this intentional scroll is not undone
// by the wrapHiddenEditorFocus safety net (e.g. search navigation after focus).
if (this.#focusScrollRafId != null) {
const win = this.#visibleHost.ownerDocument?.defaultView;
if (win) win.cancelAnimationFrame(this.#focusScrollRafId);
this.#focusScrollRafId = null;
}

const activeEditor = this.getActiveEditor();
const doc = activeEditor?.state?.doc;
if (!doc) return false;
Expand Down Expand Up @@ -2523,6 +2547,15 @@ export class PresentationEditor extends EventEmitter {
}, 'Layout RAF');
}

// Cancel pending focus-scroll safety net RAF
if (this.#focusScrollRafId != null) {
safeCleanup(() => {
const win = this.#visibleHost?.ownerDocument?.defaultView ?? window;
win.cancelAnimationFrame(this.#focusScrollRafId!);
this.#focusScrollRafId = null;
}, 'Focus scroll RAF');
}

// Cancel pending decoration sync RAF
if (this.#decorationSyncRafHandle != null) {
safeCleanup(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,52 @@ describe('PresentationEditor - Focus Wrapping (#wrapHiddenEditorFocus)', () => {
editor.editor.view.focus();
}).not.toThrow();
});

it('schedules requestAnimationFrame that restores scroll on async drift', () => {
editor = new PresentationEditor({
element: container,
documentId: 'test-doc',
pageSize: { w: 612, h: 792 },
});

// Capture the RAF callback so we can invoke it manually
let rafCallback: FrameRequestCallback | null = null;
const rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
rafCallback = cb;
return 1;
});
const scrollToSpy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});

// At focus time, scrollX=0 scrollY=0 → captured as beforeX=0 beforeY=0
editor.editor.view.focus();

expect(rafSpy).toHaveBeenCalledTimes(1);
expect(rafCallback).not.toBeNull();

// Simulate the browser async-scrolling to the hidden editor (drift from 0 to 500)
Object.defineProperty(window, 'scrollY', { value: 500, configurable: true });

// Run the RAF callback — it should detect drift and restore to beforeY=0
rafCallback!(0);
expect(scrollToSpy).toHaveBeenCalledWith(0, 0);
});

it('cancels focus-scroll RAF when scrollToPosition is called', () => {
editor = new PresentationEditor({
element: container,
documentId: 'test-doc',
pageSize: { w: 612, h: 792 },
});

const cancelSpy = vi.spyOn(window, 'cancelAnimationFrame');

editor.editor.view.focus();

// scrollToPosition will fail (no layout) but should still cancel the RAF
editor.scrollToPosition(0);

expect(cancelSpy).toHaveBeenCalled();
});
});

describe('mock detection', () => {
Expand Down
Loading