From dd35f8274117291a73041f33ad47f0e390eac04c Mon Sep 17 00:00:00 2001 From: Jonas Date: Thu, 31 Jul 2025 14:29:51 +0200 Subject: [PATCH 01/11] feat(table): Allow multiline cells in tables Allow paragraphs, lists, code blocks and images in table cells for now. Signed-off-by: Jonas --- package-lock.json | 11 ++ package.json | 1 + src/markdownit/index.js | 7 + src/nodes/Table/Table.js | 13 +- src/nodes/Table/TableCell.js | 4 +- src/nodes/Table/TableHeadRow.js | 2 + src/nodes/Table/TableHeader.js | 2 + src/nodes/Table/TableRow.js | 2 + src/nodes/Table/markdown.ts | 156 ++++++++++++++++++ .../tables/handbook/handbook.out.html | 30 ++-- src/tests/markdown.spec.js | 6 + src/tests/markdownit/multimd-tables.spec.js | 86 ++++++++++ src/tests/nodes/Table.spec.js | 25 +-- 13 files changed, 313 insertions(+), 32 deletions(-) create mode 100644 src/nodes/Table/markdown.ts create mode 100644 src/tests/markdownit/multimd-tables.spec.js diff --git a/package-lock.json b/package-lock.json index d5aa690515e..de227be6a5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -73,6 +73,7 @@ "markdown-it-container": "^4.0.0", "markdown-it-front-matter": "^0.2.4", "markdown-it-image-figures": "^2.1.1", + "markdown-it-multimd-table": "^4.2.3", "mermaid": "^11.9.0", "mitt": "^3.0.1", "path-normalize": "^6.0.13", @@ -14052,6 +14053,11 @@ "markdown-it": "*" } }, + "node_modules/markdown-it-multimd-table": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/markdown-it-multimd-table/-/markdown-it-multimd-table-4.2.3.tgz", + "integrity": "sha512-KepCr2OMJqm7IT6sOIbuqHGe+NERhgy66XMrc5lo6dHW7oaPzMDtYwR1EGwK16/blb6mCSg4jqityOe0o/H7HA==" + }, "node_modules/markdown-it/node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -30125,6 +30131,11 @@ "integrity": "sha512-mwXSQ2nPeVUzCMIE3HlLvjRioopiqyJLNph0pyx38yf9mpqFDhNGnMpAXF9/A2Xv0oiF2cVyg9xwfF0HNAz05g==", "requires": {} }, + "markdown-it-multimd-table": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/markdown-it-multimd-table/-/markdown-it-multimd-table-4.2.3.tgz", + "integrity": "sha512-KepCr2OMJqm7IT6sOIbuqHGe+NERhgy66XMrc5lo6dHW7oaPzMDtYwR1EGwK16/blb6mCSg4jqityOe0o/H7HA==" + }, "marked": { "version": "16.1.1", "resolved": "https://registry.npmjs.org/marked/-/marked-16.1.1.tgz", diff --git a/package.json b/package.json index 4399ff9be10..50fea5c42d2 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,7 @@ "markdown-it-container": "^4.0.0", "markdown-it-front-matter": "^0.2.4", "markdown-it-image-figures": "^2.1.1", + "markdown-it-multimd-table": "^4.2.3", "mermaid": "^11.9.0", "mitt": "^3.0.1", "path-normalize": "^6.0.13", diff --git a/src/markdownit/index.js b/src/markdownit/index.js index fa2bf4a2e2a..55ac2b39194 100644 --- a/src/markdownit/index.js +++ b/src/markdownit/index.js @@ -7,6 +7,7 @@ import markdownitMentions from '@quartzy/markdown-it-mentions' import MarkdownIt from 'markdown-it' import frontMatter from 'markdown-it-front-matter' import implicitFigures from 'markdown-it-image-figures' +import multimdTable from 'markdown-it-multimd-table' import { escapeHtml } from 'markdown-it/lib/common/utils.mjs' import callouts from './callouts.js' import details from './details.ts' @@ -31,6 +32,12 @@ const markdownit = MarkdownIt('commonmark', { html: false, breaks: false }) .use(keepSyntax) .use(markdownitMentions) .use(implicitFigures) + .use(multimdTable, { + multiline: true, + rowspan: false, + headerless: false, + multibody: false, + }) // Render front matter tokens markdownit.renderer.rules.front_matter = (tokens, idx, options) => diff --git a/src/nodes/Table/Table.js b/src/nodes/Table/Table.js index d03feef7aa4..9f7c8f10044 100644 --- a/src/nodes/Table/Table.js +++ b/src/nodes/Table/Table.js @@ -5,7 +5,7 @@ import { mergeAttributes } from '@tiptap/core' import { Table } from '@tiptap/extension-table' -import { Node } from '@tiptap/pm/model' +// import { Node } from '@tiptap/pm/model' import { TextSelection } from '@tiptap/pm/state' import { addRowAfter, @@ -15,6 +15,7 @@ import { selectedRect, selectionCell, } from '@tiptap/pm/tables' +import { tableToMarkdown } from './markdown.ts' import TableCaption from './TableCaption.js' import TableCell from './TableCell.js' import TableHeader from './TableHeader.js' @@ -76,6 +77,7 @@ function findSameCellInNextRow($cell) { * * @param {Node} node - Table node */ +/* function getColumns(node) { const columns = [] @@ -90,11 +92,13 @@ function getColumns(node) { return columns } + */ /** * * @param {Array} columns - Columns of table */ +/* function calculateColumnWidths(columns) { const widths = [] @@ -115,6 +119,7 @@ function calculateColumnWidths(columns) { return widths } + */ export default Table.extend({ content: 'tableCaption? tableHeadRow tableRow*', @@ -243,11 +248,7 @@ export default Table.extend({ }, toMarkdown(state, node) { - const columns = getColumns(node) - state.options.columnWidths = calculateColumnWidths(columns) - state.options.currentHeaderIndex = 0 - state.renderContent(node) - state.closeBlock(node) + tableToMarkdown(state, node) }, addKeyboardShortcuts() { diff --git a/src/nodes/Table/TableCell.js b/src/nodes/Table/TableCell.js index 4e8e6edf2d0..b6c274a18d8 100644 --- a/src/nodes/Table/TableCell.js +++ b/src/nodes/Table/TableCell.js @@ -9,9 +9,10 @@ import { Fragment } from '@tiptap/pm/model' import { Plugin } from '@tiptap/pm/state' export default TableCell.extend({ - content: 'inline*', + content: '(paragraph|list|codeBlock|image)+', toMarkdown(state, node) { + /* state.write(' ') const backup = state.options?.escapeExtraCharacters const columnIndex = state.options.currentColumnIndex @@ -39,6 +40,7 @@ export default TableCell.extend({ state.options.escapeExtraCharacters = backup state.write(' |') state.options.currentColumnIndex++ + */ }, parseHTML() { diff --git a/src/nodes/Table/TableHeadRow.js b/src/nodes/Table/TableHeadRow.js index c7a7e811464..e76ff985d92 100644 --- a/src/nodes/Table/TableHeadRow.js +++ b/src/nodes/Table/TableHeadRow.js @@ -11,6 +11,7 @@ export default TableRow.extend({ allowGapCursor: false, toMarkdown(state, node) { + /* state.write('|') state.renderInline(node) state.ensureNewLine() @@ -24,6 +25,7 @@ export default TableRow.extend({ state.write('|') }) state.ensureNewLine() + */ }, parseHTML() { diff --git a/src/nodes/Table/TableHeader.js b/src/nodes/Table/TableHeader.js index 409ed032aab..dbb8c9f23bd 100644 --- a/src/nodes/Table/TableHeader.js +++ b/src/nodes/Table/TableHeader.js @@ -10,6 +10,7 @@ export default TableHeader.extend({ content: 'inline*', toMarkdown(state, node) { + /* const headerIndex = state.options.currentHeaderIndex const columnWidth = state.options.columnWidths[headerIndex] const align = node.attrs?.textAlign || 'left' @@ -25,6 +26,7 @@ export default TableHeader.extend({ if (align === 'left') state.write(' '.repeat(space)) state.write(' |') state.options.currentHeaderIndex++ + */ }, parseHTML() { diff --git a/src/nodes/Table/TableRow.js b/src/nodes/Table/TableRow.js index d7b8bbd725a..9642910aceb 100644 --- a/src/nodes/Table/TableRow.js +++ b/src/nodes/Table/TableRow.js @@ -10,10 +10,12 @@ export default TableRow.extend({ allowGapCursor: false, toMarkdown(state, node) { + /* state.write('|') state.options.currentColumnIndex = 0 state.renderInline(node) state.ensureNewLine() + */ }, parseHTML() { diff --git a/src/nodes/Table/markdown.ts b/src/nodes/Table/markdown.ts new file mode 100644 index 00000000000..cdd343885f9 --- /dev/null +++ b/src/nodes/Table/markdown.ts @@ -0,0 +1,156 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { MarkdownSerializerState } from '@tiptap/pm/markdown' +import { Node } from '@tiptap/pm/model' +import { createMarkdownSerializer } from '../../extensions/Markdown.js' + +type Cell = { + md: string + lines: string[] + align: string +} + +type Row = { + node: Node + header: boolean + cells: Cell[] + length: number +} + +/** + * Serialize a table row to markdown line by line + * + * @param state - Markdown serializer state + * @param row - Table row to serialize + * @param columnWidths - Array of column widths + */ +function rowToMarkdown( + state: MarkdownSerializerState, + row: Row, + columnWidths: number[], +) { + const normalizedCells = row.cells.map((cell, cellIdx) => { + // Normalize cells to have the same number of lines + while (cell.lines.length < row.length) cell.lines.push('') + // Normalize lines in cell to have the same length + cell.lines.forEach((line, lineIdx) => { + cell.lines[lineIdx] = line.padEnd(columnWidths[cellIdx]) + }) + return cell + }) + + // Write row line by line + for (let lineIdx = 0; lineIdx < row.length; lineIdx++) { + state.write('| ') + normalizedCells.forEach((cell, cellIdx) => { + state.write(cell.lines[lineIdx]) + if (cellIdx < normalizedCells.length - 1) { + state.write(' | ') + } else { + state.write(' |') + } + }) + + // If this is a multline row and not the last line, add '\' + if (lineIdx < row.length - 1) { + state.write(' \\') + } + state.write('\n') + } +} + +/** + * Serialize a table header row to markdown + * + * @param state - Markdown serializer state + * @param row - Table header row to serialize + * @param columnWidths - Array of column widths + */ +function headerRowToMarkdown( + state: MarkdownSerializerState, + row: Row, + columnWidths: number[], +) { + rowToMarkdown(state, row, columnWidths) + + // Write horizontal separator line + // No space padding next to pipes in horizontal separator + state.write('|') + row.cells.forEach((cell, cellIdx) => { + const separatorWidth = columnWidths[cellIdx] + 2 + let separator = '' + switch (cell.align) { + case 'center': + separator = ':' + state.repeat('-', separatorWidth - 2) + ':' + break + case 'right': + separator = state.repeat('-', separatorWidth - 1) + ':' + break + default: + separator = state.repeat('-', separatorWidth) + break + } + state.write(separator) + state.write('|') + }) + + state.write('\n') +} + +/** + * Serialize a table to markdown + * + * @param state - Markdown serializer state + * @param node - Table node to serialize + */ +function tableToMarkdown(state: MarkdownSerializerState, node: Node) { + const serializer = createMarkdownSerializer(node.type.schema) + const rows = node.content.content.map((row): Row => { + return { + node: row, + header: row.type.name === 'tableHeadRow', + cells: [], + length: 0, + } + }) + + // Get markdown for all cells and max line width + length + const columnWidths: number[] = [] + rows.forEach((row) => { + const cellNodes = row.node.content.content + cellNodes.forEach((node, cellIdx) => { + columnWidths[cellIdx] = columnWidths[cellIdx] ?? 0 + const md = serializer.serialize(node) + const lines = md.split(/\r?\n/).map((line) => { + // Escape pipe character + line = line.replace(/\|/, '\\$&') + return line.trim() + }) + row.length = Math.max(row.length, lines.length) + const lineLength = Math.max(...lines.map((line) => line.length)) + columnWidths[cellIdx] = Math.max(columnWidths[cellIdx], lineLength) + const align = node.attrs?.textAlign ?? '' + row.cells.push({ md, lines, align }) + }) + }) + + // Render header row + const headerRow = rows.find((r) => r.header) + if (!headerRow) { + // Cannot serialize table without header + return + } + headerRowToMarkdown(state, headerRow, columnWidths) + + // Render body rows + rows.filter((r) => !r.header).forEach((row) => { + rowToMarkdown(state, row, columnWidths) + }) + + state.closeBlock(node) +} + +export { tableToMarkdown } diff --git a/src/tests/fixtures/tables/handbook/handbook.out.html b/src/tests/fixtures/tables/handbook/handbook.out.html index 494c3a00e86..ff037ab2226 100644 --- a/src/tests/fixtures/tables/handbook/handbook.out.html +++ b/src/tests/fixtures/tables/handbook/handbook.out.html @@ -8,25 +8,25 @@ Heading 4 -Letter -a -b -c -ب +

