snapshot of https://github.com/ueberdosis/tiptap.git (main, 2026-09-15; blobs > 131072B omitted)
Some checks failed
Verify / Install node dependencies (push) Failing after 2s
Verify / Build packages (push) Has been skipped
Verify / Check linting & formatting (push) Has been skipped
Verify / Run unit tests (push) Has been skipped
Verify / Run e2e tests (shard 1/4) (push) Has been skipped
Verify / Run e2e tests (shard 2/4) (push) Has been skipped
Verify / Run e2e tests (shard 3/4) (push) Has been skipped
Verify / Run e2e tests (shard 4/4) (push) Has been skipped
Publish / Resolve publish config (push) Failing after 2s
Publish / Send notification to Slack (push) Has been skipped
Publish / Send failure notification to Slack (push) Failing after 8s
Verify / Merge e2e reports (push) Has been skipped
Publish / Build (push) Has been skipped
Publish / Bump Versions OR Release to Registry (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-15 21:11:02 +05:00
commit 083a46e93b
2115 changed files with 245958 additions and 0 deletions

View File

@@ -0,0 +1,196 @@
import { Editor } from '@tiptap/core'
import Document from '@tiptap/extension-document'
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'
describe('colgroup updates after column commands', () => {
const editorElClass = 'tiptap'
let editor: Editor | null = null
const createEditorEl = () => {
const editorEl = document.createElement('div')
editorEl.classList.add(editorElClass)
document.body.appendChild(editorEl)
return editorEl
}
const getEditorEl = () => document.querySelector(`.${editorElClass}`)
const createEditor = (resizable: boolean) =>
new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
TableCell,
TableHeader,
TableRow,
Table.configure({ resizable }),
],
})
const countCols = (e: Editor) => e.view.dom.querySelectorAll('colgroup > col').length
const countCellsInFirstRow = (e: Editor) => {
const firstRow = e.view.dom.querySelector('tbody > tr')
return firstRow ? firstRow.querySelectorAll('td, th').length : 0
}
// Place cursor inside the first cell of the table so column commands have a target
const focusFirstCell = (e: Editor) => {
e.commands.focus()
e.commands.setTextSelection(3)
}
afterEach(() => {
editor?.destroy()
editor = null
getEditorEl()?.remove()
})
describe('resizable: false (default — bug case)', () => {
it('updates <colgroup> when a column is deleted', () => {
editor = createEditor(false)
editor.commands.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
expect(countCols(editor)).toBe(3)
expect(countCellsInFirstRow(editor)).toBe(3)
focusFirstCell(editor)
editor.commands.deleteColumn()
expect(countCellsInFirstRow(editor)).toBe(2)
expect(countCols(editor)).toBe(2)
})
it('updates <colgroup> when a column is added after', () => {
editor = createEditor(false)
editor.commands.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
expect(countCols(editor)).toBe(3)
focusFirstCell(editor)
editor.commands.addColumnAfter()
expect(countCellsInFirstRow(editor)).toBe(4)
expect(countCols(editor)).toBe(4)
})
it('updates <colgroup> when a column is added before', () => {
editor = createEditor(false)
editor.commands.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
expect(countCols(editor)).toBe(3)
focusFirstCell(editor)
editor.commands.addColumnBefore()
expect(countCellsInFirstRow(editor)).toBe(4)
expect(countCols(editor)).toBe(4)
})
})
describe('resizable: true (control — should already work)', () => {
it('updates <colgroup> when a column is deleted', () => {
editor = createEditor(true)
editor.commands.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
expect(countCols(editor)).toBe(3)
focusFirstCell(editor)
editor.commands.deleteColumn()
expect(countCellsInFirstRow(editor)).toBe(2)
expect(countCols(editor)).toBe(2)
})
it('updates <colgroup> when a column is added after', () => {
editor = createEditor(true)
editor.commands.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
expect(countCols(editor)).toBe(3)
focusFirstCell(editor)
editor.commands.addColumnAfter()
expect(countCellsInFirstRow(editor)).toBe(4)
expect(countCols(editor)).toBe(4)
})
})
describe('edge cases (resizable: false)', () => {
const getTable = (e: Editor) => e.view.dom.querySelector('table') as HTMLTableElement
it('updates <table> min-width style when columns change', () => {
editor = createEditor(false)
editor.commands.insertTable({ rows: 2, cols: 3, withHeaderRow: false })
// Default cellMinWidth is 25px → 3 cols × 25 = 75px
expect(getTable(editor).style.minWidth).toBe('75px')
focusFirstCell(editor)
editor.commands.deleteColumn()
expect(getTable(editor).style.minWidth).toBe('50px')
editor.commands.addColumnAfter()
expect(getTable(editor).style.minWidth).toBe('75px')
})
it('keeps <colgroup> consistent after mergeCells (no col change expected)', () => {
editor = createEditor(false)
editor.commands.insertTable({ rows: 2, cols: 3, withHeaderRow: false })
const initialCols = countCols(editor)
// select two adjacent cells then merge
editor.commands.focus()
editor.commands.setTextSelection({ from: 3, to: 8 })
editor.commands.mergeCells()
// mergeCells uses colspan; column count must not change
expect(countCols(editor)).toBe(initialCols)
})
it('updates <colgroup> in read-only mode when columns change programmatically', () => {
editor = createEditor(false)
editor.commands.insertTable({ rows: 2, cols: 3, withHeaderRow: false })
editor.setEditable(false)
expect(countCols(editor)).toBe(3)
focusFirstCell(editor)
editor.commands.deleteColumn()
expect(countCellsInFirstRow(editor)).toBe(2)
expect(countCols(editor)).toBe(2)
})
it('preserves remaining colwidths when a column is deleted', () => {
editor = createEditor(false)
editor.commands.setContent(
'<table><tbody><tr><td colwidth="100">A</td><td colwidth="200">B</td><td colwidth="300">C</td></tr></tbody></table>',
)
const widthsOf = () =>
Array.from(editor!.view.dom.querySelectorAll<HTMLTableColElement>('colgroup > col')).map(
c => c.style.width,
)
expect(widthsOf()).toEqual(['100px', '200px', '300px'])
// place cursor in the first cell, then delete it
editor.commands.focus()
editor.commands.setTextSelection(3)
editor.commands.deleteColumn()
// remaining widths must be the original 200 and 300, not 100/200 (which would
// indicate widths got reassigned to the wrong columns after the deletion)
expect(widthsOf()).toEqual(['200px', '300px'])
})
})
})

