diff --git a/.changeset/tidy-donuts-smile.md b/.changeset/tidy-donuts-smile.md
new file mode 100644
index 00000000..c4070a76
--- /dev/null
+++ b/.changeset/tidy-donuts-smile.md
@@ -0,0 +1,5 @@
+---
+'@prosemirror-adapter/core': patch
+---
+
+Stop ignoring DOM mutations that remove the `contentDOM` element, so ProseMirror stays in sync when the browser's native editing replaces a fully selected node view or mark view content.
diff --git a/e2e/src/react/components/CodeBlock.tsx b/e2e/src/react/components/CodeBlock.tsx
new file mode 100644
index 00000000..a0e64328
--- /dev/null
+++ b/e2e/src/react/components/CodeBlock.tsx
@@ -0,0 +1,6 @@
+import { useNodeViewContext } from '@prosemirror-adapter/react'
+
+export function CodeBlock() {
+ const { contentRef } = useNodeViewContext()
+ return
+}
diff --git a/e2e/src/react/components/Editor.tsx b/e2e/src/react/components/Editor.tsx
index 49326307..6f1d6f4b 100644
--- a/e2e/src/react/components/Editor.tsx
+++ b/e2e/src/react/components/Editor.tsx
@@ -14,6 +14,7 @@ import { useEffect, useRef } from 'react'
import { createEditorView } from '../../shared/createEditorView'
+import { CodeBlock } from './CodeBlock'
import { Hashes } from './Hashes'
import { Heading } from './Heading'
import { Link } from './Link'
@@ -51,6 +52,10 @@ export const Editor: FC = () => {
heading: nodeViewFactory({
component: Heading,
}),
+ code_block: nodeViewFactory({
+ component: CodeBlock,
+ contentAs: 'code',
+ }),
},
{
link: markViewFactory({
diff --git a/e2e/src/shared/createEditorView.ts b/e2e/src/shared/createEditorView.ts
index 438be77a..307bf79c 100644
--- a/e2e/src/shared/createEditorView.ts
+++ b/e2e/src/shared/createEditorView.ts
@@ -5,10 +5,9 @@ import 'prosemirror-menu/style/menu.css'
import { exampleSetup } from 'prosemirror-example-setup'
import { keymap } from 'prosemirror-keymap'
import { schema } from 'prosemirror-schema-basic'
-import type { Plugin } from 'prosemirror-state'
-import { EditorState } from 'prosemirror-state'
+import { EditorState, Plugin } from 'prosemirror-state'
import type { MarkViewConstructor, NodeViewConstructor } from 'prosemirror-view'
-import { EditorView } from 'prosemirror-view'
+import { Decoration, DecorationSet, EditorView } from 'prosemirror-view'
const defaultDoc = {
type: 'doc',
@@ -134,9 +133,40 @@ const defaultDoc = {
},
],
},
+ {
+ type: 'code_block',
+ content: [
+ {
+ type: 'text',
+ text: 'const greeting = "hello"',
+ },
+ ],
+ },
],
}
+// Mimics an inline syntax highlighter: wraps every word inside a code block
+// in a styled span, like prosemirror-highlight and similar plugins do.
+function createCodeHighlightPlugin(): Plugin {
+ return new Plugin({
+ props: {
+ decorations(state) {
+ const decorations: Decoration[] = []
+ state.doc.descendants((node, pos) => {
+ if (node.type.name !== 'code_block') return true
+ const pattern = /\S+/g
+ for (const match of node.textContent.matchAll(pattern)) {
+ const from = pos + 1 + match.index
+ decorations.push(Decoration.inline(from, from + match[0].length, { style: 'color: rgb(207, 34, 46)' }))
+ }
+ return false
+ })
+ return DecorationSet.create(state.doc, decorations)
+ },
+ },
+ })
+}
+
export function createEditorView(
element: HTMLElement | ShadowRoot,
nodeViews: Record,
@@ -168,6 +198,7 @@ export function createEditorView(
return true
},
}),
+ createCodeHighlightPlugin(),
...plugins,
],
}),
diff --git a/e2e/tests/node-view.spec.ts b/e2e/tests/node-view.spec.ts
index 403736d9..5d706533 100644
--- a/e2e/tests/node-view.spec.ts
+++ b/e2e/tests/node-view.spec.ts
@@ -41,3 +41,39 @@ testAll(() => {
await expect(h5).toBeVisible()
})
})
+
+testAll(() => {
+ test('code block node view survives typing over a fully selected code block', async ({ page }) => {
+ const content = page.locator('.editor [data-node-view-root="true"] pre code[data-node-view-content="true"]')
+ await expect(content).toBeVisible()
+ await expect(content).toContainText('const greeting')
+
+ await content.click()
+ // Select all of the code block text, crossing the highlight spans, like a
+ // user dragging from the first character to the last one.
+ await content.evaluate((element) => {
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT)
+ const textNodes: Text[] = []
+ while (walker.nextNode()) textNodes.push(walker.currentNode as Text)
+ const firstText = textNodes.at(0)
+ const lastText = textNodes.at(-1)
+ const selection = window.getSelection()
+ if (!selection || !firstText || !lastText) throw new Error('found no text to select')
+ const range = document.createRange()
+ range.setStart(firstText, 0)
+ range.setEnd(lastText, lastText.length)
+ selection.removeAllRanges()
+ selection.addRange(range)
+ })
+ await page.keyboard.type('X')
+
+ // Chrome and Safari delete the whole contentDOM element when typing over
+ // a selection that covers all of its content. ProseMirror must not ignore
+ // that mutation: the contentDOM has to come back under its control and
+ // typing has to keep updating the document.
+ await expect(content).toBeAttached()
+
+ await page.keyboard.type('hello')
+ await expect(content).toContainText('hello')
+ })
+}, ['react'])
diff --git a/e2e/tests/plugin-view.spec.ts b/e2e/tests/plugin-view.spec.ts
index 55ac649d..1b688e59 100644
--- a/e2e/tests/plugin-view.spec.ts
+++ b/e2e/tests/plugin-view.spec.ts
@@ -8,14 +8,14 @@ testAll(() => {
await expect(editor).toBeVisible()
const locator = editor.locator("[data-test-id='size-view-plugin']")
- await expect(locator).toContainText('Size for document: 523')
+ await expect(locator).toContainText('Size for document: 549')
await editor.locator('p').first().click()
await page.keyboard.type('OK')
- await expect(locator).toContainText('Size for document: 525')
+ await expect(locator).toContainText('Size for document: 551')
await page.keyboard.press('Backspace')
- await expect(locator).toContainText('Size for document: 524')
+ await expect(locator).toContainText('Size for document: 550')
})
test('Render context from parent component', async ({ page }) => {
diff --git a/packages/core/src/is-content-dom-removal.ts b/packages/core/src/is-content-dom-removal.ts
new file mode 100644
index 00000000..a5ec5965
--- /dev/null
+++ b/packages/core/src/is-content-dom-removal.ts
@@ -0,0 +1,20 @@
+import type { ViewMutationRecord } from 'prosemirror-view'
+
+/**
+ * Whether the mutation removed `contentDOM` from its parent element.
+ *
+ * The browser's native editing can cause this: when the selection covers all
+ * of the contentDOM's text and the user types, Chrome and Safari delete the
+ * whole contentDOM element and insert the typed text into its parent.
+ * ProseMirror must see such a mutation, otherwise the view and the document
+ * state diverge silently.
+ */
+export function isContentDOMRemoval(mutation: ViewMutationRecord, contentDOM: HTMLElement): boolean {
+ if (mutation.type !== 'childList') return false
+
+ for (const removedNode of mutation.removedNodes) {
+ if (removedNode === contentDOM) return true
+ }
+
+ return false
+}
diff --git a/packages/core/src/markView/CoreMarkView.ts b/packages/core/src/markView/CoreMarkView.ts
index a19b0989..fe7c2202 100644
--- a/packages/core/src/markView/CoreMarkView.ts
+++ b/packages/core/src/markView/CoreMarkView.ts
@@ -3,6 +3,7 @@ import type { Mark } from 'prosemirror-model'
import type { EditorView, MarkView, ViewMutationRecord } from 'prosemirror-view'
import { createKey } from '../create-key'
+import { isContentDOMRemoval } from '../is-content-dom-removal'
import type { CoreMarkViewSpec, CoreMarkViewUserOptions, MarkViewDOMSpec } from './CoreMarkViewOptions'
@@ -68,6 +69,8 @@ export class CoreMarkView implements MarkView {
if (this.contentDOM.contains(mutation.target)) return false
+ if (isContentDOMRemoval(mutation, this.contentDOM)) return false
+
return true
}
diff --git a/packages/core/src/nodeView/CoreNodeView.ts b/packages/core/src/nodeView/CoreNodeView.ts
index 0d4454af..45f095c5 100644
--- a/packages/core/src/nodeView/CoreNodeView.ts
+++ b/packages/core/src/nodeView/CoreNodeView.ts
@@ -3,6 +3,7 @@ import type { Attrs, Node } from 'prosemirror-model'
import type { Decoration, DecorationSource, EditorView, NodeView, ViewMutationRecord } from 'prosemirror-view'
import { createKey } from '../create-key'
+import { isContentDOMRemoval } from '../is-content-dom-removal'
import type { CoreNodeViewSpec, CoreNodeViewUserOptions, NodeViewDOMSpec } from './CoreNodeViewOptions'
@@ -114,6 +115,8 @@ export class CoreNodeView implements NodeView {
if (this.contentDOM.contains(mutation.target)) return false
+ if (isContentDOMRemoval(mutation, this.contentDOM)) return false
+
return true
}