diff --git a/src/lib/components/TiptapEditor.svelte b/src/lib/components/TiptapEditor.svelte index 3f79d14..f6fadc7 100644 --- a/src/lib/components/TiptapEditor.svelte +++ b/src/lib/components/TiptapEditor.svelte @@ -6,7 +6,7 @@ import Underline from '@tiptap/extension-underline'; import TextAlign from '@tiptap/extension-text-align'; import Placeholder from '@tiptap/extension-placeholder'; - import { Table } from '@tiptap/extension-table'; + import { Table } from './tiptap/Table.js'; import { TableRow } from '@tiptap/extension-table-row'; import { TableHeader } from '@tiptap/extension-table-header'; import { TableCell } from '@tiptap/extension-table-cell'; diff --git a/src/lib/components/tiptap/Table.test.ts b/src/lib/components/tiptap/Table.test.ts new file mode 100644 index 0000000..14ce383 --- /dev/null +++ b/src/lib/components/tiptap/Table.test.ts @@ -0,0 +1,93 @@ +// @vitest-environment jsdom +import { describe, it, expect } from 'vitest'; +import { Editor, type JSONContent } from '@tiptap/core'; +import StarterKit from '@tiptap/starter-kit'; +import { TableRow } from '@tiptap/extension-table-row'; +import { TableHeader } from '@tiptap/extension-table-header'; +import { TableCell } from '@tiptap/extension-table-cell'; +import { Markdown } from 'tiptap-markdown'; +import { Table } from './Table.js'; + +// crumbs#83: a bulleted list inside a table cell survives in the live editor +// (never touches markdown) but breaks once the note is saved and reopened, +// because saving/reopening round-trips the doc through markdown text. +describe('Table markdown round-trip with a bullet list in a cell', () => { + function createEditor(content: string | object) { + return new Editor({ + extensions: [StarterKit, Table.configure({ resizable: false }), TableRow, TableHeader, TableCell, Markdown], + content + }); + } + + it('preserves a bullet list inside a table cell after saving and reopening', () => { + const editor = createEditor({ + type: 'doc', + content: [ + { + type: 'table', + content: [ + { + type: 'tableRow', + content: [ + { + type: 'tableHeader', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Header A' }] }] + }, + { + type: 'tableHeader', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Header B' }] }] + } + ] + }, + { + type: 'tableRow', + content: [ + { + type: 'tableCell', + content: [ + { + type: 'bulletList', + content: [ + { type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'First' }] }] }, + { type: 'listItem', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Second' }] }] } + ] + } + ] + }, + { + type: 'tableCell', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Plain cell' }] }] + } + ] + } + ] + } + ] + }); + + // "Save": serialize the crumb to markdown, the way onUpdate() does. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const savedMarkdown = (editor.storage as Record).markdown.getMarkdown(); + + // "Reopen": load that markdown into a fresh editor, the way opening the + // crumb again does (content is passed in as a markdown string). + const reopened = createEditor(savedMarkdown); + const json = reopened.getJSON() as JSONContent; + + const table = json.content?.find((node) => node.type === 'table'); + expect(table).toBeDefined(); + + const rows = table!.content!; + expect(rows).toHaveLength(2); + + const [headerRow, bodyRow] = rows; + expect(headerRow.content!.map((cell) => cell.content?.[0]?.content?.[0]?.text)).toEqual(['Header A', 'Header B']); + + const [listCell, plainCell] = bodyRow.content!; + expect(listCell.content?.[0]?.type).toBe('bulletList'); + const items = listCell.content?.[0]?.content ?? []; + expect(items.map((item) => item.content?.[0]?.content?.[0]?.text)).toEqual(['First', 'Second']); + + expect(plainCell.content?.[0]?.content?.[0]?.text).toBe('Plain cell'); + }); +}); diff --git a/src/lib/components/tiptap/Table.ts b/src/lib/components/tiptap/Table.ts new file mode 100644 index 0000000..b44dc09 --- /dev/null +++ b/src/lib/components/tiptap/Table.ts @@ -0,0 +1,114 @@ +import { elementFromString, getHTMLFromFragment } from '@tiptap/core'; +import { Table as BaseTable } from '@tiptap/extension-table'; +import { Fragment, type Node as ProseMirrorNode } from '@tiptap/pm/model'; + +interface MarkdownSerializerState { + write(text: string): void; + ensureNewLine(): void; + closeBlock(node: ProseMirrorNode): void; + renderInline(node: ProseMirrorNode): void; + inTable: boolean; +} + +function childNodes(node: ProseMirrorNode): ProseMirrorNode[] { + const nodes: ProseMirrorNode[] = []; + node.forEach((child) => nodes.push(child)); + return nodes; +} + +function hasSpan(cell: ProseMirrorNode): boolean { + return cell.attrs.colspan > 1 || cell.attrs.rowspan > 1; +} + +/** + * A cell can only be squeezed onto a single `| cell |` markdown line via + * `state.renderInline()` if its content is itself a textblock (a paragraph, + * whose children are inline text runs). A block child instead - e.g. a + * bullet list - has to be rejected here, or renderInline() misreads its + * block-level children as inline ones and corrupts the row (crumbs#83). + */ +function hasOnlyInlineContent(cell: ProseMirrorNode): boolean { + const content = cell.firstChild; + return content === null || content.type.isTextblock; +} + +function isMarkdownSerializable(node: ProseMirrorNode): boolean { + const rows = childNodes(node); + const firstRow = rows[0]; + const bodyRows = rows.slice(1); + + if ( + childNodes(firstRow).some( + (cell) => cell.type.name !== 'tableHeader' || hasSpan(cell) || cell.childCount > 1 || !hasOnlyInlineContent(cell) + ) + ) { + return false; + } + + return !bodyRows.some((row) => + childNodes(row).some( + (cell) => cell.type.name === 'tableHeader' || hasSpan(cell) || cell.childCount > 1 || !hasOnlyInlineContent(cell) + ) + ); +} + +/** Mirrors tiptap-markdown's internal HTML-block fallback (its HTMLNode spec isn't exported). */ +function formatBlock(html: string): string { + const element = elementFromString(html).firstElementChild!; + element.innerHTML = element.innerHTML.trim() ? `\n${element.innerHTML}\n` : '\n'; + return element.outerHTML; +} + +function serializeAsHTML(state: MarkdownSerializerState, node: ProseMirrorNode, parent: ProseMirrorNode | Fragment) { + const schema = node.type.schema; + const html = getHTMLFromFragment(Fragment.from(node), schema); + const isTopLevel = parent instanceof Fragment || parent.type.name === schema.topNodeType.name; + state.write(isTopLevel ? formatBlock(html) : html); + state.closeBlock(node); +} + +/** + * Overrides @tiptap/extension-table's markdown storage (tiptap-markdown ships + * a default for it, but only guards against multi-child/spanning cells, not + * a single block child - see hasOnlyInlineContent above). + */ +export const Table = BaseTable.extend({ + addStorage() { + return { + markdown: { + serialize(this: unknown, state: MarkdownSerializerState, node: ProseMirrorNode, parent: ProseMirrorNode | Fragment) { + if (!isMarkdownSerializable(node)) { + serializeAsHTML(state, node, parent); + return; + } + + state.inTable = true; + node.forEach((row, _rowOffset, rowIndex) => { + state.write('| '); + row.forEach((cell, _cellOffset, cellIndex) => { + if (cellIndex) { + state.write(' | '); + } + const cellContent = cell.firstChild; + if (cellContent && cellContent.textContent.trim()) { + state.renderInline(cellContent); + } + }); + state.write(' |'); + state.ensureNewLine(); + if (!rowIndex) { + const delimiterRow = Array.from({ length: row.childCount }, () => '---').join(' | '); + state.write(`| ${delimiterRow} |`); + state.ensureNewLine(); + } + }); + state.closeBlock(node); + state.inTable = false; + }, + parse: { + // handled by markdown-it, same as tiptap-markdown's default table spec + } + } + }; + } +});