View File

@@ -0,0 +1,64 @@
import { Editor } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import { TableKit } from '@tiptap/extension-table'
import Text from '@tiptap/extension-text'
import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
const collectCellPositions = (editor: Editor): number[] => {
const positions: number[] = []
editor.state.doc.descendants((node, pos) => {
if (node.type.name === 'tableCell' || node.type.name === 'tableHeader') {
positions.push(pos)
}
})
return positions
}
const getCellContents = (editor: Editor): string[] => {
const contents: string[] = []
editor.state.doc.descendants(node => {
if (node.type.name === 'tableCell' || node.type.name === 'tableHeader') {
contents.push(node.textContent)
}
})
return contents
}
describe('deleteSelection with CellSelection', () => {
let editor: Editor
beforeEach(() => {
const element = document.createElement('div')
document.body.appendChild(element)
editor = new Editor({
element,
extensions: [Document, Paragraph, Text, TableKit],
content:
'<table><tbody><tr><td>A1</td><td>B1</td><td>C1</td></tr><tr><td>A2</td><td>B2</td><td>C2</td></tr></tbody></table>',
})
})
afterEach(() => {
editor.destroy()
})
it('clears every selected cell in a multi-cell CellSelection', () => {
const cells = collectCellPositions(editor)
// Row-major order: A1, B1, C1, A2, B2, C2
// Select the 2x2 block A1..B2, leaving C1 and C2 untouched.
editor.chain().focus().setCellSelection({ anchorCell: cells[0], headCell: cells[4] }).run()
const result = editor.commands.deleteSelection()
expect(result).toBe(true)
const contents = getCellContents(editor)
expect(contents[0]).toBe('') // A1
expect(contents[1]).toBe('') // B1
expect(contents[2]).toBe('C1')
expect(contents[3]).toBe('') // A2
expect(contents[4]).toBe('') // B2
expect(contents[5]).toBe('C2')
})
})

View File

