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; }, }, }); }, });