fix: prevent ProseMirror from overwriting native drag selection in rich markdown editor (#704)

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).
This commit is contained in:
Jinjing 2026-04-15 22:58:16 -07:00 committed by GitHub
parent bb53c4a7b6
commit c2d312888e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 262 additions and 2 deletions

View file

@ -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
}
}
}
})
]
}
})

View file

@ -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

View file

@ -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<T = HTMLElement>(
component: ComponentType<ReactNodeViewProps<T>>,
options?: Partial<ReactNodeViewRendererOptions>
): 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
}
}