import { Editor, Node, mergeAttributes } from '@tiptap/core' import Document from '@tiptap/extension-document' import Paragraph from '@tiptap/extension-paragraph' import Text from '@tiptap/extension-text' import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test' import { defineComponent } from 'vue' import { Editor as VueEditor } from './Editor.js' import { NodeViewContent } from './NodeViewContent.js' import { NodeViewWrapper } from './NodeViewWrapper.js' import { nodeViewProps, VueNodeViewRenderer, type VueNodeViewRendererOptions, } from './VueNodeViewRenderer.js' const ComponentWithoutContent = defineComponent({ name: 'WithoutContent', props: nodeViewProps, template: '
Custom UI
', components: { NodeViewWrapper }, }) const ComponentWithContent = defineComponent({ name: 'WithContent', props: nodeViewProps, template: '', components: { NodeViewWrapper, NodeViewContent }, }) const ComponentWithTbodyContent = defineComponent({ name: 'WithTbodyContent', props: nodeViewProps, template: '', components: { NodeViewWrapper, NodeViewContent }, }) const LeafComponent = defineComponent({ name: 'LeafComponent', props: nodeViewProps, template: '
Leaf
', components: { NodeViewWrapper }, }) function createBlockNode( component: ReturnType, options?: Partial, ) { return Node.create({ name: 'customBlock', group: 'block', content: 'inline*', parseHTML: () => [{ tag: 'custom-block' }], renderHTML: ({ HTMLAttributes }) => ['custom-block', mergeAttributes(HTMLAttributes), 0], addNodeView: () => VueNodeViewRenderer(component, options), }) } function createLeafNode(component: ReturnType) { return Node.create({ name: 'customLeaf', group: 'block', atom: true, parseHTML: () => [{ tag: 'custom-leaf' }], renderHTML: ({ HTMLAttributes }) => ['custom-leaf', mergeAttributes(HTMLAttributes)], addNodeView: () => VueNodeViewRenderer(component), }) } function createVueEditor(options: ConstructorParameters[0]) { const desc = Object.getOwnPropertyDescriptor(VueEditor.prototype, 'contentComponent') Object.defineProperty(VueEditor.prototype, 'contentComponent', { value: {}, writable: true, configurable: true, }) const instance = new VueEditor(options) if (desc) Object.defineProperty(VueEditor.prototype, 'contentComponent', desc) else delete (VueEditor.prototype as any).contentComponent return instance } describe('VueNodeViewRenderer contentDOM', () => { let editor: Editor | null = null let el: HTMLElement | null = null function withEditor( content: string, component: ReturnType, leaf = false, options?: Partial, ) { editor = createVueEditor({ element: el!, extensions: [ Document, Paragraph, Text, leaf ? createLeafNode(component) : createBlockNode(component, options), ], content, }) return new Promise(r => setTimeout(r, 10)) } beforeEach(() => { el = document.createElement('div') document.body.appendChild(el) }) afterEach(() => { editor?.destroy() editor = null el?.parentNode?.removeChild(el) el = null }) it('does not thrash the DOM when is absent', async () => { await withEditor('Hello World', ComponentWithoutContent) // Node view wrapper should mount expect(el!.querySelector('[data-node-view-wrapper]')).toBeTruthy() // Without , the contentDOMElement stays detached // (matching React's behavior). It exists but is not in the DOM tree. expect(el!.querySelector('[data-node-view-content]')).toBeFalsy() // Verify the internal contentDOM is not null (fixes the thrashing bug) const wrapperDesc = (el!.querySelector('[data-node-view-wrapper]') as any)?.pmViewDesc expect(wrapperDesc?.spec?.contentDOM).toBeTruthy() }) it('returns null for leaf nodes', async () => { await withEditor('', LeafComponent, true) expect(el!.querySelector('[data-node-view-content]')).toBeFalsy() }) it('works with in the component', async () => { await withEditor('Hello World', ComponentWithContent) const content = el!.querySelector('[data-node-view-content]') expect(content).toBeTruthy() expect(content!.textContent).toContain('Hello World') }) it('adopts as the contentDOM instead of nesting a wrapper div inside it', async () => { await withEditor('Hello World', ComponentWithTbodyContent) const tbody = el!.querySelector('tbody[data-node-view-content]') expect(tbody).toBeTruthy() // A may only contain elements, so the placeholder wrapper div // must never be nested inside it. expect(tbody!.querySelector('[data-node-view-content-vue]')).toBeFalsy() expect(tbody!.children.length).toBe(0) expect(tbody!.textContent).toContain('Hello World') }) it('does not throw on destroy', async () => { await withEditor('Hello World', ComponentWithoutContent) expect(() => editor!.destroy()).not.toThrow() }) // Uses `` as `contentDOM`; `contentDOMElementTag` only defines the fallback when none is rendered. it('ignores contentDOMElementTag when a real is present', async () => { await withEditor('Hello World', ComponentWithContent, false, { contentDOMElementTag: 'custom-content-dom', }) expect(el!.querySelector('custom-content-dom')).toBeFalsy() const content = el!.querySelector('[data-node-view-content]') expect(content).toBeTruthy() expect(content!.tagName.toLowerCase()).toBe('div') expect(content!.textContent).toContain('Hello World') }) it('use custom content dom element tag as the fallback when there is no ', async () => { await withEditor('Hello World', ComponentWithoutContent, false, { contentDOMElementTag: 'custom-content-dom', }) // The fallback placeholder stays detached (see "does not thrash the DOM" above), // so we assert on the internal contentDOM rather than querying the live DOM. const wrapperDesc = (el!.querySelector('[data-node-view-wrapper]') as any)?.pmViewDesc const contentDOM = wrapperDesc?.spec?.contentDOM as HTMLElement | undefined expect(contentDOM).toBeTruthy() expect(contentDOM!.tagName.toLowerCase()).toBe('custom-content-dom') }) })