@@ -0,0 +1,226 @@
import { Editor } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Heading from '@tiptap/extension-heading'
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'
/** Regression tests for empty `<td>`/`<th>` insertion — https://github.com/ueberdosis/tiptap/issues/6237 */
describe('extension table empty cell/header parsing', () => {
const editorElClass = 'tiptap'
let editor: Editor | null = null
const createEditorEl = () => {
const editorEl = document.createElement('div')
editorEl.classList.add(editorElClass)
document.body.appendChild(editorEl)
return editorEl
}
const getEditorEl = () => document.querySelector(`.${editorElClass}`)
const createTableEditor = () =>
new Editor({
element: createEditorEl(),
extensions: [
Document,
Text,
Paragraph,
TableCell,
TableHeader,
TableRow,
Table.configure({
resizable: true,
}),
],
content: '<p></p>',
})
afterEach(() => {
editor?.destroy()
editor = null
getEditorEl()?.remove()
})
it('inserts a table with an empty cell without throwing, backfilling an empty paragraph', () => {
editor = createTableEditor()
expect(() => {
editor?.commands.insertContentAt(0, '<table><tr><td></td></tr></table>')
}).not.toThrow()
const cellNode = editor.getJSON().content?.[0]?.content?.[0]?.content?.[0]
expect(cellNode?.type).toBe('tableCell')
expect(cellNode?.content).toEqual([{ type: 'paragraph' }])
})
it('inserts a table with an empty header cell without throwing, backfilling an empty paragraph', () => {
editor = createTableEditor()
expect(() => {
editor?.commands.insertContentAt(0, '<table><tr><th></th></tr></table>')
}).not.toThrow()
const headerNode = editor.getJSON().content?.[0]?.content?.[0]?.content?.[0]
expect(headerNode?.type).toBe('tableHeader')
expect(headerNode?.content).toEqual([{ type: 'paragraph' }])
})
it('inserts an empty cell via insertContent without throwing', () => {
editor = createTableEditor()
expect(() => {
editor?.commands.insertContent('<table><tr><td></td></tr></table>')
}).not.toThrow()
const cellNode = editor.getJSON().content?.[0]?.content?.[0]?.content?.[0]
expect(cellNode?.type).toBe('tableCell')
expect(cellNode?.content).toEqual([{ type: 'paragraph' }])
})
it('inserts an empty header cell via insertContent without throwing', () => {
editor = createTableEditor()
expect(() => {
editor?.commands.insertContent('<table><tr><th></th></tr></table>')
}).not.toThrow()
const headerNode = editor.getJSON().content?.[0]?.content?.[0]?.content?.[0]
expect(headerNode?.type).toBe('tableHeader')
expect(headerNode?.content).toEqual([{ type: 'paragraph' }])
})
it('backfills a whitespace-only cell when preserveWhitespace is false', () => {
editor = createTableEditor()
expect(() => {
editor?.commands.insertContentAt(0, '<table><tr><td> </td></tr></table>', {
parseOptions: { preserveWhitespace: false },
})
}).not.toThrow()
const cellNode = editor.getJSON().content?.[0]?.content?.[0]?.content?.[0]
expect(cellNode?.content).toEqual([{ type: 'paragraph' }])
})
it.each([
['default (no parseOptions)', undefined],
['preserveWhitespace: true', { preserveWhitespace: true as const }],
['preserveWhitespace: "full"', { preserveWhitespace: 'full' as const }],
])('backfills a whitespace-only cell identically to setContent — %s', (_label, parseOptions) => {
const tableHTML = '<table><tr><td> </td></tr></table>'
// setContent collapses a spaces-only cell too, so no whitespace is lost.
const setContentEditor = createTableEditor()
setContentEditor.commands.setContent(tableHTML)
const reference = setContentEditor.getJSON().content?.[0]?.content?.[0]?.content?.[0]?.content
setContentEditor.destroy()
editor = createTableEditor()
expect(() => {
editor?.commands.insertContentAt(0, tableHTML, parseOptions ? { parseOptions } : undefined)
}).not.toThrow()
const cellContent = editor.getJSON().content?.[0]?.content?.[0]?.content?.[0]?.content
expect(cellContent).toEqual(reference)
expect(cellContent).toEqual([{ type: 'paragraph' }])
})
it('produces the same empty cell as setContent does', () => {
const tableHTML = '<table><tr><td></td></tr></table>'
const setContentEditor = createTableEditor()
setContentEditor.commands.setContent(tableHTML)
const setContentCell =
setContentEditor.getJSON().content?.[0]?.content?.[0]?.content?.[0]?.content
setContentEditor.destroy()
editor = createTableEditor()
editor.commands.insertContentAt(0, tableHTML)
const insertContentCell = editor.getJSON().content?.[0]?.content?.[0]?.content?.[0]?.content
expect(insertContentCell).toEqual(setContentCell)
expect(insertContentCell).toEqual([{ type: 'paragraph' }])
})
it('preserves a non-breaking-space cell instead of treating it as empty', () => {
editor = createTableEditor()
// trim() would strip the NBSP; it must survive as text, not be backfilled.
editor.commands.insertContentAt(0, '<table><tr><td>&nbsp;</td></tr></table>')
const cellNode = editor.getJSON().content?.[0]?.content?.[0]?.content?.[0]
expect(cellNode?.type).toBe('tableCell')
expect(cellNode?.content).toEqual([
{ type: 'paragraph', content: [{ type: 'text', text: ' ' }] },
])
})
it('backfills using the schema block when no paragraph node exists', () => {
const el = createEditorEl()
// A valid schema whose only block is Heading (no Paragraph).
const headingOnlyEditor = new Editor({
element: el,
extensions: [Document, Text, Heading, TableCell, TableHeader, TableRow, Table],
content: '<h1>x</h1>',
})
expect(() => {
headingOnlyEditor.commands.insertContentAt(0, '<table><tr><td></td></tr></table>')
}).not.toThrow()
const cellNode = headingOnlyEditor.getJSON().content?.[0]?.content?.[0]?.content?.[0]
// Fill uses the schema's block (heading here), not a hard-coded paragraph.
expect(cellNode?.content?.[0]?.type).toBe('heading')
headingOnlyEditor.destroy()
el.remove()
})
it('keeps non-empty cell content intact (regression)', () => {
editor = createTableEditor()
editor.commands.insertContentAt(0, '<table><tr><td>hello</td></tr></table>')
const cellNode = editor.getJSON().content?.[0]?.content?.[0]?.content?.[0]
expect(cellNode?.content).toEqual([
{ type: 'paragraph', content: [{ type: 'text', text: 'hello' }] },
])
})
it('keeps non-empty header content intact (regression)', () => {
editor = createTableEditor()
editor.commands.insertContentAt(0, '<table><tr><th>hello</th></tr></table>')
const headerNode = editor.getJSON().content?.[0]?.content?.[0]?.content?.[0]
expect(headerNode?.content).toEqual([
{ type: 'paragraph', content: [{ type: 'text', text: 'hello' }] },
])
})
it('still parses colspan/colwidth on a multi-cell table (regression)', () => {
editor = createTableEditor()
editor.commands.insertContentAt(
0,
'<table><tr><td colwidth="200">Name</td><td colspan="2">Description</td></tr></table>',
)
const row = editor.getJSON().content?.[0]?.content?.[0]
expect(row?.content?.[0]?.attrs?.colwidth).toEqual([200])
expect(row?.content?.[1]?.attrs?.colspan).toBe(2)
})
})

