| import { Extension } from "@tiptap/core"; | |
| import { Plugin, PluginKey } from "@tiptap/pm/state"; | |
| import { ySyncPluginKey } from "@tiptap/y-tiptap"; | |
| /** | |
| * Prevents ProseMirror's scrollIntoView from firing after remote Yjs sync | |
| * transactions. The y-tiptap binding calls tr.scrollIntoView() whenever | |
| * _isLocalCursorInView() is true, but that check uses window dimensions | |
| * instead of the actual scroll container. This fights the user's manual | |
| * scroll, causing a "stuck then resume" feeling. | |
| * | |
| * Strategy: a filterTransaction hook sets a module-level flag when it | |
| * sees a remote-origin sync transaction, and handleScrollToSelection | |
| * suppresses the scroll while the flag is set. The flag is cleared | |
| * asynchronously after the synchronous dispatch cycle completes. | |
| */ | |
| let _blockScroll = false; | |
| export const ScrollGuard = Extension.create({ | |
| name: "scrollGuard", | |
| addProseMirrorPlugins() { | |
| return [ | |
| new Plugin({ | |
| key: new PluginKey("scroll-guard"), | |
| filterTransaction(tr) { | |
| const meta = tr.getMeta(ySyncPluginKey); | |
| if (meta?.isChangeOrigin) { | |
| _blockScroll = true; | |
| queueMicrotask(() => { _blockScroll = false; }); | |
| } | |
| return true; | |
| }, | |
| }), | |
| ]; | |
| }, | |
| addOptions() { | |
| return {}; | |
| }, | |
| onBeforeCreate() { | |
| const existing = this.editor.options.editorProps?.handleScrollToSelection; | |
| this.editor.setOptions({ | |
| editorProps: { | |
| ...this.editor.options.editorProps, | |
| handleScrollToSelection(view) { | |
| if (_blockScroll) return true; | |
| if (existing) return existing.call(this, view); | |
| return false; | |
| }, | |
| }, | |
| }); | |
| }, | |
| }); | |