From c2d312888e85f8687c2526a5bb024f2da7dc2724 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 15 Apr 2026 22:58:16 -0700 Subject: [PATCH] fix: prevent ProseMirror from overwriting native drag selection in rich markdown editor (#704) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two workarounds for Chrome/ProseMirror drag-selection breakage: 1. DragSelectionGuard extension — suppresses selectionchange → selectionToDOM during active drags, blocks CellSelection transactions from prosemirror-tables, and restores native selection post-mouseup to preserve table-cell highlighting. 2. safeReactNodeViewRenderer — patches handleSelectionUpdate so selectNode/ deselectNode only fire for actual NodeSelections, preventing React re-renders mid-drag that disrupt native selection tracking (Tiptap #7647). --- .../components/editor/drag-selection-guard.ts | 186 ++++++++++++++++++ .../editor/rich-markdown-extensions.ts | 6 +- .../editor/safe-react-node-view-renderer.ts | 72 +++++++ 3 files changed, 262 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/components/editor/drag-selection-guard.ts create mode 100644 src/renderer/src/components/editor/safe-react-node-view-renderer.ts diff --git a/src/renderer/src/components/editor/drag-selection-guard.ts b/src/renderer/src/components/editor/drag-selection-guard.ts new file mode 100644 index 00000000000..fbf0032b8c2 --- /dev/null +++ b/src/renderer/src/components/editor/drag-selection-guard.ts @@ -0,0 +1,186 @@ +import { Extension } from '@tiptap/core' +import { Plugin, PluginKey } from '@tiptap/pm/state' +import { CellSelection } from '@tiptap/pm/tables' + +/** + * Workaround for ProseMirror/Chrome drag-selection breakage. + * + * Problem 1 — selectionToDOM overwrites native drag selection: + * During a mouse drag, Chrome fires `selectionchange` events on every mouse + * move. ProseMirror's DOMObserver picks these up, dispatches selection-only + * transactions, and calls `selectionToDOM()` to push the ProseMirror selection + * back to the DOM. A Chrome-specific guard in `selectionToDOM` should detect + * the drag and bail out, but it relies on `isEquivalentPosition()` — a DOM + * scan that stops at `contenteditable="false"` boundaries and fails when + * ProseMirror ↔ DOM position mapping is lossy (tables, raw-HTML atom nodes). + * + * Problem 2 — prosemirror-tables forces selectionToDOM: + * The prosemirror-tables plugin has its own mousedown handler that registers a + * mousemove listener. When the user drags from one cell to another (or outside + * the table), it dispatches `CellSelection` transactions that cause decoration + * changes, which force `selectionToDOM(view, true)` — bypassing both guards. + * + * Problem 3 — post-mouseup selection round-trip loses table highlight: + * Chrome renders drag-created selections differently from programmatically-set + * selections. ProseMirror's `selectionToDOM` uses `collapse()`+`extend()` to + * set the DOM selection, which causes Chrome to lose the native table-cell + * highlighting that the drag selection had. + * + * Fix — three layers: + * 1. Suppress `DOMObserver.onSelectionChange` during drag so the + * selectionchange → flush → dispatch → selectionToDOM path never fires. + * Call `setCurSelection()` to keep the stored DOM selection fresh so the + * `updateStateInner` guard passes for any direct dispatches. + * 2. Block `CellSelection` transactions via `filterTransaction` during drag, + * preventing the prosemirror-tables decoration path. + * 3. On mouseup, save the native selection, flush the DOMObserver to sync + * ProseMirror state, then restore the native selection so Chrome preserves + * the table highlight. + * + * Safe to revisit when ProseMirror's Chrome drag guard improves upstream. + */ +export const DragSelectionGuard = Extension.create({ + name: 'dragSelectionGuard', + + addProseMirrorPlugins() { + // Why: shared across the plugin's view() and filterTransaction() hooks. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let viewRef: any = null + let suppressedDuringDrag = false + + return [ + new Plugin({ + key: new PluginKey('dragSelectionGuard'), + + // Why: the prosemirror-tables plugin dispatches CellSelection + // transactions from its own mousemove handler during drag. These + // cause decoration changes → forceSelUpdate → selectionToDOM(force) + // which bypasses both Chrome guards. Blocking CellSelection + // creation during a text drag prevents this forced overwrite. + filterTransaction(tr) { + if (!viewRef) { + return true + } + const mouseDown = viewRef.input.mouseDown + if (mouseDown && mouseDown.allowDefault && tr.selection instanceof CellSelection) { + return false + } + return true + }, + + view(editorView) { + // Why: domObserver and input are ProseMirror-internal properties + // with no public API. The cast is required to access them. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + viewRef = editorView as any + const observer = viewRef.domObserver + const doc = editorView.dom.ownerDocument + + const originalOnSelectionChange: () => void = observer.onSelectionChange + + // Remove the listener registered by ProseMirror's DOMObserver constructor + doc.removeEventListener('selectionchange', originalOnSelectionChange) + + const patchedOnSelectionChange = (): void => { + const mouseDown = viewRef.input.mouseDown + // Why: allowDefault is false on initial click, then becomes true + // once the mouse moves ≥ 4 px — i.e. it's a genuine drag, not a + // click. We only suppress during actual drags so normal + // click-to-place-cursor events are processed as usual. + if (mouseDown && mouseDown.allowDefault) { + // Why: keep the stored DOM selection reference in sync so the + // `updateStateInner` guard (`currentSelection.eq(domSelectionRange())`) + // passes for any direct dispatches that occur during drag. + observer.setCurSelection() + suppressedDuringDrag = true + return + } + originalOnSelectionChange() + } + + // Why: replacing the property ensures that ProseMirror's own + // connectSelection / disconnectSelection (which reference + // `this.onSelectionChange`) use our patched version, so the patch + // survives internal stop() / start() cycles. + observer.onSelectionChange = patchedOnSelectionChange + doc.addEventListener('selectionchange', patchedOnSelectionChange) + + const handleMouseUp = (): void => { + if (!suppressedDuringDrag) { + return + } + suppressedDuringDrag = false + requestAnimationFrame(() => { + // Why: if the plugin was destroyed between mouseup and this + // rAF callback, viewRef is null — bail out to avoid a + // TypeError on viewRef.domSelectionRange(). + if (!viewRef || !editorView.dom.isConnected) { + return + } + // Why: if a new drag started between mouseup and this rAF + // callback (extremely unlikely but possible within a single + // frame), bail out to avoid disrupting the new drag's + // native selection. + const mouseDown = viewRef?.input?.mouseDown + if (mouseDown && mouseDown.allowDefault) { + return + } + // Why: capture the native drag selection BEFORE ProseMirror + // touches it. Chrome renders drag-created selections differently + // from programmatically-set ones (table cells stay highlighted + // with a drag selection but lose highlighting when set via + // collapse + extend). + const domSel = viewRef.domSelectionRange() + const savedAnchor: Node | null = domSel.anchorNode + const savedAnchorOff: number = domSel.anchorOffset + const savedFocus: Node | null = domSel.focusNode + const savedFocusOff: number = domSel.focusOffset + + // Why: force ProseMirror to read the final native selection and + // update its state. Resetting the stored selection to a sentinel + // makes flush() treat the current DOM selection as new. + observer.currentSelection.set({ + anchorNode: null, + anchorOffset: 0, + focusNode: null, + focusOffset: 0 + }) + observer.flush() + + // Why: restore the native drag selection so Chrome preserves + // table-cell highlighting. We pause the DOMObserver around the + // restore to prevent the selection write from triggering another + // flush → dispatch → selectionToDOM cycle. + if ( + savedAnchor && + savedFocus && + (savedAnchor as Element).isConnected && + (savedFocus as Element).isConnected + ) { + const sel = doc.getSelection() + if (sel) { + observer.stop() + sel.setBaseAndExtent(savedAnchor, savedAnchorOff, savedFocus, savedFocusOff) + observer.setCurSelection() + observer.start() + } + } + }) + } + + doc.addEventListener('mouseup', handleMouseUp) + + return { + destroy() { + doc.removeEventListener('mouseup', handleMouseUp) + doc.removeEventListener('selectionchange', patchedOnSelectionChange) + observer.onSelectionChange = originalOnSelectionChange + doc.addEventListener('selectionchange', originalOnSelectionChange) + viewRef = null + } + } + } + }) + ] + } +}) diff --git a/src/renderer/src/components/editor/rich-markdown-extensions.ts b/src/renderer/src/components/editor/rich-markdown-extensions.ts index f727fa8a631..9eb32788ba2 100644 --- a/src/renderer/src/components/editor/rich-markdown-extensions.ts +++ b/src/renderer/src/components/editor/rich-markdown-extensions.ts @@ -1,5 +1,4 @@ import type { AnyExtension } from '@tiptap/core' -import { ReactNodeViewRenderer } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import Link from '@tiptap/extension-link' import Image from '@tiptap/extension-image' @@ -16,6 +15,8 @@ import { createLowlight, common } from 'lowlight' import { loadLocalImageSrc, onImageCacheInvalidated } from './useLocalImageSrc' import { RawMarkdownHtmlBlock, RawMarkdownHtmlInline } from './raw-markdown-html' import { RichMarkdownCodeBlock } from './RichMarkdownCodeBlock' +import { safeReactNodeViewRenderer } from './safe-react-node-view-renderer' +import { DragSelectionGuard } from './drag-selection-guard' const lowlight = createLowlight(common) @@ -36,7 +37,7 @@ export function createRichMarkdownExtensions({ }), CodeBlockLowlight.extend({ addNodeView() { - return ReactNodeViewRenderer(RichMarkdownCodeBlock) + return safeReactNodeViewRenderer(RichMarkdownCodeBlock) } }).configure({ lowlight, @@ -132,6 +133,7 @@ export function createRichMarkdownExtensions({ TableCell, RawMarkdownHtmlInline, RawMarkdownHtmlBlock, + DragSelectionGuard, Markdown.configure({ markedOptions: { gfm: true diff --git a/src/renderer/src/components/editor/safe-react-node-view-renderer.ts b/src/renderer/src/components/editor/safe-react-node-view-renderer.ts new file mode 100644 index 00000000000..5aa00295cf7 --- /dev/null +++ b/src/renderer/src/components/editor/safe-react-node-view-renderer.ts @@ -0,0 +1,72 @@ +import type { ComponentType } from 'react' +import { ReactNodeViewRenderer } from '@tiptap/react' +import type { ReactNodeViewProps, ReactNodeViewRendererOptions } from '@tiptap/react' +import type { NodeViewRenderer } from '@tiptap/core' +import { NodeSelection } from '@tiptap/pm/state' + +/** + * Workaround for Tiptap #7647: ReactNodeViewRenderer's handleSelectionUpdate + * incorrectly calls selectNode() for *any* selection that encompasses the node + * view — including TextSelection and AllSelection from mouse drag. The + * selectNode() call triggers a React re-render that mutates the DOM during an + * active drag, causing ProseMirror to lose the native browser selection. + * + * This wrapper patches handleSelectionUpdate on each created NodeView instance + * so selectNode/deselectNode only fire for actual NodeSelections (the user + * clicking a node with the modifier key to select it as a whole). + * + * Safe to remove once Tiptap merges PR #7691. + */ +export function safeReactNodeViewRenderer( + component: ComponentType>, + options?: Partial +): NodeViewRenderer { + const factory = ReactNodeViewRenderer(component, options) + + return (props) => { + const nodeView = factory(props) + + // Why: the factory returns an empty object when editor.contentComponent + // is not set (SSR / immediatelyRender: false initial pass). In that case + // there is no handleSelectionUpdate to patch. + if (!('handleSelectionUpdate' in nodeView)) { + return nodeView + } + + // Why: the constructor binds handleSelectionUpdate and registers it via + // editor.on('selectionUpdate', ...). We must unregister the original bound + // reference before replacing, otherwise the event emitter still calls the + // original and our patch is a no-op. On destroy(), the class calls + // editor.off('selectionUpdate', this.handleSelectionUpdate), so storing + // the patched function back on the property ensures clean teardown. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const nv = nodeView as any + const originalBound = nv.handleSelectionUpdate + nv.editor.off('selectionUpdate', originalBound) + + nv.handleSelectionUpdate = function patchedHandleSelectionUpdate( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this: any + ): void { + // Why: only NodeSelection means the user intentionally selected this + // specific node (e.g. Ctrl/Cmd-click on an atom node). Text and All + // selections that happen to span across the node should not trigger + // selectNode(), because that causes a React re-render mid-drag which + // disrupts the browser's native selection tracking. + if (this.editor.state.selection instanceof NodeSelection) { + originalBound() + } else { + // Why: if a previous NodeSelection had set selected=true, clear it + // now that the selection is no longer a NodeSelection. + if (this.renderer?.props?.selected) { + this.deselectNode() + } + } + } + + nv.handleSelectionUpdate = nv.handleSelectionUpdate.bind(nv) + nv.editor.on('selectionUpdate', nv.handleSelectionUpdate) + + return nodeView + } +}