diff --git a/.changeset/nodeview-leaf-selection-mutations.md b/.changeset/nodeview-leaf-selection-mutations.md new file mode 100644 index 0000000000..66ff42b6ef --- /dev/null +++ b/.changeset/nodeview-leaf-selection-mutations.md @@ -0,0 +1,5 @@ +--- +'@tiptap/core': patch +--- + +Node views without a `contentDOM` no longer ignore selection mutations, so ProseMirror moves the caret back to a valid position when the browser places it inside the node view. diff --git a/packages/core/src/NodeView.test.ts b/packages/core/src/NodeView.test.ts index 397f452802..35a9a17b75 100644 --- a/packages/core/src/NodeView.test.ts +++ b/packages/core/src/NodeView.test.ts @@ -1,3 +1,4 @@ +import type { ViewMutationRecord } from '@tiptap/pm/view' import { afterEach, describe, expect, it, vi } from 'vite-plus/test' import { createViewMutationTestContext } from '../tests/utils/createViewMutationTestContext.js' @@ -102,3 +103,55 @@ describe('NodeView ignoreMutation on iOS', () => { expect(nodeView.ignoreMutation(childListMutation(contentDOM, [edited]))).toBe(false) }) }) + +function createLeafNodeView() { + const wrapper = document.createElement('span') + + wrapper.contentEditable = 'false' + wrapper.append(document.createTextNode('@mention')) + document.body.append(wrapper) + + class LeafNodeView extends NodeView { + get dom() { + return wrapper + } + } + + const nodeView = new LeafNodeView(null, { + editor: {}, + extension: {}, + node: { isLeaf: true, isAtom: true }, + decorations: [], + innerDecorations: {}, + view: {}, + HTMLAttributes: {}, + getPos: () => 0, + } as unknown as NodeViewRendererProps) + + return { nodeView, wrapper } +} + +describe('NodeView ignoreMutation without contentDOM', () => { + afterEach(() => { + document.body.replaceChildren() + }) + + it('lets ProseMirror handle selection changes inside the node view', () => { + const { nodeView, wrapper } = createLeafNodeView() + const mutation = { type: 'selection', target: wrapper } as unknown as ViewMutationRecord + + expect(nodeView.ignoreMutation(mutation)).toBe(false) + }) + + it('still ignores DOM mutations inside the node view', () => { + const { nodeView, wrapper } = createLeafNodeView() + const mutation = { + type: 'childList', + target: wrapper, + addedNodes: [], + removedNodes: [], + } as unknown as ViewMutationRecord + + expect(nodeView.ignoreMutation(mutation)).toBe(true) + }) +}) diff --git a/packages/core/src/NodeView.ts b/packages/core/src/NodeView.ts index d63444b4ec..600dc274d2 100644 --- a/packages/core/src/NodeView.ts +++ b/packages/core/src/NodeView.ts @@ -269,10 +269,16 @@ export class NodeView< * @return `true` if it can safely be ignored. */ ignoreMutation(mutation: ViewMutationRecord) { - if (!this.dom || !this.contentDOM) { + if (!this.dom) { return true } + if (!this.contentDOM) { + // ProseMirror has to see selection changes inside a leaf node view, otherwise + // the browser caret can get stuck inside its non-editable DOM and typed text is lost + return mutation.type !== 'selection' + } + if (typeof this.options.ignoreMutation === 'function') { return this.options.ignoreMutation({ mutation }) }