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;
}
interface SerializeContext {
editor: { storage: { markdown: { options: { html: 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;
}
/**
* Same as tiptap-markdown's HTMLNode fallback: when the editor's `html`
* option is off, other node types degrade to a `[nodename]` placeholder
* instead of emitting HTML. A table with non-inline cell content has to
* follow the same rule, or it'd be the only node type still leaking raw HTML
* with html mode disabled.
*/
function serializeAsHTML(
context: SerializeContext,
state: MarkdownSerializerState,
node: ProseMirrorNode,
parent: ProseMirrorNode | Fragment
) {
if (!context.editor.storage.markdown.options.html) {
state.write(`[${node.type.name}]`);
state.closeBlock(node);
return;
}
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: SerializeContext,
state: MarkdownSerializerState,
node: ProseMirrorNode,
parent: ProseMirrorNode | Fragment
) {
if (!isMarkdownSerializable(node)) {
serializeAsHTML(this, 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
}
}
};
}
});