diff --git a/e2e/src/react/components/Paragraph.tsx b/e2e/src/react/components/Paragraph.tsx
index cd9f1ae1..988915a8 100644
--- a/e2e/src/react/components/Paragraph.tsx
+++ b/e2e/src/react/components/Paragraph.tsx
@@ -1,6 +1,19 @@
-import { useNodeViewContext } from '@prosemirror-adapter/react'
+import { NodeViewContent, NodeViewRoot, useNodeViewContext } from '@prosemirror-adapter/react'
export function Paragraph() {
const { contentRef, selected } = useNodeViewContext()
- return
+ const shouldMixContentRef =
+ typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('mixed-content-ref')
+
+ return (
+
+
+ {shouldMixContentRef ? : null}
+
+ )
}
diff --git a/e2e/tests/node-view.spec.ts b/e2e/tests/node-view.spec.ts
index 403736d9..4f5415c0 100644
--- a/e2e/tests/node-view.spec.ts
+++ b/e2e/tests/node-view.spec.ts
@@ -41,3 +41,35 @@ testAll(() => {
await expect(h5).toBeVisible()
})
})
+
+testAll(() => {
+ test('react node view controls root and content DOM attributes', async ({ page }) => {
+ const root = page.locator('.editor blockquote [data-node-view-root="true"][data-react-node-view-root="true"]')
+ const content = root.locator('[data-node-view-content="true"][data-react-node-view-content="true"]')
+ const anchor = root.locator('[data-node-view-content-anchor="true"]')
+
+ await expect(root).toBeVisible()
+ await expect(root).toHaveAttribute('role', 'presentation')
+ await expect(root).toHaveAttribute('aria-hidden', 'true')
+ await expect(root).toHaveCSS('width', '10px')
+ await expect(content).toBeVisible()
+ await expect(content).toHaveClass(/react-node-view-content/)
+ await expect(anchor).not.toHaveClass(/react-node-view-content/)
+ })
+
+ test('react node view warns when mixing contentRef and NodeViewContent', async ({ page }) => {
+ const warnings: string[] = []
+
+ page.on('console', (message) => {
+ if (message.type() === 'warning') {
+ warnings.push(message.text())
+ }
+ })
+
+ await page.goto('/stories/react/app/react?mixed-content-ref=true', { waitUntil: 'domcontentloaded' })
+
+ await expect
+ .poll(() => warnings.some((warning) => warning.includes('Do not mix contentRef and NodeViewContent')))
+ .toBe(true)
+ })
+}, ['react'])
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 568dfba2..af738e5e 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -1,5 +1,11 @@
export { CoreMarkView, type CoreMarkViewSpec, type CoreMarkViewUserOptions, type MarkViewDOMSpec } from './markView'
-export { CoreNodeView, type CoreNodeViewSpec, type CoreNodeViewUserOptions, type NodeViewDOMSpec } from './nodeView'
+export {
+ CoreNodeView,
+ type CoreNodeViewSpec,
+ type CoreNodeViewUserOptions,
+ type NodeViewContentMountSource,
+ type NodeViewDOMSpec,
+} from './nodeView'
export {
CorePluginView,
type CorePluginViewSpec,
diff --git a/packages/core/src/nodeView/CoreNodeView.ts b/packages/core/src/nodeView/CoreNodeView.ts
index 0d4454af..cc7ec460 100644
--- a/packages/core/src/nodeView/CoreNodeView.ts
+++ b/packages/core/src/nodeView/CoreNodeView.ts
@@ -4,7 +4,12 @@ import type { Decoration, DecorationSource, EditorView, NodeView, ViewMutationRe
import { createKey } from '../create-key'
-import type { CoreNodeViewSpec, CoreNodeViewUserOptions, NodeViewDOMSpec } from './CoreNodeViewOptions'
+import type {
+ CoreNodeViewSpec,
+ CoreNodeViewUserOptions,
+ NodeViewContentMountSource,
+ NodeViewDOMSpec,
+} from './CoreNodeViewOptions'
export class CoreNodeView implements NodeView {
key: string
@@ -19,6 +24,8 @@ export class CoreNodeView implements NodeView {
selected = false
setSelection?: (anchor: number, head: number, root: Document | ShadowRoot) => void
stopEvent?: (event: Event) => boolean
+ #contentMountSource: NodeViewContentMountSource | null = null
+ #didWarnContentMountSource = false
#createElement(as?: NodeViewDOMSpec) {
const { node } = this
@@ -65,12 +72,30 @@ export class CoreNodeView implements NodeView {
return this.options.component
}
- protected contentRef = (element: unknown): void => {
- if (element && this.contentDOM && isElementLike(element) && element.firstChild !== this.contentDOM) {
+ mountContentDOM = (element: HTMLElement | null, source: NodeViewContentMountSource = 'contentRef'): void => {
+ if (!element || !this.contentDOM) return
+
+ if (this.#contentMountSource && this.#contentMountSource !== source && !this.#didWarnContentMountSource) {
+ this.#didWarnContentMountSource = true
+ console.warn(
+ '[prosemirror-adapter] Do not mix contentRef and NodeViewContent in the same node view. ' +
+ 'They both control the same contentDOM mount point.',
+ )
+ }
+
+ this.#contentMountSource = source
+
+ if (element.firstChild !== this.contentDOM) {
element.appendChild(this.contentDOM)
}
}
+ protected contentRef = (element: unknown): void => {
+ if (element && isElementLike(element)) {
+ this.mountContentDOM(element as HTMLElement, 'contentRef')
+ }
+ }
+
selectNode = () => {
this.selected = true
this.options.selectNode?.()
diff --git a/packages/core/src/nodeView/CoreNodeViewOptions.ts b/packages/core/src/nodeView/CoreNodeViewOptions.ts
index 594b003e..4a5b117d 100644
--- a/packages/core/src/nodeView/CoreNodeViewOptions.ts
+++ b/packages/core/src/nodeView/CoreNodeViewOptions.ts
@@ -3,6 +3,8 @@ import type { Decoration, DecorationSource, EditorView, ViewMutationRecord } fro
export type NodeViewDOMSpec = string | HTMLElement | ((node: Node) => HTMLElement)
+export type NodeViewContentMountSource = 'contentRef' | 'component'
+
export interface CoreNodeViewUserOptions {
// DOM
as?: NodeViewDOMSpec
diff --git a/packages/core/src/nodeView/index.ts b/packages/core/src/nodeView/index.ts
index 537c9728..f2396595 100644
--- a/packages/core/src/nodeView/index.ts
+++ b/packages/core/src/nodeView/index.ts
@@ -1,2 +1,7 @@
export { CoreNodeView } from './CoreNodeView'
-export type { CoreNodeViewSpec, CoreNodeViewUserOptions, NodeViewDOMSpec } from './CoreNodeViewOptions'
+export type {
+ CoreNodeViewSpec,
+ CoreNodeViewUserOptions,
+ NodeViewContentMountSource,
+ NodeViewDOMSpec,
+} from './CoreNodeViewOptions'
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index 2ce61d78..3366d488 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -17,11 +17,15 @@ export {
nodeViewContext,
ReactNodeView,
AbstractReactNodeView,
+ NodeViewContent,
+ NodeViewRoot,
buildReactNodeViewCreator,
useNodeViewContext,
useNodeViewFactory,
type NodeViewContentRef,
+ type NodeViewContentProps,
type NodeViewContext,
+ type NodeViewRootProps,
type ReactNodeViewComponent,
type ReactNodeViewSpec,
type ReactNodeViewUserOptions,
diff --git a/packages/react/src/nodeView/NodeViewDOM.ts b/packages/react/src/nodeView/NodeViewDOM.ts
new file mode 100644
index 00000000..68514267
--- /dev/null
+++ b/packages/react/src/nodeView/NodeViewDOM.ts
@@ -0,0 +1,363 @@
+import {
+ createElement,
+ forwardRef,
+ Fragment,
+ useCallback,
+ useLayoutEffect,
+ useRef,
+ type AriaAttributes,
+ type AriaRole,
+ type CSSProperties,
+ type ReactNode,
+ type Ref,
+} from 'react'
+
+import { useNodeViewContext } from './nodeViewContext'
+
+type NodeViewStyle = CSSProperties & Record<`--${string}`, string | number | null | undefined>
+
+type NodeViewDOMAttributeValue = string | number | boolean | null | undefined
+
+type NodeViewDOMProps = AriaAttributes & {
+ className?: string
+ contentEditable?: boolean | 'true' | 'false' | 'inherit' | 'plaintext-only'
+ dir?: string
+ draggable?: boolean | 'true' | 'false'
+ hidden?: boolean
+ id?: string
+ inert?: boolean
+ inputMode?: string
+ lang?: string
+ part?: string
+ role?: AriaRole
+ slot?: string
+ spellCheck?: boolean | 'true' | 'false'
+ style?: NodeViewStyle
+ tabIndex?: number
+ title?: string
+ translate?: 'yes' | 'no'
+} & {
+ [key: `data-${string}`]: NodeViewDOMAttributeValue
+}
+
+export interface NodeViewRootProps extends NodeViewDOMProps {
+ children?: ReactNode
+}
+
+export interface NodeViewContentProps extends NodeViewDOMProps {}
+
+function assignRef(ref: Ref | undefined, element: HTMLElement | null): void {
+ if (!ref) return
+
+ if (typeof ref === 'function') {
+ ref(element)
+ } else {
+ ref.current = element
+ }
+}
+
+function getAttributeName(name: string): string {
+ if (name === 'className') return 'class'
+ if (name === 'htmlFor') return 'for'
+ if (name === 'tabIndex') return 'tabindex'
+ return name
+}
+
+function isBooleanAttribute(name: string): boolean {
+ return name === 'hidden' || name === 'inert'
+}
+
+function setAttribute(element: HTMLElement, name: string, value: unknown): void {
+ if (value == null || value === false) {
+ element.removeAttribute(getAttributeName(name))
+ return
+ }
+
+ if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {
+ element.removeAttribute(getAttributeName(name))
+ return
+ }
+
+ const attributeName = getAttributeName(name)
+ const attributeValue = value === true && isBooleanAttribute(name) ? '' : String(value)
+
+ element.setAttribute(attributeName, attributeValue)
+}
+
+function getStylePropertyName(name: string): string {
+ return name.startsWith('--') ? name : name.replaceAll(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)
+}
+
+const UNITLESS_STYLE_PROPS = new Set([
+ 'animationIterationCount',
+ 'aspectRatio',
+ 'borderImageOutset',
+ 'borderImageSlice',
+ 'borderImageWidth',
+ 'boxFlex',
+ 'boxFlexGroup',
+ 'boxOrdinalGroup',
+ 'columnCount',
+ 'columns',
+ 'flex',
+ 'flexGrow',
+ 'flexPositive',
+ 'flexShrink',
+ 'flexNegative',
+ 'flexOrder',
+ 'gridArea',
+ 'gridRow',
+ 'gridRowEnd',
+ 'gridRowSpan',
+ 'gridRowStart',
+ 'gridColumn',
+ 'gridColumnEnd',
+ 'gridColumnSpan',
+ 'gridColumnStart',
+ 'fontWeight',
+ 'lineClamp',
+ 'lineHeight',
+ 'opacity',
+ 'order',
+ 'orphans',
+ 'scale',
+ 'tabSize',
+ 'widows',
+ 'zIndex',
+ 'zoom',
+])
+
+function getStyleValue(name: string, value: string | number): string {
+ if (typeof value === 'number' && value !== 0 && !UNITLESS_STYLE_PROPS.has(name)) {
+ return `${value}px`
+ }
+
+ return String(value)
+}
+
+function restoreAttribute(element: HTMLElement, name: string, value: string | null): void {
+ const attributeName = getAttributeName(name)
+ if (value == null) {
+ element.removeAttribute(attributeName)
+ } else {
+ element.setAttribute(attributeName, value)
+ }
+}
+
+function restoreStyle(element: HTMLElement, name: string, value: string): void {
+ if (name.startsWith('--')) {
+ if (value) {
+ element.style.setProperty(name, value)
+ } else {
+ element.style.removeProperty(name)
+ }
+ } else {
+ element.style[name as never] = value
+ }
+}
+
+function setStyle(element: HTMLElement, style: NodeViewStyle | null | undefined): void {
+ if (!style) return
+
+ for (const [name, value] of Object.entries(style)) {
+ if (value == null) {
+ if (name.startsWith('--')) {
+ element.style.removeProperty(name)
+ } else {
+ element.style[name as never] = ''
+ }
+ } else if (typeof value !== 'string' && typeof value !== 'number') {
+ if (name.startsWith('--')) {
+ element.style.removeProperty(name)
+ } else {
+ element.style[name as never] = ''
+ }
+ } else if (name.startsWith('--')) {
+ element.style.setProperty(name, String(value))
+ } else {
+ element.style[name as never] = getStyleValue(name, value)
+ }
+ }
+}
+
+function restoreDOMAttrs(
+ element: HTMLElement,
+ originalAttrs: Map,
+ originalStyles: Map,
+): void {
+ for (const [name, value] of originalAttrs) {
+ restoreAttribute(element, name, value)
+ }
+
+ for (const [name, value] of originalStyles) {
+ restoreStyle(element, name, value)
+ }
+
+ originalAttrs.clear()
+ originalStyles.clear()
+}
+
+function restoreRemovedDOMAttrs(
+ element: HTMLElement,
+ prevAttrs: NodeViewDOMProps | null,
+ attrs: NodeViewDOMProps,
+ originalAttrs: Map,
+ originalStyles: Map,
+): void {
+ if (!prevAttrs) return
+
+ for (const name of Object.keys(prevAttrs)) {
+ if (name === 'style') continue
+ if (!(name in attrs) && originalAttrs.has(name)) {
+ restoreAttribute(element, name, originalAttrs.get(name) ?? null)
+ originalAttrs.delete(name)
+ }
+ }
+
+ for (const name of Object.keys(prevAttrs.style ?? {})) {
+ if (!attrs.style || !(name in attrs.style)) {
+ restoreStyle(element, name, originalStyles.get(name) ?? '')
+ originalStyles.delete(name)
+ }
+ }
+}
+
+function saveOriginalDOMAttrs(
+ element: HTMLElement,
+ attrs: NodeViewDOMProps,
+ originalAttrs: Map,
+ originalStyles: Map,
+): void {
+ for (const name of Object.keys(attrs)) {
+ if (name === 'style') continue
+ if (!originalAttrs.has(name)) {
+ originalAttrs.set(name, element.getAttribute(getAttributeName(name)))
+ }
+ }
+
+ for (const name of Object.keys(attrs.style ?? {})) {
+ if (!originalStyles.has(name)) {
+ originalStyles.set(name, element.style.getPropertyValue(getStylePropertyName(name)))
+ }
+ }
+}
+
+function areStylesEqual(prevStyle: NodeViewStyle | null | undefined, style: NodeViewStyle | null | undefined): boolean {
+ if (prevStyle === style) return true
+ if (!prevStyle || !style) return !prevStyle && !style
+
+ const prevKeys = Object.keys(prevStyle)
+ const keys = Object.keys(style)
+
+ if (prevKeys.length !== keys.length) return false
+
+ return prevKeys.every(
+ (name) => name in style && Object.is(prevStyle[name as keyof NodeViewStyle], style[name as keyof NodeViewStyle]),
+ )
+}
+
+function areDOMAttrsEqual(prevAttrs: NodeViewDOMProps | null, attrs: NodeViewDOMProps): boolean {
+ if (prevAttrs === attrs) return true
+ if (!prevAttrs) return false
+
+ const prevKeys = Object.keys(prevAttrs).filter((name) => name !== 'style')
+ const keys = Object.keys(attrs).filter((name) => name !== 'style')
+
+ if (prevKeys.length !== keys.length) return false
+
+ return (
+ prevKeys.every(
+ (name) =>
+ name in attrs && Object.is(prevAttrs[name as keyof NodeViewDOMProps], attrs[name as keyof NodeViewDOMProps]),
+ ) && areStylesEqual(prevAttrs.style, attrs.style)
+ )
+}
+
+function useDOMAttrs(element: HTMLElement | null, attrs: NodeViewDOMProps, ref: Ref | undefined): void {
+ const prevAttrsRef = useRef(null)
+ const prevElementRef = useRef(null)
+ const originalAttrsRef = useRef(new Map())
+ const originalStylesRef = useRef(new Map())
+
+ useLayoutEffect(() => {
+ const originalAttrs = originalAttrsRef.current
+ const originalStyles = originalStylesRef.current
+
+ return () => {
+ if (element) {
+ restoreDOMAttrs(element, originalAttrs, originalStyles)
+ }
+ }
+ }, [element])
+
+ useLayoutEffect(() => {
+ assignRef(ref, element)
+
+ return () => {
+ assignRef(ref, null)
+ }
+ }, [element, ref])
+
+ useLayoutEffect(() => {
+ const prevAttrs = prevAttrsRef.current
+ const prevElement = prevElementRef.current
+
+ if (prevElement === element && areDOMAttrsEqual(prevAttrs, attrs)) return
+
+ if (element) {
+ restoreRemovedDOMAttrs(element, prevAttrs, attrs, originalAttrsRef.current, originalStylesRef.current)
+ saveOriginalDOMAttrs(element, attrs, originalAttrsRef.current, originalStylesRef.current)
+
+ for (const [name, value] of Object.entries(attrs)) {
+ if (name === 'style') continue
+ setAttribute(element, name, value)
+ }
+
+ setStyle(element, attrs.style)
+ }
+
+ prevAttrsRef.current = attrs
+ prevElementRef.current = element
+ })
+}
+
+export const NodeViewRoot = forwardRef((props, ref) => {
+ const { children, ...attrs } = props
+ const { rootDOM } = useNodeViewContext()
+
+ useDOMAttrs(rootDOM, attrs, ref)
+
+ return createElement(Fragment, null, children)
+})
+
+NodeViewRoot.displayName = 'NodeViewRoot'
+
+export const NodeViewContent = forwardRef((props, ref) => {
+ const { children, ...attrs } = props as NodeViewContentProps & { children?: ReactNode }
+ const { contentDOM, mountContentDOM } = useNodeViewContext()
+ const didWarnChildrenRef = useRef(false)
+
+ useDOMAttrs(contentDOM, attrs, ref)
+
+ useLayoutEffect(() => {
+ if (children != null && !didWarnChildrenRef.current) {
+ didWarnChildrenRef.current = true
+ console.warn('[prosemirror-adapter] NodeViewContent does not render children. Put custom UI next to it instead.')
+ }
+ })
+
+ const handleAnchorRef = useCallback(
+ (element: HTMLElement | null) => {
+ mountContentDOM(element, 'component')
+ },
+ [mountContentDOM],
+ )
+
+ return createElement('span', {
+ 'data-node-view-content-anchor': 'true',
+ ref: handleAnchorRef,
+ style: { display: 'contents' },
+ })
+})
+
+NodeViewContent.displayName = 'NodeViewContent'
diff --git a/packages/react/src/nodeView/ReactNodeView.ts b/packages/react/src/nodeView/ReactNodeView.ts
index 8493bd8e..613a6d7b 100644
--- a/packages/react/src/nodeView/ReactNodeView.ts
+++ b/packages/react/src/nodeView/ReactNodeView.ts
@@ -21,6 +21,9 @@ export abstract class AbstractReactNodeView
view: this.view,
getPos: this.getPos,
setAttrs: this.setAttrs,
+ rootDOM: this.dom,
+ contentDOM: this.contentDOM,
+ mountContentDOM: this.mountContentDOM,
node: this.node,
selected: this.selected,
diff --git a/packages/react/src/nodeView/index.ts b/packages/react/src/nodeView/index.ts
index c3805d8f..223432c6 100644
--- a/packages/react/src/nodeView/index.ts
+++ b/packages/react/src/nodeView/index.ts
@@ -6,6 +6,7 @@ export {
type NodeViewContentRef,
type NodeViewContext,
} from './nodeViewContext'
+export { NodeViewContent, NodeViewRoot, type NodeViewContentProps, type NodeViewRootProps } from './NodeViewDOM'
export { ReactNodeView, AbstractReactNodeView } from './ReactNodeView'
export { buildReactNodeViewCreator } from './useReactNodeViewCreator'
export type { ReactNodeViewComponent, ReactNodeViewSpec, ReactNodeViewUserOptions } from './ReactNodeViewOptions'
diff --git a/packages/react/src/nodeView/nodeViewContext.ts b/packages/react/src/nodeView/nodeViewContext.ts
index 50ee90f6..20c3c698 100644
--- a/packages/react/src/nodeView/nodeViewContext.ts
+++ b/packages/react/src/nodeView/nodeViewContext.ts
@@ -1,3 +1,4 @@
+import type { NodeViewContentMountSource } from '@prosemirror-adapter/core'
import type { Attrs, Node } from 'prosemirror-model'
import type { Decoration, DecorationSource, EditorView, NodeViewConstructor } from 'prosemirror-view'
import { createContext, useContext } from 'react'
@@ -12,6 +13,9 @@ export interface NodeViewContext {
view: EditorView
getPos: () => number | undefined
setAttrs: (attrs: Attrs) => void
+ rootDOM: HTMLElement
+ contentDOM: HTMLElement | null
+ mountContentDOM: (element: HTMLElement | null, source?: NodeViewContentMountSource) => void
// changes between updates
node: Node
@@ -29,6 +33,11 @@ export const nodeViewContext = createContext({
setAttrs: () => {
// nothing to do
},
+ rootDOM: null as never,
+ contentDOM: null,
+ mountContentDOM: () => {
+ // nothing to do
+ },
node: null as never,
selected: false,