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')
})
})