mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-12 12:06:57 +02:00
fix(editor): preserve bullet lists inside table cells on save/reopen
tiptap-markdown's bundled table serializer only rejects cells with multiple children or spans, not a single non-paragraph block child. A cell holding just a bullet list slipped through and got fed to renderInline(), which is only valid for textblocks, corrupting the row on save. Now such cells fall back to serializing the whole table as raw HTML, same as tiptap-markdown does for other unsupported layouts. Fixes #83
This commit is contained in:
committed by
Fred Cabassut
parent
ff24fb6d5b
commit
64f96b21a3
@@ -6,7 +6,7 @@
|
|||||||
import Underline from '@tiptap/extension-underline';
|
import Underline from '@tiptap/extension-underline';
|
||||||
import TextAlign from '@tiptap/extension-text-align';
|
import TextAlign from '@tiptap/extension-text-align';
|
||||||
import Placeholder from '@tiptap/extension-placeholder';
|
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 { TableRow } from '@tiptap/extension-table-row';
|
||||||
import { TableHeader } from '@tiptap/extension-table-header';
|
import { TableHeader } from '@tiptap/extension-table-header';
|
||||||
import { TableCell } from '@tiptap/extension-table-cell';
|
import { TableCell } from '@tiptap/extension-table-cell';
|
||||||
|
|||||||
@@ -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<string, any>).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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user