View File

@@ -0,0 +1,131 @@
import { Editor } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import { TableKit } from '@tiptap/extension-table'
import Text from '@tiptap/extension-text'
import { afterEach, describe, expect, it } from 'vite-plus/test'
const callClipboardTextSerializer = (editor: Editor): string => {
const serializer = editor.view.someProp('clipboardTextSerializer') as
| ((slice: ReturnType<typeof editor.state.selection.content>) => string)
| undefined
if (!serializer) {
throw new Error('clipboardTextSerializer prop not registered')
}
return serializer(editor.state.selection.content())
}
const collectCellPositions = (editor: Editor): number[] => {
const positions: number[] = []
editor.state.doc.descendants((node, pos) => {
if (node.type.name === 'tableCell' || node.type.name === 'tableHeader') {
positions.push(pos)
}
})
return positions
}
describe('Table plain-text clipboard serialization', () => {
let editor: Editor
afterEach(() => {
editor?.destroy()
})
it('only serializes selected cells when CellSelection spans non-adjacent rows', () => {
const element = document.createElement('div')
document.body.appendChild(element)
editor = new Editor({
element,
extensions: [Document, Paragraph, Text, TableKit],
content: `
<table>
<tbody>
<tr><td>A1</td><td>B1</td></tr>
<tr><td>A2</td><td>B2</td></tr>
<tr><td>A3</td><td>B3</td></tr>
</tbody>
</table>
`,
})
// Document order: A1, B1, A2, B2, A3, B3
const cells = collectCellPositions(editor)
// Select column 0 across all three rows: A1 -> A3.
// ProseMirror's CellSelection turns this into a rectangular selection
// covering A1, A2, A3 only. The bug was that the plain-text serializer
// also pulled in B1 and B2 because they sit between A1 and A3 in doc order.
editor.chain().focus().setCellSelection({ anchorCell: cells[0], headCell: cells[4] }).run()
const result = callClipboardTextSerializer(editor)
expect(result).toContain('A1')
expect(result).toContain('A2')
expect(result).toContain('A3')
expect(result).not.toContain('B1')
expect(result).not.toContain('B2')
expect(result).not.toContain('B3')
})
it('emits cells in document order even when CellSelection is built bottom-up', () => {
const element = document.createElement('div')
document.body.appendChild(element)
editor = new Editor({
element,
extensions: [Document, Paragraph, Text, TableKit],
content: `
<table>
<tbody>
<tr><td>A1</td><td>B1</td></tr>
<tr><td>A2</td><td>B2</td></tr>
<tr><td>A3</td><td>B3</td></tr>
</tbody>
</table>
`,
})
const cells = collectCellPositions(editor)
// Reverse selection: anchor on the bottom cell, head on the top cell.
// Simulates a user dragging upward. ranges may not be in doc order.
editor.chain().focus().setCellSelection({ anchorCell: cells[4], headCell: cells[0] }).run()
const result = callClipboardTextSerializer(editor)
expect(result.indexOf('A1')).toBeLessThan(result.indexOf('A2'))
expect(result.indexOf('A2')).toBeLessThan(result.indexOf('A3'))
})
it('serializes adjacent cells in a row without dropping content', () => {
const element = document.createElement('div')
document.body.appendChild(element)
editor = new Editor({
element,
extensions: [Document, Paragraph, Text, TableKit],
content: `
<table>
<tbody>
<tr><td>A1</td><td>B1</td><td>C1</td></tr>
<tr><td>A2</td><td>B2</td><td>C2</td></tr>
</tbody>
</table>
`,
})
// Document order: A1, B1, C1, A2, B2, C2
const cells = collectCellPositions(editor)
// Select A1 and B1 (same row, adjacent).
editor.chain().focus().setCellSelection({ anchorCell: cells[0], headCell: cells[1] }).run()
const result = callClipboardTextSerializer(editor)
expect(result).toContain('A1')
expect(result).toContain('B1')
expect(result).not.toContain('C1')
expect(result).not.toContain('A2')
})
})

View File

