import type { DecorationWithType, Editor, NodeViewRenderer, NodeViewRendererOptions, NodeViewRendererProps, } from '@tiptap/core' import { getRenderedAttributes, NodeView } from '@tiptap/core' import type { Node, Node as ProseMirrorNode } from '@tiptap/pm/model' import type { Decoration, DecorationSource, NodeView as ProseMirrorNodeView } from '@tiptap/pm/view' import type { ComponentType, NamedExoticComponent } from 'react' import { createElement, createRef, memo } from 'react' import type { EditorWithContentComponent } from './Editor.js' import { ReactRenderer } from './ReactRenderer.js' import type { ReactNodeViewProps } from './types.js' import type { ReactNodeViewContextProps } from './useReactNodeView.js' import { ReactNodeViewContext } from './useReactNodeView.js' export interface ReactNodeViewRendererOptions extends NodeViewRendererOptions { /** * This function is called when the node view is updated. * It allows you to compare the old node with the new node and decide if the component should update. */ update: | ((props: { oldNode: ProseMirrorNode oldDecorations: readonly Decoration[] oldInnerDecorations: DecorationSource newNode: ProseMirrorNode newDecorations: readonly Decoration[] innerDecorations: DecorationSource updateProps: () => void }) => boolean) | null /** * The tag name of the element wrapping the React component. */ as?: string /** * The class name of the element wrapping the React component. */ className?: string /** * Attributes that should be applied to the element wrapping the React component. * If this is a function, it will be called each time the node view is updated. * If this is an object, it will be applied once when the node view is mounted. */ attrs?: | Record | ((props: { node: ProseMirrorNode; HTMLAttributes: Record }) => Record) } export class ReactNodeView< T = HTMLElement, Component extends ComponentType> = ComponentType>, NodeEditor extends Editor = Editor, Options extends ReactNodeViewRendererOptions = ReactNodeViewRendererOptions, > extends NodeView { /** * The renderer instance. */ renderer!: ReactRenderer> /** * The element that holds the rich-text content of the node. */ contentDOMElement!: HTMLElement | null /** * The requestAnimationFrame ID used for selection updates. */ selectionRafId: number | null = null constructor(component: Component, props: NodeViewRendererProps, options?: Partial) { super(component, props, options) if (!this.node.isLeaf) { if (this.options.contentDOMElementTag) { this.contentDOMElement = document.createElement(this.options.contentDOMElementTag) } else { this.contentDOMElement = document.createElement(this.node.isInline ? 'span' : 'div') } this.contentDOMElement.dataset.nodeViewContentReact = '' this.contentDOMElement.dataset.nodeViewWrapper = '' // For some reason the whiteSpace prop is not inherited properly in Chrome and Safari // With this fix it seems to work fine // See: https://github.com/ueberdosis/tiptap/issues/1197 this.contentDOMElement.style.whiteSpace = 'inherit' const contentTarget = this.dom.querySelector('[data-node-view-content]') if (!contentTarget) { return } contentTarget.appendChild(this.contentDOMElement) } } private cachedExtensionWithSyncedStorage: NodeViewRendererProps['extension'] | null = null /** * Returns a proxy of the extension that redirects storage access to the editor's mutable storage. * This preserves the original prototype chain (instanceof checks, methods like configure/extend work). * Cached to avoid proxy creation on every update. */ get extensionWithSyncedStorage(): NodeViewRendererProps['extension'] { if (!this.cachedExtensionWithSyncedStorage) { const editor = this.editor const extension = this.extension this.cachedExtensionWithSyncedStorage = new Proxy(extension, { get(target, prop, receiver) { if (prop === 'storage') { return editor.storage[extension.name as keyof typeof editor.storage] ?? {} } return Reflect.get(target, prop, receiver) }, }) } return this.cachedExtensionWithSyncedStorage } /** * Setup the React component. * Called on initialization. */ mount() { const props = { editor: this.editor, node: this.node, decorations: this.decorations as DecorationWithType[], innerDecorations: this.innerDecorations, view: this.view, selected: false, extension: this.extensionWithSyncedStorage, HTMLAttributes: this.HTMLAttributes, getPos: () => this.getPos(), updateAttributes: (attributes = {}) => this.updateAttributes(attributes), deleteNode: () => this.deleteNode(), ref: createRef(), } satisfies ReactNodeViewProps if (!(this.component as any).displayName) { const capitalizeFirstChar = (string: string): string => { return string.charAt(0).toUpperCase() + string.substring(1) } this.component.displayName = capitalizeFirstChar(this.extension.name) } const onDragStart = this.onDragStart.bind(this) const nodeViewContentRef: ReactNodeViewContextProps['nodeViewContentRef'] = element => { if (element && this.contentDOMElement && element.firstChild !== this.contentDOMElement) { // remove the nodeViewWrapper attribute from the element if (element.hasAttribute('data-node-view-wrapper')) { element.removeAttribute('data-node-view-wrapper') } element.appendChild(this.contentDOMElement) } } const context = { onDragStart, nodeViewContentRef } const Component = this.component // For performance reasons, we memoize the provider component // And all of the things it requires are declared outside of the component, so it doesn't need to re-render const ReactNodeViewProvider: NamedExoticComponent> = memo(componentProps => { return ( {createElement(Component, componentProps)} ) }) ReactNodeViewProvider.displayName = 'ReactNodeView' let as = this.node.isInline ? 'span' : 'div' if (this.options.as) { as = this.options.as } const { className = '' } = this.options this.handleSelectionUpdate = this.handleSelectionUpdate.bind(this) this.renderer = new ReactRenderer(ReactNodeViewProvider, { editor: this.editor, props, as, className: `node-${this.node.type.name} ${className}`.trim(), }) this.editor.on('selectionUpdate', this.handleSelectionUpdate) this.updateElementAttributes() } /** * Return the DOM element. * This is the element that will be used to display the node view. */ get dom() { if ( this.renderer.element.firstElementChild && !this.renderer.element.firstElementChild?.hasAttribute('data-node-view-wrapper') ) { throw Error('Please use the NodeViewWrapper component for your node view.') } return this.renderer.element } /** * Return the content DOM element. * This is the element that will be used to display the rich-text content of the node. */ get contentDOM() { if (this.node.isLeaf) { return null } return this.contentDOMElement } /** * On editor selection update, check if the node is selected. * If it is, call `selectNode`, otherwise call `deselectNode`. */ handleSelectionUpdate() { if (this.selectionRafId) { cancelAnimationFrame(this.selectionRafId) this.selectionRafId = null } this.selectionRafId = requestAnimationFrame(() => { this.selectionRafId = null const { from, to } = this.editor.state.selection const pos = this.getPos() if (typeof pos !== 'number') { return } if (from <= pos && to >= pos + this.node.nodeSize) { if (this.renderer.props.selected) { return } this.selectNode() } else { if (!this.renderer.props.selected) { return } this.deselectNode() } }) } /** * On update, update the React component. * To prevent unnecessary updates, the `update` option can be used. */ update(node: Node, decorations: readonly Decoration[], innerDecorations: DecorationSource): boolean { const rerenderComponent = (props?: Record) => { this.renderer.updateProps(props) if (typeof this.options.attrs === 'function') { this.updateElementAttributes() } } if (node.type !== this.node.type) { return false } if (typeof this.options.update === 'function') { const oldNode = this.node const oldDecorations = this.decorations const oldInnerDecorations = this.innerDecorations this.node = node this.decorations = decorations this.innerDecorations = innerDecorations return this.options.update({ oldNode, oldDecorations, newNode: node, newDecorations: decorations, oldInnerDecorations, innerDecorations, updateProps: () => rerenderComponent({ node, decorations, innerDecorations, extension: this.extensionWithSyncedStorage }), }) } if (node === this.node && this.decorations === decorations && this.innerDecorations === innerDecorations) { return true } this.node = node this.decorations = decorations this.innerDecorations = innerDecorations rerenderComponent({ node, decorations, innerDecorations, extension: this.extensionWithSyncedStorage }) return true } /** * Select the node. * Add the `selected` prop and the `ProseMirror-selectednode` class. */ selectNode() { this.renderer.updateProps({ selected: true, }) this.renderer.element.classList.add('ProseMirror-selectednode') } /** * Deselect the node. * Remove the `selected` prop and the `ProseMirror-selectednode` class. */ deselectNode() { this.renderer.updateProps({ selected: false, }) this.renderer.element.classList.remove('ProseMirror-selectednode') } /** * Destroy the React component instance. */ destroy() { this.renderer.destroy() this.editor.off('selectionUpdate', this.handleSelectionUpdate) this.contentDOMElement = null if (this.selectionRafId) { cancelAnimationFrame(this.selectionRafId) this.selectionRafId = null } } /** * Update the attributes of the top-level element that holds the React component. * Applying the attributes defined in the `attrs` option. */ updateElementAttributes() { if (this.options.attrs) { let attrsObj: Record = {} if (typeof this.options.attrs === 'function') { const extensionAttributes = this.editor.extensionManager.attributes const HTMLAttributes = getRenderedAttributes(this.node, extensionAttributes) attrsObj = this.options.attrs({ node: this.node, HTMLAttributes }) } else { attrsObj = this.options.attrs } this.renderer.updateAttributes(attrsObj) } } } /** * Create a React node view renderer. */ export function ReactNodeViewRenderer( component: ComponentType>, options?: Partial, ): NodeViewRenderer { return props => { // try to get the parent component // this is important for vue devtools to show the component hierarchy correctly // maybe it’s `undefined` because isn’t rendered yet if (!(props.editor as EditorWithContentComponent).contentComponent) { return {} as unknown as ProseMirrorNodeView } return new ReactNodeView(component, props, options) } }