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
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:
@@ -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'])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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> </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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
306
packages/extension-table/tests/integration/tableCommands.test.ts
Normal file
306
packages/extension-table/tests/integration/tableCommands.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
275
packages/extension-table/tests/integration/tableMarkdown.test.ts
Normal file
275
packages/extension-table/tests/integration/tableMarkdown.test.ts
Normal 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>')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user