import Blockquote from '@tiptap/extension-blockquote' import BulletList from '@tiptap/extension-bullet-list' import Document from '@tiptap/extension-document' import HardBreak from '@tiptap/extension-hard-break' import HorizontalRule from '@tiptap/extension-horizontal-rule' import { ListItem } from '@tiptap/extension-list' import OrderedList from '@tiptap/extension-ordered-list' import Paragraph from '@tiptap/extension-paragraph' import { Table, TableCell, TableHeader, TableRow } from '@tiptap/extension-table' import Text from '@tiptap/extension-text' import { afterEach, describe, expect, it } from 'vite-plus/test' import { Editor } from './Editor.js' import { Node } from './Node.js' // Simple inline node for testing (similar to mention) const CustomInlineNode = Node.create({ name: 'customInline', group: 'inline', inline: true, atom: true, addAttributes() { return { id: { default: null, }, } }, parseHTML() { return [ { tag: 'span[data-type="custom-inline"]', }, ] }, renderHTML({ HTMLAttributes }) { return ['span', { 'data-type': 'custom-inline', ...HTMLAttributes }, 0] }, }) const CustomBlockAtomNode = Node.create({ name: 'customBlockAtom', group: 'block', atom: true, addAttributes() { return { id: { default: null, }, } }, parseHTML() { return [ { tag: 'div[data-type="custom-block-atom"]', }, ] }, renderHTML({ HTMLAttributes }) { return ['div', { 'data-type': 'custom-block-atom', ...HTMLAttributes }] }, }) describe('NodePos', () => { let editor: Editor afterEach(() => { editor?.destroy() }) describe('$nodes', () => { it('should return text nodes', () => { editor = new Editor({ extensions: [Document, Paragraph, Text], content: '
Hello World
', }) const textNodes = editor.$nodes('text') expect(textNodes).not.toBeNull() expect(textNodes!.length).toBe(1) expect(textNodes![0].textContent).toBe('Hello World') }) it('should return multiple text nodes across paragraphs', () => { editor = new Editor({ extensions: [Document, Paragraph, Text], content: 'First
Second
Third
', }) const textNodes = editor.$nodes('text') expect(textNodes).not.toBeNull() expect(textNodes!.length).toBe(3) expect(textNodes![0].textContent).toBe('First') expect(textNodes![1].textContent).toBe('Second') expect(textNodes![2].textContent).toBe('Third') }) it('should return inline atom nodes', () => { editor = new Editor({ extensions: [Document, Paragraph, Text, CustomInlineNode], content: 'Hello World
', }) const inlineNodes = editor.$nodes('customInline') expect(inlineNodes).not.toBeNull() expect(inlineNodes!.length).toBe(1) expect(inlineNodes![0].node.type.name).toBe('customInline') }) it('should return multiple inline nodes', () => { editor = new Editor({ extensions: [Document, Paragraph, Text, CustomInlineNode], content: 'Hello and World
', }) const inlineNodes = editor.$nodes('customInline') expect(inlineNodes).not.toBeNull() expect(inlineNodes!.length).toBe(2) }) it('should return inline nodes with attribute filtering', () => { editor = new Editor({ extensions: [Document, Paragraph, Text, CustomInlineNode], content: 'Hello and World
', }) // Note: HTML attribute parsing converts numeric strings to numbers const inlineNodes = editor.$nodes('customInline', { id: 1 }) expect(inlineNodes).not.toBeNull() expect(inlineNodes!.length).toBe(1) expect(inlineNodes![0].node.attrs.id).toBe(1) }) it('should return paragraph nodes', () => { editor = new Editor({ extensions: [Document, Paragraph, Text], content: 'First
Second
', }) const paragraphNodes = editor.$nodes('paragraph') expect(paragraphNodes).not.toBeNull() expect(paragraphNodes!.length).toBe(2) }) it('should return inline nodes nested in multiple paragraphs', () => { editor = new Editor({ extensions: [Document, Paragraph, Text, CustomInlineNode], content: 'Para 1
Para 2
', }) const inlineNodes = editor.$nodes('customInline') expect(inlineNodes).not.toBeNull() expect(inlineNodes!.length).toBe(2) // Note: HTML attribute parsing converts numeric strings to numbers expect(inlineNodes![0].node.attrs.id).toBe(1) expect(inlineNodes![1].node.attrs.id).toBe(2) }) it('should find paragraphs inside blockquotes', () => { editor = new Editor({ extensions: [Document, Paragraph, Text, Blockquote], content: '', }) const paragraphs = editor.$nodes('paragraph') expect(paragraphs).not.toBeNull() expect(paragraphs!.length).toBe(1) expect(paragraphs![0].textContent).toBe('Quoted text') }) it('should find blockquote nodes', () => { editor = new Editor({ extensions: [Document, Paragraph, Text, Blockquote], content: 'Quoted text
First quote
Normal
', }) const blockquotes = editor.$nodes('blockquote') expect(blockquotes).not.toBeNull() expect(blockquotes!.length).toBe(2) }) it('should find text nodes inside nested block structures', () => { editor = new Editor({ extensions: [Document, Paragraph, Text, Blockquote], content: 'Second quote
Quoted
Normal
', }) const textNodes = editor.$nodes('text') expect(textNodes).not.toBeNull() expect(textNodes!.length).toBe(2) expect(textNodes![0].textContent).toBe('Quoted') expect(textNodes![1].textContent).toBe('Normal') }) it('should find list items in bullet lists', () => { editor = new Editor({ extensions: [Document, Paragraph, Text, BulletList, ListItem], content: 'Item 1
Item 2
Item 1
Item 2
Item
Item
Content
', }) const paragraphs = editor.$nodes('paragraph') expect(paragraphs).not.toBeNull() expect(paragraphs!.length).toBe(3) }) it('should return empty array for text nodes when paragraph is empty', () => { editor = new Editor({ extensions: [Document, Paragraph, Text], content: '', }) const textNodes = editor.$nodes('text') expect(textNodes).not.toBeNull() expect(textNodes!.length).toBe(0) }) it('should find all node types in mixed content', () => { editor = new Editor({ extensions: [Document, Paragraph, Text, Blockquote, CustomInlineNode], content: 'Normal
', }) const paragraphs = editor.$nodes('paragraph') const blockquotes = editor.$nodes('blockquote') const textNodes = editor.$nodes('text') const inlineNodes = editor.$nodes('customInline') expect(paragraphs!.length).toBe(2) expect(blockquotes!.length).toBe(1) expect(textNodes!.length).toBe(2) expect(inlineNodes!.length).toBe(2) }) }) describe('$node', () => { it('should find a single text node', () => { editor = new Editor({ extensions: [Document, Paragraph, Text], content: 'Quoted
Hello
', }) const textNode = editor.$node('text') expect(textNode).not.toBeNull() expect(textNode!.textContent).toBe('Hello') }) it('should find a single inline node', () => { editor = new Editor({ extensions: [Document, Paragraph, Text, CustomInlineNode], content: '', }) const inlineNode = editor.$node('customInline') expect(inlineNode).not.toBeNull() expect(inlineNode!.node.attrs.id).toBe('found') }) }) describe('children', () => { it('should include text nodes as children of paragraph', () => { editor = new Editor({ extensions: [Document, Paragraph, Text], content: '
Hello World
', }) const paragraph = editor.$node('paragraph') expect(paragraph).not.toBeNull() const children = paragraph!.children expect(children.length).toBe(1) expect(children[0].node.type.name).toBe('text') expect(children[0].textContent).toBe('Hello World') }) it('should include inline atom nodes as children of paragraph', () => { editor = new Editor({ extensions: [Document, Paragraph, Text, CustomInlineNode], content: 'Hello World
', }) const paragraph = editor.$node('paragraph') expect(paragraph).not.toBeNull() const children = paragraph!.children // Should have: "Hello ", customInline, " World" expect(children.length).toBe(3) expect(children[0].node.type.name).toBe('text') expect(children[0].textContent).toBe('Hello ') expect(children[1].node.type.name).toBe('customInline') expect(children[2].node.type.name).toBe('text') expect(children[2].textContent).toBe(' World') }) it('should return correct node reference for inline nodes', () => { editor = new Editor({ extensions: [Document, Paragraph, Text, CustomInlineNode], content: '', }) const inlineNodes = editor.$nodes('customInline') expect(inlineNodes).not.toBeNull() expect(inlineNodes!.length).toBe(1) expect(inlineNodes![0].node.attrs.id).toBe('test-id') }) }) describe('querySelector', () => { it('should return first matching node', () => { editor = new Editor({ extensions: [Document, Paragraph, Text, CustomInlineNode], content: '
', }) const paragraph = editor.$node('paragraph') const firstInline = paragraph!.querySelector('customInline') expect(firstInline).not.toBeNull() expect(firstInline!.node.attrs.id).toBe(1) }) it('should return all matching nodes with querySelectorAll', () => { editor = new Editor({ extensions: [Document, Paragraph, Text, CustomInlineNode], content: '
', }) const paragraph = editor.$node('paragraph') const allInlines = paragraph!.querySelectorAll('customInline') expect(allInlines.length).toBe(2) }) it('should filter correctly with attributes', () => { editor = new Editor({ extensions: [Document, Paragraph, Text, CustomInlineNode], content: '
', }) const paragraph = editor.$node('paragraph') const specific = paragraph!.querySelector('customInline', { id: 2 }) expect(specific).not.toBeNull() expect(specific!.node.attrs.id).toBe(2) }) }) describe('node position properties', () => { it('should return correct from/to positions for inline nodes', () => { editor = new Editor({ extensions: [Document, Paragraph, Text, CustomInlineNode], content: '
Hi there
', }) const inlineNode = editor.$node('customInline') expect(inlineNode).not.toBeNull() expect(inlineNode!.from).toBeLessThan(inlineNode!.to) expect(inlineNode!.size).toBeGreaterThan(0) }) it('should return correct from/to positions for text nodes', () => { editor = new Editor({ extensions: [Document, Paragraph, Text], content: 'Hello
', }) const textNode = editor.$node('text') expect(textNode).not.toBeNull() expect(textNode!.from).toBeLessThan(textNode!.to) expect(textNode!.size).toBe(5) // "Hello" is 5 characters }) it('should return correct range for nodes', () => { editor = new Editor({ extensions: [Document, Paragraph, Text], content: 'Hello
', }) const paragraph = editor.$node('paragraph') expect(paragraph).not.toBeNull() expect(paragraph!.range.from).toBe(paragraph!.from) expect(paragraph!.range.to).toBe(paragraph!.to) }) }) describe('firstChild and lastChild', () => { it('should return correct firstChild for paragraph with text', () => { editor = new Editor({ extensions: [Document, Paragraph, Text], content: 'Hello World
', }) const paragraph = editor.$node('paragraph') const firstChild = paragraph!.firstChild expect(firstChild).not.toBeNull() expect(firstChild!.node.type.name).toBe('text') expect(firstChild!.textContent).toBe('Hello World') }) it('should return correct lastChild for paragraph with mixed content', () => { editor = new Editor({ extensions: [Document, Paragraph, Text, CustomInlineNode], content: 'Hello
', }) const paragraph = editor.$node('paragraph') const lastChild = paragraph!.lastChild expect(lastChild).not.toBeNull() expect(lastChild!.node.type.name).toBe('customInline') }) it('should return null for firstChild/lastChild on empty paragraph', () => { editor = new Editor({ extensions: [Document, Paragraph, Text], content: '', }) const paragraph = editor.$node('paragraph') expect(paragraph!.firstChild).toBeNull() expect(paragraph!.lastChild).toBeNull() }) }) describe('parent', () => { it('should return parent for paragraph node', () => { editor = new Editor({ extensions: [Document, Paragraph, Text], content: 'Hello
', }) const paragraph = editor.$node('paragraph') const parent = paragraph!.parent expect(parent).not.toBeNull() expect(parent!.node.type.name).toBe('doc') }) it('should return parent for blockquote node', () => { editor = new Editor({ extensions: [Document, Paragraph, Text, Blockquote], content: '', }) const blockquote = editor.$node('blockquote') const parent = blockquote!.parent expect(parent).not.toBeNull() expect(parent!.node.type.name).toBe('doc') }) it('should return parent for nested paragraph in blockquote', () => { editor = new Editor({ extensions: [Document, Paragraph, Text, Blockquote], content: 'Quoted
', }) const paragraph = editor.$node('paragraph') const parent = paragraph!.parent expect(parent).not.toBeNull() expect(parent!.node.type.name).toBe('blockquote') }) }) describe('$pos', () => { it('should return the correct node when pointing at a non-text atom node', () => { editor = new Editor({ extensions: [Document, Paragraph, Text, CustomInlineNode], content: 'Quoted
', }) const inlineNode = editor.$node('customInline') expect(inlineNode).not.toBeNull() const nodeAtPos = editor.$pos(inlineNode!.pos) expect(nodeAtPos.node.type.name).toBe('customInline') expect(nodeAtPos.node.type.name).not.toBe('doc') }) it('should return doc node when resolving pos 0', () => { editor = new Editor({ extensions: [Document, Paragraph, Text], content: '
Hello
', }) const docPos = editor.$pos(0) expect(docPos.node.type.name).toBe('doc') }) it('should return the top-level block atom node for positions before non-text block atoms', () => { editor = new Editor({ extensions: [Document, Paragraph, Text, CustomBlockAtomNode], content: 'Before
', }) const blockAtom = editor.$node('customBlockAtom') expect(blockAtom).not.toBeNull() const nodeAtPos = editor.$pos(blockAtom!.pos) expect(nodeAtPos.node.type.name).toBe('customBlockAtom') expect(nodeAtPos.node.type.name).not.toBe('doc') }) it('should return the containing node for positions inside containers, not the child after them', () => { editor = new Editor({ extensions: [Document, Paragraph, Text, OrderedList, ListItem, Blockquote], content: 'Hello
', }) // pos 1 sits before the list item, pos 12 before the inner paragraph expect(editor.$pos(1).node.type.name).toBe('orderedList') expect(editor.$pos(12).node.type.name).toBe('blockquote') }) it('should still resolve text positions to the containing node', () => { editor = new Editor({ extensions: [Document, Paragraph, Text], content: 'World
Hello
', }) expect(editor.$pos(3).node.type.name).toBe('paragraph') }) it('should return the list for the boundary between two list items', () => { editor = new Editor({ extensions: [Document, Paragraph, Text, OrderedList, ListItem], content: 'a
b
a
b
a | b |
Hello
World
a
b
', }) expect(editor.$pos(3).node.type.name).toBe('horizontalRule') expect(editor.$pos(4).node.type.name).toBe('doc') }) it('should return the doc for the position at the very end of the document', () => { editor = new Editor({ extensions: [Document, Paragraph, Text], content: 'Hi
', }) expect(editor.$pos(editor.state.doc.content.size).node.type.name).toBe('doc') }) it('should return the following atom for the boundary between two block atoms', () => { editor = new Editor({ extensions: [Document, Paragraph, Text, CustomBlockAtomNode], content: 'a
', }) expect(editor.$pos(4).node.type.name).toBe('customBlockAtom') }) it('should return inline atoms nested inside list items', () => { editor = new Editor({ extensions: [Document, Paragraph, Text, BulletList, ListItem, CustomInlineNode], content: 'x
First
Second
', }) const doc = editor.$doc expect(doc).not.toBeNull() expect(doc!.children.length).toBe(2) expect(doc!.children[0].node.type.name).toBe('paragraph') expect(doc!.children[1].node.type.name).toBe('paragraph') }) it('should handle deeply nested structures', () => { editor = new Editor({ extensions: [Document, Paragraph, Text, Blockquote, CustomInlineNode], content: '', }) const inlineNode = editor.$node('customInline') expect(inlineNode).not.toBeNull() expect(inlineNode!.node.attrs.id).toBe('deep') // Verify the structure const blockquote = editor.$node('blockquote') const paragraph = editor.$node('paragraph') expect(blockquote).not.toBeNull() expect(paragraph).not.toBeNull() expect(paragraph!.parent!.node.type.name).toBe('blockquote') }) }) })Quote with