Letter

+

a

+

b

+

c

+

ب

-Number -1 -٢ -3 -4 +

Number

+

1

+

٢

+

3

+

4

-Square -1 -4 -9 -16 +

Square

+

1

+

4

+

9

+

16

diff --git a/src/tests/markdown.spec.js b/src/tests/markdown.spec.js index db5b3ccdd9e..f1a8cf9a80a 100644 --- a/src/tests/markdown.spec.js +++ b/src/tests/markdown.spec.js @@ -110,6 +110,12 @@ describe('Markdown though editor', () => { ) }) + test('table', () => { + expect(markdownThroughEditor('| a | b |\n|---|---|\n| 1 | 2 |')).toBe( + '| a | b |\n|---|---|\n| 1 | 2 |\n', + ) + }) + test('escaping', () => { const test = '(Asdf [asdf asdf](asdf asdf) asdf asdf asdf asdf asdf asdf asdf asdf asdf)\n' diff --git a/src/tests/markdownit/multimd-tables.spec.js b/src/tests/markdownit/multimd-tables.spec.js new file mode 100644 index 00000000000..4d2a60c5044 --- /dev/null +++ b/src/tests/markdownit/multimd-tables.spec.js @@ -0,0 +1,86 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import markdownit from '../../markdownit/index.js' +import stripIndent from './stripIndent.js' + +describe('multimd-table extension', () => { + it('renders simple table', () => { + const rendered = markdownit.render(` +| header1 | header2 | +| --- | --- | +| cell1 | cell2 | +`) + expect(stripIndent(rendered)).toBe( + stripIndent(` + + + +
header1header2
cell1cell2
`), + ) + }) + + it('renders table with line breaks', () => { + const rendered = markdownit.render(` +| header1 | header2 | +| --- | --- | +| cell1
second line | cell2 | +`) + expect(stripIndent(rendered)).toBe( + stripIndent(` + + + +
header1header2
cell1
second line
cell2
`), + ) + }) + + it('renders mulitline with alignment, paragraph, list, codeblock and image', () => { + const rendered = markdownit.render(` +| #| header1 | header2 | +|--:|-------------------|---------------| +| 1| list: | code: | \\ +| | | | \\ +| | * item1 | \`\`\`js | \\ +| | * item2 | const x = '1' | \\ +| | | \`\`\` | \\ +| | ![alt](/test.png) | | +| 2| cell3 | cell4 | +| 3| | cell5 | +`) + expect(stripIndent(rendered)).toBe( + stripIndent(` + + + + + + + + + + + + + + + + + + + + + +
#header1header2

1

+

list:

+
  • item1
  • item2
+
alt
+
+

code:

+
const x = '1'
+
2cell3cell4
3cell5
`), + ) + }) +}) diff --git a/src/tests/nodes/Table.spec.js b/src/tests/nodes/Table.spec.js index 8a0ee600399..fab2b1d8ee0 100644 --- a/src/tests/nodes/Table.spec.js +++ b/src/tests/nodes/Table.spec.js @@ -21,6 +21,7 @@ import handbookOut from '../fixtures/tables/handbook/handbook.out.html?raw' import { br, expectDocument, + p, table, td, th, @@ -54,13 +55,15 @@ describe('Table', () => { th({ dir: 'ltr' }, 'heading 3'), ), tr( - td({ dir: 'ltr', textAlign: 'center' }, 'center'), - td({ dir: 'ltr', textAlign: 'right' }, 'right'), + td({ dir: 'ltr', textAlign: 'center' }, p({ dir: 'ltr' }, 'center')), + td({ dir: 'ltr', textAlign: 'right' }, p({ dir: 'ltr' }, 'right')), td( { dir: 'ltr' }, - 'left cell ', - br({ syntax: 'html' }), - 'with line break', + p({ dir: 'ltr' }, + 'left cell ', + br({ syntax: 'html' }), + 'with line break', + ), ), ), ), @@ -79,13 +82,15 @@ describe('Table', () => { th({ dir: 'ltr' }, 'heading 3'), ), tr( - td({ dir: 'ltr', textAlign: 'center' }, 'center'), - td({ dir: 'ltr', textAlign: 'right' }, 'right'), + td({ dir: 'ltr', textAlign: 'center' }, p({ dir: 'ltr' }, 'center')), + td({ dir: 'ltr', textAlign: 'right' }, p({ dir: 'ltr' }, 'right')), td( { dir: 'ltr' }, - 'left cell ', - br({ syntax: ' ' }), - 'with line break', + p({ dir: 'ltr' }, + 'left cell ', + br({ syntax: ' ' }), + 'with line break', + ), ), ), ), From 14376cf6e46e0a18f349683c376e740d1d0c4853 Mon Sep 17 00:00:00 2001 From: Jonas Date: Fri, 1 Aug 2025 13:01:33 +0200 Subject: [PATCH 02/11] test(cypress): Import required extensions for table test Signed-off-by: Jonas --- cypress/e2e/nodes/Table.spec.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/cypress/e2e/nodes/Table.spec.js b/cypress/e2e/nodes/Table.spec.js index 8a1ee6cbeb7..ff20f8e5b2e 100644 --- a/cypress/e2e/nodes/Table.spec.js +++ b/cypress/e2e/nodes/Table.spec.js @@ -7,11 +7,15 @@ import { initUserAndFiles, randUser } from '../../utils/index.js' import { findChildren } from './../../../src/helpers/prosemirrorUtils.js' import { createCustomEditor } from './../../support/components.js' +import { CodeBlock } from '@tiptap/extension-code-block' +import { ListItem } from '@tiptap/extension-list-item' import Markdown, { createMarkdownSerializer, } from './../../../src/extensions/Markdown.js' import markdownit from './../../../src/markdownit/index.js' +import BulletList from './../../../src/nodes/BulletList.js' import EditableTable from './../../../src/nodes/EditableTable.js' +import Image from './../../../src/nodes/Image.js' // https://github.com/import-js/eslint-plugin-import/issues/1739 /* eslint-disable-next-line import/no-unresolved */ @@ -169,7 +173,14 @@ describe('table plugin', () => { describe('Table extension integrated in the editor', () => { const editor = createCustomEditor({ content: '', - extensions: [Markdown, EditableTable], + extensions: [ + BulletList, + CodeBlock, + EditableTable, + Image, + ListItem, + Markdown, + ], }) for (const spec of testData.split(/#+\s+/)) { From 41ce25261b412df42a31a21bb1c06007c1b1f5b8 Mon Sep 17 00:00:00 2001 From: Jonas Date: Fri, 1 Aug 2025 10:49:24 +0200 Subject: [PATCH 03/11] test: Add markdownThroughEditor test for complex table Signed-off-by: Jonas --- src/tests/nodes/Table.spec.js | 49 ++++++++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/src/tests/nodes/Table.spec.js b/src/tests/nodes/Table.spec.js index fab2b1d8ee0..992b470042a 100644 --- a/src/tests/nodes/Table.spec.js +++ b/src/tests/nodes/Table.spec.js @@ -6,6 +6,7 @@ import { test as baseTest } from 'vitest' import { createRichEditor } from '../../EditorFactory.js' import { createMarkdownSerializer } from '../../extensions/Markdown.js' +import { markdownThroughEditor } from '../testHelpers/markdown.js' import markdownit from '../../markdownit/index.js' @@ -37,12 +38,32 @@ const test = baseTest.extend({ }, }) -describe('Table', () => { +describe('Table extension', () => { test('Markdown-IT renders tables', () => { const rendered = markdownit.render(input) expect(rendered).toBe(output) }) + it('markdown table is preserved through editor', () => { + expect(markdownThroughEditor('a|b\n-|-\n1|2\n')).toBe( + '| a | b |\n|---|---|\n| 1 | 2 |\n', + ) + + const complexTable = ` +| #| header1 | header2 | +|--:|-------------------|---------------| +| 1| list: | code: | \\ +| | | | \\ +| | * item1 | \`\`\`js | \\ +| | * item2 | const x = '1' | \\ +| | | \`\`\` | \\ +| | ![alt](/test.png) | | +| 2| cell3 | cell4 | +| 3| | cell5 | +` + expect(markdownThroughEditor(complexTable)).toBe(complexTable) + }) + test('Load into editor', ({ editor }) => { editor.commands.setContent(markdownit.render(input)) @@ -55,11 +76,18 @@ describe('Table', () => { th({ dir: 'ltr' }, 'heading 3'), ), tr( - td({ dir: 'ltr', textAlign: 'center' }, p({ dir: 'ltr' }, 'center')), - td({ dir: 'ltr', textAlign: 'right' }, p({ dir: 'ltr' }, 'right')), + td( + { dir: 'ltr', textAlign: 'center' }, + p({ dir: 'ltr' }, 'center'), + ), + td( + { dir: 'ltr', textAlign: 'right' }, + p({ dir: 'ltr' }, 'right'), + ), td( { dir: 'ltr' }, - p({ dir: 'ltr' }, + p( + { dir: 'ltr' }, 'left cell ', br({ syntax: 'html' }), 'with line break', @@ -82,11 +110,18 @@ describe('Table', () => { th({ dir: 'ltr' }, 'heading 3'), ), tr( - td({ dir: 'ltr', textAlign: 'center' }, p({ dir: 'ltr' }, 'center')), - td({ dir: 'ltr', textAlign: 'right' }, p({ dir: 'ltr' }, 'right')), + td( + { dir: 'ltr', textAlign: 'center' }, + p({ dir: 'ltr' }, 'center'), + ), + td( + { dir: 'ltr', textAlign: 'right' }, + p({ dir: 'ltr' }, 'right'), + ), td( { dir: 'ltr' }, - p({ dir: 'ltr' }, + p( + { dir: 'ltr' }, 'left cell ', br({ syntax: ' ' }), 'with line break', From fb684174aa6ac3b486ba4aefc4d0e5cd0b5f0b93 Mon Sep 17 00:00:00 2001 From: Jonas Date: Fri, 1 Aug 2025 10:56:56 +0200 Subject: [PATCH 04/11] feat(tables): Support headerless tables Signed-off-by: Jonas --- src/markdownit/index.js | 2 +- src/tests/markdownit/multimd-tables.spec.js | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/markdownit/index.js b/src/markdownit/index.js index 55ac2b39194..0436a0c0eae 100644 --- a/src/markdownit/index.js +++ b/src/markdownit/index.js @@ -35,7 +35,7 @@ const markdownit = MarkdownIt('commonmark', { html: false, breaks: false }) .use(multimdTable, { multiline: true, rowspan: false, - headerless: false, + headerless: true, multibody: false, }) diff --git a/src/tests/markdownit/multimd-tables.spec.js b/src/tests/markdownit/multimd-tables.spec.js index 4d2a60c5044..ef813c00ca2 100644 --- a/src/tests/markdownit/multimd-tables.spec.js +++ b/src/tests/markdownit/multimd-tables.spec.js @@ -22,6 +22,21 @@ describe('multimd-table extension', () => { ) }) + it('renders table without header', () => { + const rendered = markdownit.render(` +|-------|-------| +| cell1 | cell2 | +| cell3 | cell4 | +`) + expect(stripIndent(rendered)).toBe( + stripIndent(` + + + +
cell1cell2
cell3cell4
`), + ) + }) + it('renders table with line breaks', () => { const rendered = markdownit.render(` | header1 | header2 | From 5f8ce5b9e68b75712039bc553a0489c1cace8312 Mon Sep 17 00:00:00 2001 From: Jonas Date: Fri, 1 Aug 2025 15:01:32 +0200 Subject: [PATCH 05/11] fix(table): Fix cell alignment in serialized markdown Table cells with lists or code blocks are hardcoded to left alignment though, as everything else would break markdown parsing later. Signed-off-by: Jonas --- src/nodes/Table/markdown.ts | 33 +++++++++++++++++++++++++++++++-- src/tests/nodes/Table.spec.js | 12 ++++++------ 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/nodes/Table/markdown.ts b/src/nodes/Table/markdown.ts index cdd343885f9..03630c4a010 100644 --- a/src/nodes/Table/markdown.ts +++ b/src/nodes/Table/markdown.ts @@ -10,6 +10,7 @@ import { createMarkdownSerializer } from '../../extensions/Markdown.js' type Cell = { md: string lines: string[] + nodeTypes: Set align: string } @@ -20,6 +21,10 @@ type Row = { length: number } +// Allowed node types for center/right alignment. Cells with any other node type have +// enforced left alignment to not break markdown parsing. +const alignNodeTypes = new Set(['text', 'paragraph', 'image']) + /** * Serialize a table row to markdown line by line * @@ -37,7 +42,23 @@ function rowToMarkdown( while (cell.lines.length < row.length) cell.lines.push('') // Normalize lines in cell to have the same length cell.lines.forEach((line, lineIdx) => { - cell.lines[lineIdx] = line.padEnd(columnWidths[cellIdx]) + if ([...cell.nodeTypes].some((type) => !alignNodeTypes.has(type))) { + // Enforced left alignment. + cell.lines[lineIdx] = line.padEnd(columnWidths[cellIdx]) + return + } + + // Pad according to alignment + if (cell.align === 'center') { + const spaces = Math.max(columnWidths[cellIdx] - line.length, 0) + const spacesStart = line.length + Math.floor(spaces / 2) + const spacesEnd = line.length + Math.ceil(spaces / 2) + cell.lines[lineIdx] = line.padStart(spacesStart).padEnd(spacesEnd) + } else if (cell.align === 'right') { + cell.lines[lineIdx] = line.padStart(columnWidths[cellIdx]) + } else { + cell.lines[lineIdx] = line.padEnd(columnWidths[cellIdx]) + } }) return cell }) @@ -80,6 +101,7 @@ function headerRowToMarkdown( // No space padding next to pipes in horizontal separator state.write('|') row.cells.forEach((cell, cellIdx) => { + // Separator alignment const separatorWidth = columnWidths[cellIdx] + 2 let separator = '' switch (cell.align) { @@ -123,17 +145,24 @@ function tableToMarkdown(state: MarkdownSerializerState, node: Node) { const cellNodes = row.node.content.content cellNodes.forEach((node, cellIdx) => { columnWidths[cellIdx] = columnWidths[cellIdx] ?? 0 + + // Serialize cell content with all child nodes and split lines const md = serializer.serialize(node) + const nodeTypes = new Set() + node.descendants((descendant) => { + nodeTypes.add(descendant.type.name) + }) const lines = md.split(/\r?\n/).map((line) => { // Escape pipe character line = line.replace(/\|/, '\\$&') return line.trim() }) + row.length = Math.max(row.length, lines.length) const lineLength = Math.max(...lines.map((line) => line.length)) columnWidths[cellIdx] = Math.max(columnWidths[cellIdx], lineLength) const align = node.attrs?.textAlign ?? '' - row.cells.push({ md, lines, align }) + row.cells.push({ md, lines, nodeTypes, align }) }) }) diff --git a/src/tests/nodes/Table.spec.js b/src/tests/nodes/Table.spec.js index 992b470042a..de83abbb046 100644 --- a/src/tests/nodes/Table.spec.js +++ b/src/tests/nodes/Table.spec.js @@ -50,17 +50,17 @@ describe('Table extension', () => { ) const complexTable = ` -| #| header1 | header2 | -|--:|-------------------|---------------| -| 1| list: | code: | \\ +| # | header1 | header2 | +|--:|------------------:|---------------| +| 1 | list: | code: | \\ | | | | \\ | | * item1 | \`\`\`js | \\ | | * item2 | const x = '1' | \\ | | | \`\`\` | \\ | | ![alt](/test.png) | | -| 2| cell3 | cell4 | -| 3| | cell5 | -` +| 2 | cell3 | cell4 | +| 3 | | cell5 | +`.trimStart() expect(markdownThroughEditor(complexTable)).toBe(complexTable) }) From 323df332aaa9941eb4767243599845ca7c20c8e8 Mon Sep 17 00:00:00 2001 From: Jonas Date: Sun, 3 Aug 2025 19:54:24 +0200 Subject: [PATCH 06/11] chore(table): Remove dead code from table node classes Signed-off-by: Jonas --- src/nodes/Table/Table.js | 48 --------------------------------- src/nodes/Table/TableCell.js | 32 +--------------------- src/nodes/Table/TableHeadRow.js | 18 +------------ src/nodes/Table/TableHeader.js | 20 +------------- src/nodes/Table/TableRow.js | 9 +------ 5 files changed, 4 insertions(+), 123 deletions(-) diff --git a/src/nodes/Table/Table.js b/src/nodes/Table/Table.js index 9f7c8f10044..67c824f3fbc 100644 --- a/src/nodes/Table/Table.js +++ b/src/nodes/Table/Table.js @@ -73,54 +73,6 @@ function findSameCellInNextRow($cell) { } } -/** - * - * @param {Node} node - Table node - */ -/* -function getColumns(node) { - const columns = [] - - node.content.forEach((row) => { - row.content.forEach((cell, offset, columnIndex) => { - if (!columns[columnIndex]) { - columns[columnIndex] = [] - } - columns[columnIndex].push(cell) - }) - }) - - return columns -} - */ - -/** - * - * @param {Array} columns - Columns of table - */ -/* -function calculateColumnWidths(columns) { - const widths = [] - - columns.forEach((column) => { - let maxWidth = 0 - - column.forEach((cell) => { - let cellWidth = 0 - cell.content.forEach((node) => { - cellWidth += node.text?.length || 6 - if (node.text?.includes('|')) cellWidth += 1 - }) - maxWidth = Math.max(maxWidth, cellWidth) - }) - - widths.push(maxWidth) - }) - - return widths -} - */ - export default Table.extend({ content: 'tableCaption? tableHeadRow tableRow*', diff --git a/src/nodes/Table/TableCell.js b/src/nodes/Table/TableCell.js index b6c274a18d8..58093eb378d 100644 --- a/src/nodes/Table/TableCell.js +++ b/src/nodes/Table/TableCell.js @@ -11,37 +11,7 @@ import { Plugin } from '@tiptap/pm/state' export default TableCell.extend({ content: '(paragraph|list|codeBlock|image)+', - toMarkdown(state, node) { - /* - state.write(' ') - const backup = state.options?.escapeExtraCharacters - const columnIndex = state.options.currentColumnIndex - state.options.escapeExtraCharacters = /\|/ - - let cellRenderedContentLength = 0 - node.content.forEach((childNode, offset, index) => { - cellRenderedContentLength += childNode.text?.length || 6 - if (childNode.text?.includes('|')) cellRenderedContentLength += 1 - if (childNode.attrs.syntax === ' ') - node.child(index).attrs.syntax = 'html' - }) - const columnWidth = state.options.columnWidths[columnIndex] - const align = node.attrs?.textAlign || 'left' - const space = columnWidth - cellRenderedContentLength - const leftPadding = Math.floor(space / 2) - const rightPadding = Math.ceil(space / 2) - - if (align === 'center') state.write(' '.repeat(leftPadding)) - if (align === 'right') state.write(' '.repeat(space)) - state.renderInline(node) - if (align === 'center') state.write(' '.repeat(rightPadding)) - if (align === 'left') state.write(' '.repeat(space)) - - state.options.escapeExtraCharacters = backup - state.write(' |') - state.options.currentColumnIndex++ - */ - }, + toMarkdown() {}, parseHTML() { return [ diff --git a/src/nodes/Table/TableHeadRow.js b/src/nodes/Table/TableHeadRow.js index e76ff985d92..87015f8143a 100644 --- a/src/nodes/Table/TableHeadRow.js +++ b/src/nodes/Table/TableHeadRow.js @@ -10,23 +10,7 @@ export default TableRow.extend({ content: 'tableHeader+', allowGapCursor: false, - toMarkdown(state, node) { - /* - state.write('|') - state.renderInline(node) - state.ensureNewLine() - state.write('|') - node.forEach((cell, offset, index) => { - let row = state.repeat('-', state.options.columnWidths[index] + 2) - const align = cell.attrs?.textAlign - if (align === 'center' || align === 'left') row = ':' + row.slice(1) - if (align === 'center' || align === 'right') row = row.slice(0, -1) + ':' - state.write(row) - state.write('|') - }) - state.ensureNewLine() - */ - }, + toMarkdown() {}, parseHTML() { return [{ tag: 'tr:first-of-type', priority: 80 }] diff --git a/src/nodes/Table/TableHeader.js b/src/nodes/Table/TableHeader.js index dbb8c9f23bd..33695df584b 100644 --- a/src/nodes/Table/TableHeader.js +++ b/src/nodes/Table/TableHeader.js @@ -9,25 +9,7 @@ import { TableHeader } from '@tiptap/extension-table-header' export default TableHeader.extend({ content: 'inline*', - toMarkdown(state, node) { - /* - const headerIndex = state.options.currentHeaderIndex - const columnWidth = state.options.columnWidths[headerIndex] - const align = node.attrs?.textAlign || 'left' - const space = columnWidth - node.content.size - const leftPadding = Math.floor(space / 2) - const rightPadding = Math.ceil(space / 2) - - state.write(' ') - if (align === 'center') state.write(' '.repeat(leftPadding)) - if (align === 'right') state.write(' '.repeat(space)) - state.renderInline(node) - if (align === 'center') state.write(' '.repeat(rightPadding)) - if (align === 'left') state.write(' '.repeat(space)) - state.write(' |') - state.options.currentHeaderIndex++ - */ - }, + toMarkdown() {}, parseHTML() { return [ diff --git a/src/nodes/Table/TableRow.js b/src/nodes/Table/TableRow.js index 9642910aceb..68d93ba4e8d 100644 --- a/src/nodes/Table/TableRow.js +++ b/src/nodes/Table/TableRow.js @@ -9,14 +9,7 @@ export default TableRow.extend({ content: 'tableCell*', allowGapCursor: false, - toMarkdown(state, node) { - /* - state.write('|') - state.options.currentColumnIndex = 0 - state.renderInline(node) - state.ensureNewLine() - */ - }, + toMarkdown() {}, parseHTML() { return [{ tag: 'tr', priority: 70 }] From 2dacfd64d28a927fbb2da45998f56c63d6634b7f Mon Sep 17 00:00:00 2001 From: Jonas Date: Sun, 3 Aug 2025 20:16:24 +0200 Subject: [PATCH 07/11] test(cy): Adjust mulitline cell test Signed-off-by: Jonas --- cypress/e2e/nodes/Table.spec.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/cypress/e2e/nodes/Table.spec.js b/cypress/e2e/nodes/Table.spec.js index ff20f8e5b2e..0ca5f7c61aa 100644 --- a/cypress/e2e/nodes/Table.spec.js +++ b/cypress/e2e/nodes/Table.spec.js @@ -125,20 +125,18 @@ describe('table plugin', () => { }) it('Creates table and add multilines', function () { - const multilinesContent = 'Line 1\nLine 2\nLine 3' - cy.getActionEntry('table').click() cy.getContent() .find('table:nth-of-type(1) tr:nth-child(2) td:nth-child(1)') .click() - cy.getContent().type(multilinesContent) + cy.getContent().type('Line 1\nLine 2\nLine 3') cy.getContent() .find('table:nth-of-type(1) tr:nth-child(2) td:nth-child(1) .content') .then(($el) => { expect($el.get(0).innerHTML).to.equal( - multilinesContent.replace(/\n/g, '
'), + '

Line 1

Line 2

Line 3

', ) }) }) From 41e20df302c72a5cd3a0af8865a33f79e3761af1 Mon Sep 17 00:00:00 2001 From: Jonas Date: Thu, 14 Aug 2025 15:05:19 +0200 Subject: [PATCH 08/11] chore(imageInline): Rename node Dashes in nodes names are not nice and this PR changes the editor schema anyway. Signed-off-by: Jonas --- src/nodes/ImageInline.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nodes/ImageInline.js b/src/nodes/ImageInline.js index cf91f498bcd..95e41871aa8 100644 --- a/src/nodes/ImageInline.js +++ b/src/nodes/ImageInline.js @@ -11,7 +11,7 @@ import ImageView from './ImageView.vue' // Inline image extension. Needed if markdown contains inline images. // Not supported to be created from our UI (we default to block images). const ImageInline = TiptapImage.extend({ - name: 'image-inline', + name: 'imageInline', // Lower priority than (block) Image extension priority: 99, From 863481a165bd2ef1ea137291f52b2762baa0aca5 Mon Sep 17 00:00:00 2001 From: Jonas Date: Thu, 14 Aug 2025 15:07:45 +0200 Subject: [PATCH 09/11] fix(table): Don't trim cell lines Unnecessary and breaks serializing e.g. nested lists. Signed-off-by: Jonas --- src/nodes/Table/markdown.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/nodes/Table/markdown.ts b/src/nodes/Table/markdown.ts index 03630c4a010..baeb6a43e6d 100644 --- a/src/nodes/Table/markdown.ts +++ b/src/nodes/Table/markdown.ts @@ -152,11 +152,10 @@ function tableToMarkdown(state: MarkdownSerializerState, node: Node) { node.descendants((descendant) => { nodeTypes.add(descendant.type.name) }) - const lines = md.split(/\r?\n/).map((line) => { + const lines = md + .split(/\r?\n/) // Escape pipe character - line = line.replace(/\|/, '\\$&') - return line.trim() - }) + .map((line) => line.replace(/\|/, '\\$&')) row.length = Math.max(row.length, lines.length) const lineLength = Math.max(...lines.map((line) => line.length)) From 2bce8230b69437b260cb2ec5374921d2d1280d81 Mon Sep 17 00:00:00 2001 From: Jonas Date: Thu, 14 Aug 2025 16:04:24 +0200 Subject: [PATCH 10/11] chore(tableCaption): remove obsolete parameters from `toMarkdown` Signed-off-by: Jonas --- src/nodes/Table/TableCaption.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nodes/Table/TableCaption.js b/src/nodes/Table/TableCaption.js index cff306fcfe2..62b7259d3cc 100644 --- a/src/nodes/Table/TableCaption.js +++ b/src/nodes/Table/TableCaption.js @@ -23,7 +23,7 @@ export default Node.create({ return ['caption'] }, - toMarkdown(state, node) {}, + toMarkdown() {}, parseHTML() { return [{ tag: 'table caption', priority: 90 }] From 8e407581555e5299f49fe3bc31bbb411d414480a Mon Sep 17 00:00:00 2001 From: Jonas Date: Thu, 14 Aug 2025 15:55:09 +0200 Subject: [PATCH 11/11] fix(table): Allow all block elements as table children Also add tests for tables with complex nested structures in their cells. Signed-off-by: Jonas --- src/nodes/Table/TableCell.js | 5 ++++- src/tests/nodes/Table.spec.js | 38 +++++++++++++++++++++++++++++++---- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/nodes/Table/TableCell.js b/src/nodes/Table/TableCell.js index 58093eb378d..237bfa228a3 100644 --- a/src/nodes/Table/TableCell.js +++ b/src/nodes/Table/TableCell.js @@ -9,7 +9,10 @@ import { Fragment } from '@tiptap/pm/model' import { Plugin } from '@tiptap/pm/state' export default TableCell.extend({ - content: '(paragraph|list|codeBlock|image)+', + // content: 'block+', + // All block elements except blockquote as that one causes issues for now. + // Blockquote as nested child (e.g. inside a list) is no problem. + content: '(paragraph|list|codeBlock|image|callout|details|horizontalRule)+', toMarkdown() {}, diff --git a/src/tests/nodes/Table.spec.js b/src/tests/nodes/Table.spec.js index de83abbb046..0dc0b8dd479 100644 --- a/src/tests/nodes/Table.spec.js +++ b/src/tests/nodes/Table.spec.js @@ -44,24 +44,54 @@ describe('Table extension', () => { expect(rendered).toBe(output) }) - it('markdown table is preserved through editor', () => { + it('simple md table is preserved through editor', () => { expect(markdownThroughEditor('a|b\n-|-\n1|2\n')).toBe( '| a | b |\n|---|---|\n| 1 | 2 |\n', ) + }) - const complexTable = ` + it('complex md table with alignment, nested list, image and code block is preserved through editor', () => { + const table = ` | # | header1 | header2 | |--:|------------------:|---------------| | 1 | list: | code: | \\ | | | | \\ | | * item1 | \`\`\`js | \\ -| | * item2 | const x = '1' | \\ +| | * item2 | const x = '1' | \\ | | | \`\`\` | \\ | | ![alt](/test.png) | | | 2 | cell3 | cell4 | | 3 | | cell5 | `.trimStart() - expect(markdownThroughEditor(complexTable)).toBe(complexTable) + + expect(markdownThroughEditor(table)).toBe(table) + }) + + it('complex md table with callout, hr, blockquote and details in nested list is preserved through editor', () => { + const table = ` +| nested list1 | nested list 2 | +|-----------------------|----------------------------------| +| 1. first with callout | 1. first with blockquote | \\ +| | | \\ +| ::: info | > - quoted list item1 | \\ +| info | > - quoted list item2 | \\ +| | 1. with image | \\ +| ::: | | \\ +| 1. item2 | ![a](i.png) | \\ +| 2. second | 2. summary | \\ +| | | \\ +| --- |
| \\ +| | summary | \\ +| ::: warn | > * quoted list item | \\ +| warn | | \\ +| |
| \\ +| ::: | | +| * [ ] task list item1 | | \\ +| * [x] task list item2 | | +| > quote | | +`.trimStart() + + expect(markdownThroughEditor(table)).toBe(table) }) test('Load into editor', ({ editor }) => {