Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tidy-donuts-smile.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions e2e/src/react/components/CodeBlock.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { useNodeViewContext } from '@prosemirror-adapter/react'

export function CodeBlock() {
const { contentRef } = useNodeViewContext()
return <pre ref={contentRef} />
}
5 changes: 5 additions & 0 deletions e2e/src/react/components/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -51,6 +52,10 @@ export const Editor: FC = () => {
heading: nodeViewFactory({
component: Heading,
}),
code_block: nodeViewFactory({
component: CodeBlock,
contentAs: 'code',
}),
},
{
link: markViewFactory({
Expand Down
37 changes: 34 additions & 3 deletions e2e/src/shared/createEditorView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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<string, NodeViewConstructor>,
Expand Down Expand Up @@ -168,6 +198,7 @@ export function createEditorView(
return true
},
}),
createCodeHighlightPlugin(),
...plugins,
],
}),
Expand Down
36 changes: 36 additions & 0 deletions e2e/tests/node-view.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
6 changes: 3 additions & 3 deletions e2e/tests/plugin-view.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
20 changes: 20 additions & 0 deletions packages/core/src/is-content-dom-removal.ts
Original file line number Diff line number Diff line change
@@ -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
}
3 changes: 3 additions & 0 deletions packages/core/src/markView/CoreMarkView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -68,6 +69,8 @@ export class CoreMarkView<ComponentType> implements MarkView {

if (this.contentDOM.contains(mutation.target)) return false

if (isContentDOMRemoval(mutation, this.contentDOM)) return false

return true
}

Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/nodeView/CoreNodeView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -114,6 +115,8 @@ export class CoreNodeView<ComponentType> implements NodeView {

if (this.contentDOM.contains(mutation.target)) return false

if (isContentDOMRemoval(mutation, this.contentDOM)) return false

return true
}

Expand Down
Loading