@@ -0,0 +1,306 @@
import { Editor } from '@tiptap/core'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import { TableKit } from '@tiptap/extension-table'
import Text from '@tiptap/extension-text'
import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
const countCells = (editor: Editor, selector: string) =>
editor.view.dom.querySelectorAll(selector).length
const firstTableBounds = (editor: Editor) => {
let start = -1
let end = -1
editor.state.doc.descendants((node, pos) => {
if (node.type.name === 'table' && start === -1) {
start = pos
end = pos + node.nodeSize
}
})
return { start, end }
}
const selectFirstTwoHeaderCells = (editor: Editor) => {
const positions: number[] = []
editor.state.doc.descendants((node, pos) => {
if (node.type.name === 'tableHeader') {
positions.push(pos)
}
})
editor
.chain()
.focus()
.setCellSelection({ anchorCell: positions[0], headCell: positions[1] })
.run()
}
describe('Table commands', () => {
let editor: Editor
beforeEach(() => {
const element = document.createElement('div')
document.body.appendChild(element)
editor = new Editor({
element,
extensions: [Document, Paragraph, Text, TableKit],
content: '',
})
editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()
})
afterEach(() => {
editor.destroy()
})
it('adds a table with three columns and three rows', () => {
expect(countCells(editor, 'table')).toBe(1)
expect(countCells(editor, 'table tr')).toBe(3)
expect(countCells(editor, 'table th')).toBe(3)
expect(countCells(editor, 'table td')).toBe(6)
})
it('adds & deletes columns', () => {
editor.commands.addColumnBefore()
expect(countCells(editor, 'table th')).toBe(4)
editor.commands.addColumnAfter()
expect(countCells(editor, 'table th')).toBe(5)
editor.commands.deleteColumn()
editor.commands.deleteColumn()
expect(countCells(editor, 'table th')).toBe(3)
})
it('adds & deletes rows', () => {
editor.commands.addRowBefore()
expect(countCells(editor, 'table tr')).toBe(4)
editor.commands.addRowAfter()
expect(countCells(editor, 'table tr')).toBe(5)
editor.commands.deleteRow()
editor.commands.deleteRow()
expect(countCells(editor, 'table tr')).toBe(3)
})
it('keeps the cursor inside the table when deleting the last row', () => {
editor.commands.insertContentAt(editor.state.doc.content.size, '<p>Text below table</p>')
let lastCellPos = 0
editor.state.doc.descendants((node, pos) => {
if (node.type.name === 'tableCell') {
lastCellPos = pos
}
})
editor
.chain()
.focus()
.setTextSelection(lastCellPos + 2)
.run()
editor.commands.deleteRow()
expect(countCells(editor, 'table tr')).toBe(2)
expect(editor.isActive('table')).toBe(true)
})
it('keeps the cursor inside the table when deleting the last column', () => {
editor.commands.insertContentAt(editor.state.doc.content.size, '<p>Text below table</p>')
let lastCellPos = 0
editor.state.doc.descendants((node, pos) => {
if (node.type.name === 'tableCell') {
lastCellPos = pos
}
})
editor
.chain()
.focus()
.setTextSelection(lastCellPos + 2)
.run()
editor.commands.deleteColumn()
expect(countCells(editor, 'table th')).toBe(2)
expect(editor.isActive('table')).toBe(true)
})
it('keeps the cursor in the edited table when deleting the last row with a table below', () => {
editor.commands.insertContentAt(
editor.state.doc.content.size,
'<table><tbody><tr><td><p>Following table</p></td></tr></tbody></table>',
)
const { end: firstTableEnd } = firstTableBounds(editor)
let lastCellPos = 0
editor.state.doc.descendants((node, pos) => {
if (node.type.name === 'tableCell' && pos < firstTableEnd) {
lastCellPos = pos
}
})
editor
.chain()
.focus()
.setTextSelection(lastCellPos + 2)
.run()
editor.commands.deleteRow()
const { start, end } = firstTableBounds(editor)
const cursor = editor.state.selection.from
expect(editor.isActive('table')).toBe(true)
expect(cursor).toBeGreaterThan(start)
expect(cursor).toBeLessThan(end)
})
it('keeps the cursor in the edited table when deleting the last column with a table below', () => {
editor.commands.insertContentAt(
editor.state.doc.content.size,
'<table><tbody><tr><td><p>Following table</p></td></tr></tbody></table>',
)
const { end: firstTableEnd } = firstTableBounds(editor)
let lastCellPos = 0
editor.state.doc.descendants((node, pos) => {
if (node.type.name === 'tableCell' && pos < firstTableEnd) {
lastCellPos = pos
}
})
editor
.chain()
.focus()
.setTextSelection(lastCellPos + 2)
.run()
editor.commands.deleteColumn()
const { start, end } = firstTableBounds(editor)
const cursor = editor.state.selection.from
expect(editor.isActive('table')).toBe(true)
expect(cursor).toBeGreaterThan(start)
expect(cursor).toBeLessThan(end)
})
it('deletes the table', () => {
editor.commands.deleteTable()
expect(countCells(editor, 'table')).toBe(0)
})
it('merges cells', () => {
selectFirstTwoHeaderCells(editor)
editor.commands.mergeCells()
expect(countCells(editor, 'table th')).toBe(2)
})
it('splits cells', () => {
selectFirstTwoHeaderCells(editor)
editor.commands.mergeCells()
expect(countCells(editor, 'table th')).toBe(2)
editor.commands.splitCell()
expect(countCells(editor, 'table th')).toBe(3)
})
it('toggles header columns', () => {
editor.commands.toggleHeaderColumn()
expect(countCells(editor, 'table th')).toBe(5)
})
it('toggles header row', () => {
editor.commands.toggleHeaderRow()
expect(countCells(editor, 'table th')).toBe(0)
})
it('merges and splits via mergeOrSplit', () => {
selectFirstTwoHeaderCells(editor)
editor.commands.mergeCells()
expect(editor.view.dom.querySelector('th[colspan="2"]')).not.toBeNull()
editor.commands.mergeOrSplit()
expect(editor.view.dom.querySelector('th[colspan="2"]')).toBeNull()
})
it('creates a 1x1 table', () => {
editor.commands.clearContent()
editor.commands.insertTable({ cols: 1, rows: 1, withHeaderRow: false })
expect(countCells(editor, 'td')).toBe(1)
expect(countCells(editor, 'tr')).toBe(1)
})
it('creates a 3x1 table', () => {
editor.commands.clearContent()
editor.commands.insertTable({ cols: 3, rows: 1, withHeaderRow: false })
expect(countCells(editor, 'td')).toBe(3)
expect(countCells(editor, 'tr')).toBe(1)
})
it('creates a 1x3 table', () => {
editor.commands.clearContent()
editor.commands.insertTable({ cols: 1, rows: 3, withHeaderRow: false })
expect(countCells(editor, 'td')).toBe(3)
expect(countCells(editor, 'tr')).toBe(3)
})
it('creates a 1x3 table with header row', () => {
editor.commands.clearContent()
editor.commands.insertTable({ cols: 1, rows: 3, withHeaderRow: true })
expect(countCells(editor, 'th')).toBe(1)
expect(countCells(editor, 'td')).toBe(2)
expect(countCells(editor, 'tr')).toBe(3)
})
it('creates a 3x3 table with defaults', () => {
editor.commands.clearContent()
editor.commands.insertTable()
expect(countCells(editor, 'th')).toBe(3)
expect(countCells(editor, 'td')).toBe(6)
expect(countCells(editor, 'tr')).toBe(3)
})
it('sets a minimum width on colgroups', () => {
editor.commands.clearContent()
editor.commands.insertTable({ cols: 3, rows: 1, withHeaderRow: false })
const firstCol = editor.view.dom.querySelector('col')
expect(firstCol?.getAttribute('style')).toBe('min-width: 25px;')
})
it('generates correct markup for a 1x1 table', () => {
editor.commands.clearContent()
editor.commands.insertTable({ cols: 1, rows: 1, withHeaderRow: false })
expect(editor.getHTML()).toBe(
'<table style="min-width: 25px;"><colgroup><col style="min-width: 25px;"></colgroup><tbody><tr><td colspan="1" rowspan="1"><p></p></td></tr></tbody></table>',
)
})
it('generates correct markup for a 1x1 table with header', () => {
editor.commands.clearContent()
editor.commands.insertTable({ cols: 1, rows: 1, withHeaderRow: true })
expect(editor.getHTML()).toBe(
'<table style="min-width: 25px;"><colgroup><col style="min-width: 25px;"></colgroup><tbody><tr><th colspan="1" rowspan="1"><p></p></th></tr></tbody></table>',
)
})
it('moves focus between cells with goToNextCell / goToPreviousCell', () => {
let firstHeaderPos = -1
editor.state.doc.descendants((node, pos) => {
if (node.type.name === 'tableHeader' && firstHeaderPos === -1) {
firstHeaderPos = pos
}
})
editor
.chain()
.focus()
.setTextSelection(firstHeaderPos + 1)
.insertContent('Column 1')
.run()
editor.commands.goToNextCell()
editor.commands.insertContent('Column 2')
editor.commands.goToPreviousCell()
const ths = editor.view.dom.querySelectorAll('table th')
expect(ths[0].textContent).toContain('Column 1')
expect(ths[1].textContent).toContain('Column 2')
})
})

