/* oslint-disable no-underscore-dangle */ import type { DecorationWithType, NodeViewProps, NodeViewRenderer, NodeViewRendererOptions, NodeViewRendererProps, } from '@tiptap/core' import { isNodeViewSelected, NodeView } from '@tiptap/core' import type { Node as ProseMirrorNode } from '@tiptap/pm/model' import type { Decoration, DecorationSource, NodeView as ProseMirrorNodeView } from '@tiptap/pm/view' import type { Component, PropType, Ref } from 'vue' import { defineComponent, provide, ref } from 'vue' import type { Editor } from './Editor.js' import { VueRenderer } from './VueRenderer.js' export const nodeViewProps = { editor: { type: Object as PropType, required: true as const, }, node: { type: Object as PropType, required: true as const, }, decorations: { type: Object as PropType, required: true as const, }, selected: { type: Boolean as PropType, required: true as const, }, extension: { type: Object as PropType, required: true as const, }, getPos: { type: Function as PropType, required: true as const, }, updateAttributes: { type: Function as PropType, required: true as const, }, deleteNode: { type: Function as PropType, required: true as const, }, view: { type: Object as PropType, required: true as const, }, innerDecorations: { type: Object as PropType, required: true as const, }, HTMLAttributes: { type: Object as PropType, required: true as const, }, } export interface VueNodeViewRendererOptions extends NodeViewRendererOptions { update: | ((props: { oldNode: ProseMirrorNode oldDecorations: readonly Decoration[] oldInnerDecorations: DecorationSource newNode: ProseMirrorNode newDecorations: readonly Decoration[] innerDecorations: DecorationSource updateProps: () => void }) => boolean) | null } class VueNodeView extends NodeView { renderer!: VueRenderer decorationClasses!: Ref private currentPos: number | undefined private cachedExtensionWithSyncedStorage: NodeViewProps['extension'] | null = null constructor( component: Component, props: NodeViewRendererProps, options?: Partial, ) { super(component, props, options) if (this.options.trackNodeViewPosition) { this.editor.on('update', this.handlePositionUpdate) } } /** * 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(): NodeViewProps['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 } mount() { const props: Record = { 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(), } const mountProps = props as NodeViewProps const onDragStart = this.onDragStart.bind(this) this.decorationClasses = ref(this.getDecorationClasses()) const extendedComponent = defineComponent({ extends: { ...this.component }, props: Object.keys(props), template: (this.component as any).template, setup: reactiveProps => { provide('onDragStart', onDragStart) provide('decorationClasses', this.decorationClasses) return (this.component as any).setup?.(reactiveProps, { expose: () => undefined, }) }, // add support for scoped styles // @ts-ignore // oxlint-disable-next-line __scopeId: this.component.__scopeId, // add support for CSS Modules // @ts-ignore // oxlint-disable-next-line __cssModules: this.component.__cssModules, // add support for vue devtools // @ts-ignore // oxlint-disable-next-line __name: this.component.__name, // @ts-ignore // oxlint-disable-next-line __file: this.component.__file, }) this.handleSelectionUpdate = this.handleSelectionUpdate.bind(this) this.editor.on('selectionUpdate', this.handleSelectionUpdate) this.currentPos = this.getPos() this.renderer = new VueRenderer(extendedComponent, { editor: this.editor, props: mountProps, }) } /** * Fires on editor updates when trackNodeViewPosition is enabled. * Detects position shifts where update() is NOT called. */ private handlePositionUpdate = () => { const newPos = this.getPos() if (typeof newPos !== 'number' || newPos === this.currentPos) { return } this.currentPos = newPos this.renderer.updateProps({ getPos: () => this.getPos() }) } /** * Return the DOM element. * This is the element that will be used to display the node view. */ get dom() { if (!this.renderer.element || !this.renderer.element.hasAttribute('data-node-view-wrapper')) { throw Error('Please use the NodeViewWrapper component for your node view.') } return this.renderer.element as HTMLElement } /** * 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.dom.querySelector('[data-node-view-content]') as HTMLElement | null } /** * On editor selection update, check if the node is selected. * If it is, call `selectNode`, otherwise call `deselectNode`. */ handleSelectionUpdate() { const pos = this.getPos() if (typeof pos !== 'number') { return } const isSelected = isNodeViewSelected({ selection: this.editor.state.selection, pos, nodeSize: this.node.nodeSize, selectedOnTextSelection: this.options.selectedOnTextSelection, }) if (isSelected) { 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: ProseMirrorNode, decorations: readonly Decoration[], innerDecorations: DecorationSource, ): boolean { const rerenderComponent = (props?: Record) => { this.decorationClasses.value = this.getDecorationClasses() this.renderer.updateProps(props) } 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.type !== this.node.type) { return false } const nodeChanged = node !== this.node // Node reference unchanged — only decorations may have changed. // ProseMirror renders decorations independently on the contentDOM, // and the getPos closure (bound in mount()) calls through to // ProseMirror's position function at call time, so it is always // current. Update internal refs, refresh decoration classes for // the wrapper component, and skip the Vue re-render. if (!nodeChanged) { this.node = node this.decorations = decorations this.innerDecorations = innerDecorations this.decorationClasses.value = this.getDecorationClasses() return true } this.node = node this.decorations = decorations this.innerDecorations = innerDecorations this.currentPos = this.getPos() const extraProps: Record = { node, decorations, innerDecorations, extension: this.extensionWithSyncedStorage, } if (this.options.trackNodeViewPosition) { extraProps.getPos = () => this.getPos() } rerenderComponent(extraProps) return true } /** * Select the node. * Add the `selected` prop and the `ProseMirror-selectednode` class. */ selectNode() { this.renderer.updateProps({ selected: true, }) if (this.renderer.element) { 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, }) if (this.renderer.element) { this.renderer.element.classList.remove('ProseMirror-selectednode') } } getDecorationClasses() { return ( this.decorations // @ts-ignore .flatMap(item => item.type.attrs.class) .join(' ') ) } destroy() { this.renderer.destroy() this.editor.off('selectionUpdate', this.handleSelectionUpdate) if (this.options.trackNodeViewPosition) { this.editor.off('update', this.handlePositionUpdate) } } } export function VueNodeViewRenderer( component: Component, 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 Editor).contentComponent) { return {} as unknown as ProseMirrorNodeView } // check for class-component and normalize if neccessary const normalizedComponent = typeof component === 'function' && '__vccOpts' in component ? (component.__vccOpts as Component) : component return new VueNodeView(normalizedComponent, props, options) } }