View File

@@ -0,0 +1,275 @@
import { Code } from '@tiptap/extension-code'
import Document from '@tiptap/extension-document'
import HardBreak from '@tiptap/extension-hard-break'
import Paragraph from '@tiptap/extension-paragraph'
import { TableKit } from '@tiptap/extension-table'
import Text from '@tiptap/extension-text'
import { MarkdownManager } from '@tiptap/markdown'
import { describe, expect, it } from 'vite-plus/test'
describe('table markdown — inline code with pipe characters', () => {
const manager = new MarkdownManager({
extensions: [Document, Paragraph, Text, Code, TableKit],
})
it('should parse `||` inside a code span as a single cell', () => {
const markdown = '| Header |\n| ------ |\n| `||` |'
const parsed = manager.parse(markdown)
const bodyRow = parsed.content?.[0]?.content?.[1]?.content || []
expect(bodyRow).toHaveLength(1)
const textNode = bodyRow[0]?.content?.[0]?.content?.[0]
expect(textNode?.text).toBe('||')
expect(textNode?.marks?.[0]?.type).toBe('code')
})
it('should parse `a || b` inside a code span as a single cell', () => {
const markdown = '| H | H | H |\n| - | - | - |\n| `||` | or | `a || b` |'
const parsed = manager.parse(markdown)
const table = parsed.content?.[0]
expect(table?.type).toBe('table')
const bodyRow = table?.content?.[1]?.content || []
expect(bodyRow).toHaveLength(3)
const cell1 = bodyRow[0]?.content?.[0]?.content?.[0]
expect(cell1?.text).toBe('||')
expect(cell1?.marks?.[0]?.type).toBe('code')
const cell2 = bodyRow[1]?.content?.[0]?.content?.[0]
expect(cell2?.text).toBe('or')
expect(cell2?.marks).toBeUndefined()
const cell3 = bodyRow[2]?.content?.[0]?.content?.[0]
expect(cell3?.text).toBe('a || b')
expect(cell3?.marks?.[0]?.type).toBe('code')
})
it('should parse `&&` inside a code span correctly (regression guard)', () => {
const markdown = '| H | H | H |\n| - | - | - |\n| `&&` | and | `a && b` |'
const parsed = manager.parse(markdown)
const bodyRow = parsed.content?.[0]?.content?.[1]?.content || []
expect(bodyRow).toHaveLength(3)
const cell1 = bodyRow[0]?.content?.[0]?.content?.[0]
expect(cell1?.text).toBe('&&')
expect(cell1?.marks?.[0]?.type).toBe('code')
const cell3 = bodyRow[2]?.content?.[0]?.content?.[0]
expect(cell3?.text).toBe('a && b')
expect(cell3?.marks?.[0]?.type).toBe('code')
})
it('should roundtrip table with inline code containing pipes', () => {
const markdown = '| H |\n| - |\n| `a || b` |'
const parsed = manager.parse(markdown)
const serialized = manager.serialize(parsed)
const reparsed = manager.parse(serialized)
const cell = reparsed.content?.[0]?.content?.[1]?.content?.[0]?.content?.[0]?.content?.[0]
expect(cell?.text).toBe('a || b')
expect(cell?.marks?.[0]?.type).toBe('code')
})
it('should not affect table cells without backtick spans', () => {
const markdown = '| a | b |\n| - | - |\n| 1 | 2 |'
const parsed = manager.parse(markdown)
const bodyRow = parsed.content?.[0]?.content?.[1]?.content || []
expect(bodyRow).toHaveLength(2)
expect(bodyRow[0]?.content?.[0]?.content?.[0]?.text).toBe('1')
expect(bodyRow[1]?.content?.[0]?.content?.[0]?.text).toBe('2')
})
it('should not corrupt content that follows a table with inline code containing pipes', () => {
const markdown = '| H |\n| - |\n| `a || b` |\n\nParagraph after.'
const parsed = manager.parse(markdown)
expect(parsed.content).toHaveLength(2)
expect(parsed.content?.[0]?.type).toBe('table')
expect(parsed.content?.[1]?.type).toBe('paragraph')
expect(parsed.content?.[1]?.content?.[0]?.text).toBe('Paragraph after.')
})
it('should not affect inline code with pipes outside of tables', () => {
const markdown = 'Use `a || b` for logical or.'
const parsed = manager.parse(markdown)
// Must parse as a paragraph, not accidentally as a table
const block = parsed.content?.[0]
expect(block?.type).toBe('paragraph')
// The code mark must be present on one of the inline nodes
const codeNode = block?.content?.find(n => n.marks?.some(m => m.type === 'code'))
expect(codeNode).toBeDefined()
expect(codeNode?.marks?.[0]?.type).toBe('code')
})
it('should treat a setext heading with pipe in title as a heading, not a table', () => {
// '`a | b`\n---' is a setext h2, not a table. Our tokenizer must not
// activate on it and must not contaminate the inline queue via
// helper.blockTokens (which would turn '|' into '\|' in the output).
const markdown = '`a | b`\n---'
const parsed = manager.parse(markdown)
const block = parsed.content?.[0]
expect(block?.type).toBe('heading')
// The pipe must be preserved verbatim inside the code span.
const codeNode = block?.content?.find((n: any) => n.marks?.some((m: any) => m.type === 'code'))
expect(codeNode?.text).toBe('a | b')
})
it('should parse `||` inside a code span in a pipeless table as a single cell', () => {
const markdown = 'Expression | Meaning\n---------- | -------\n`||` | or'
const parsed = manager.parse(markdown)
const table = parsed.content?.[0]
expect(table?.type).toBe('table')
const bodyRow = table?.content?.[1]?.content || []
expect(bodyRow).toHaveLength(2)
const cell1 = bodyRow[0]?.content?.[0]?.content?.[0]
expect(cell1?.text).toBe('||')
expect(cell1?.marks?.[0]?.type).toBe('code')
const cell2 = bodyRow[1]?.content?.[0]?.content?.[0]
expect(cell2?.text).toBe('or')
})
it('should not corrupt content that follows a pipeless table with inline code containing pipes', () => {
const markdown = 'H1 | H2\n-- | --\n`a || b` | x\n\nParagraph after.'
const parsed = manager.parse(markdown)
expect(parsed.content).toHaveLength(2)
expect(parsed.content?.[0]?.type).toBe('table')
expect(parsed.content?.[1]?.type).toBe('paragraph')
expect(parsed.content?.[1]?.content?.[0]?.text).toBe('Paragraph after.')
})
it('should include a no-pipe body row as a table row, matching marked native behavior', () => {
// marked treats any non-blank line before the blank line as a table body row,
// even if it contains no pipe. Our tokenizer must not cut it off into a paragraph.
const markdown = 'A | B\n--- | ---\n`a || b` | c\njust some text\n\nAfter'
const parsed = manager.parse(markdown)
const table = parsed.content?.[0]
expect(table?.type).toBe('table')
// 2 body rows: "`a || b` | c" and "just some text"
expect(table?.content).toHaveLength(3) // header row + 2 body rows
expect(parsed.content?.[1]?.type).toBe('paragraph')
expect(parsed.content?.[1]?.content?.[0]?.text).toBe('After')
})
})
describe('table markdown alignment', () => {
const markdownManager = new MarkdownManager({
extensions: [Document, Paragraph, Text, TableKit],
})
it('should parse and serialize left/right/center table alignment', () => {
const markdown = `| left | right | center |
| :---- | ----: | :-----: |
| a | b | c |`
const parsed = markdownManager.parse(markdown)
const table = parsed.content?.[0]
expect(table?.type).toBe('table')
const headerCells = table?.content?.[0]?.content || []
expect(headerCells[0]?.attrs?.align).toBe('left')
expect(headerCells[1]?.attrs?.align).toBe('right')
expect(headerCells[2]?.attrs?.align).toBe('center')
const serialized = markdownManager.serialize(parsed)
expect(serialized).toContain('| left | right | center |')
expect(serialized).toMatch(/\|\s*:[-]+\s*\|\s*[-]+:\s*\|\s*:[-]+:\s*\|/)
})
})
describe('table markdown line breaks', () => {
const markdownManager = new MarkdownManager({
extensions: [Document, Paragraph, Text, HardBreak, TableKit],
})
const cell = (content: Record<string, unknown>[]) => ({
type: 'tableCell',
content,
})
const tableDoc = (cells: ReturnType<typeof cell>[]) => ({
type: 'doc',
content: [
{
type: 'table',
content: [{ type: 'tableRow', content: cells }],
},
],
})
it('keeps hard breaks in cells as <br>', () => {
const doc = tableDoc([
cell([
{
type: 'paragraph',
content: [
{ type: 'text', text: 'foo' },
{ type: 'hardBreak' },
{ type: 'text', text: 'bar' },
],
},
]),
])
const serialized = markdownManager.serialize(doc)
expect(serialized).toContain('foo<br>bar')
})
it('keeps consecutive hard breaks as separate <br> tags', () => {
const doc = tableDoc([
cell([
{
type: 'paragraph',
content: [
{ type: 'text', text: 'foo' },
{ type: 'hardBreak' },
{ type: 'hardBreak' },
{ type: 'text', text: 'bar' },
],
},
]),
])
const serialized = markdownManager.serialize(doc)
expect(serialized).toContain('foo<br><br>bar')
})
it('round trips <br> inside cells', () => {
const markdown = `| desc |
| --- |
| foo<br>bar |`
const parsed = markdownManager.parse(markdown)
const parsedText = JSON.stringify(parsed)
expect(parsedText).toContain('"hardBreak"')
const serialized = markdownManager.serialize(parsed)
expect(serialized).toContain('foo<br>bar')
})
it('joins multiple paragraphs in a cell with <br>', () => {
const doc = tableDoc([
cell([
{ type: 'paragraph', content: [{ type: 'text', text: 'one' }] },
{ type: 'paragraph', content: [{ type: 'text', text: 'two' }] },
]),
])
const serialized = markdownManager.serialize(doc)
expect(serialized).toContain('one<br>two')
expect(serialized).not.toContain('\u001F')
})
it('leaves cells without breaks alone', () => {
const doc = tableDoc([
cell([{ type: 'paragraph', content: [{ type: 'text', text: 'plain' }] }]),
])
const serialized = markdownManager.serialize(doc)
expect(serialized).toContain('| plain |')
expect(serialized).not.toContain('<br>')
})
})