From 0be53769eecdc75f94a49608ab3cc18e357a0f7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?To=20Trieu=C2=A0?= Date: Mon, 27 Apr 2026 20:19:00 +0700 Subject: [PATCH] feat(admin): add table support to PortableText editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add TipTap table extensions (Table, TableRow, TableCell, TableHeader) - Add table controls to editor toolbar with slash command (/table) - Add TableBubbleMenu for editing tables (add/remove rows/columns) - Update PT↔PM converters with proper markDefs handling for links - Use Kumo design tokens for table styling (no hard-coded colors) - Add unit tests for table conversion in both directions --- .changeset/table-support.md | 5 + packages/admin/package.json | 4 + .../src/components/PortableTextEditor.tsx | 256 ++++++ packages/admin/src/styles.css | 41 + .../PortableTextEditor.table.test.ts | 398 ++++++++ pnpm-lock.yaml | 853 ++++++++++-------- pnpm-workspace.yaml | 4 + 7 files changed, 1201 insertions(+), 360 deletions(-) create mode 100644 .changeset/table-support.md create mode 100644 packages/admin/tests/components/PortableTextEditor.table.test.ts diff --git a/.changeset/table-support.md b/.changeset/table-support.md new file mode 100644 index 000000000..a9297bf59 --- /dev/null +++ b/.changeset/table-support.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/admin": minor +--- + +Adds table support to the PortableText editor. Users can now insert and edit tables via the slash command menu (/table) or toolbar button. Tables support header rows, column/row insertion and deletion, and include a bubble menu for quick editing. diff --git a/packages/admin/package.json b/packages/admin/package.json index 757b8f47f..ca4668dab 100644 --- a/packages/admin/package.json +++ b/packages/admin/package.json @@ -53,6 +53,10 @@ "@tiptap/extension-placeholder": "catalog:", "@tiptap/extension-text-align": "catalog:", "@tiptap/extension-typography": "catalog:", + "@tiptap/extension-table": "catalog:", + "@tiptap/extension-table-cell": "catalog:", + "@tiptap/extension-table-header": "catalog:", + "@tiptap/extension-table-row": "catalog:", "@tiptap/extension-underline": "catalog:", "@tiptap/pm": "catalog:", "@tiptap/react": "catalog:", diff --git a/packages/admin/src/components/PortableTextEditor.tsx b/packages/admin/src/components/PortableTextEditor.tsx index 6910e5f7f..c147728d6 100644 --- a/packages/admin/src/components/PortableTextEditor.tsx +++ b/packages/admin/src/components/PortableTextEditor.tsx @@ -59,8 +59,11 @@ import { CodeBlock, Stack, Eye, + Table as TableIcon, Plus, Trash, + Rows, + Columns, DotsSixVertical, CaretDown, CaretRight, @@ -71,6 +74,10 @@ import { Extension, type Range } from "@tiptap/core"; import CharacterCount from "@tiptap/extension-character-count"; import Focus from "@tiptap/extension-focus"; import Placeholder from "@tiptap/extension-placeholder"; +import { Table } from "@tiptap/extension-table"; +import { TableCell } from "@tiptap/extension-table-cell"; +import { TableHeader } from "@tiptap/extension-table-header"; +import { TableRow } from "@tiptap/extension-table-row"; import TextAlign from "@tiptap/extension-text-align"; import Typography from "@tiptap/extension-typography"; import { useEditor, EditorContent, useEditorState, type Editor } from "@tiptap/react"; @@ -305,6 +312,68 @@ function convertPMNode(node: { }; } + case "table": { + const tableKey = generateKey(); + const tableContent = (node.content || []) as Array<{ + type: string; + content?: Array<{ + type: string; + content?: unknown[]; + }>; + }>; + + const tableMarkDefs: PortableTextMarkDef[] = []; + const rows = tableContent + .filter((row) => row.type === "tableRow") + .map((row, rowIndex) => { + const cells = (row.content || []).map((cell, cellIndex) => { + const isHeader = cell.type === "tableHeader"; + const cellContent = (cell.content || []) as Array<{ + type: string; + content?: unknown[]; + }>; + + const contentSpans: PortableTextSpan[] = []; + for (const para of cellContent) { + if (para.type === "paragraph") { + const { children, markDefs } = convertInlineContent(para.content || []); + contentSpans.push(...children); + tableMarkDefs.push(...markDefs); + } + } + + if (contentSpans.length === 0) { + contentSpans.push({ + _type: "span", + _key: generateKey(), + text: "", + }); + } + + return { + _type: "tableCell" as const, + _key: `${tableKey}_r${rowIndex}_c${cellIndex}`, + content: contentSpans, + isHeader, + }; + }); + + return { + _type: "tableRow" as const, + _key: `${tableKey}_r${rowIndex}`, + cells, + }; + }); + + return { + _type: "table", + _key: tableKey, + rows, + hasHeaderRow: rows[0]?.cells.some((c) => c.isHeader) ?? false, + markDefs: tableMarkDefs.length > 0 ? tableMarkDefs : undefined, + }; + } + default: return null; } @@ -569,6 +638,66 @@ function convertPTBlock(block: PortableTextBlock): unknown { case "break": return { type: "horizontalRule" }; + case "table": { + const tableBlock = block as { + _type: "table"; + _key: string; + rows?: Array<{ + _type: "tableRow"; + _key: string; + cells: Array<{ + _type: "tableCell"; + _key: string; + content: PortableTextSpan[]; + isHeader?: boolean; + markDefs?: PortableTextMarkDef[]; + }>; + }>; + hasHeaderRow?: boolean; + markDefs?: PortableTextMarkDef[]; + }; + + const tableMarkDefs = tableBlock.markDefs || []; + const tableMarkDefsMap = new Map(tableMarkDefs.map((md) => [md._key, md])); + + const rows = (tableBlock.rows || []).map((row, rowIndex) => { + const cells = row.cells.map((cell) => { + const cellType = + cell.isHeader || (tableBlock.hasHeaderRow && rowIndex === 0) + ? "tableHeader" + : "tableCell"; + + const cellMarkDefs = cell.markDefs || []; + const markDefsMap = new Map([ + ...tableMarkDefsMap, + ...cellMarkDefs.map((md) => [md._key, md] as const), + ]); + + const pmContent = convertPTSpans(cell.content, [...markDefsMap.values()]); + + return { + type: cellType, + content: [ + { + type: "paragraph", + content: pmContent.length > 0 ? pmContent : undefined, + }, + ], + }; + }); + + return { + type: "tableRow", + content: cells, + }; + }); + + return { + type: "table", + content: rows, + }; + } + default: { // Treat unknown block types as plugin blocks (embeds) // These have an id field (or url for backwards compat) for the embed source, @@ -796,6 +925,21 @@ const defaultSlashCommands: SlashCommandItem[] = [ editor.chain().focus().deleteRange(range).setHorizontalRule().run(); }, }, + { + id: "table", + title: "Table", + description: "Insert a table", + icon: TableIcon, + aliases: ["grid", "spreadsheet"], + command: ({ editor, range }) => { + editor + .chain() + .focus() + .deleteRange(range) + .insertTable({ rows: 3, cols: 3, withHeaderRow: true }) + .run(); + }, + }, ]; /** @@ -1860,6 +2004,12 @@ export function PortableTextEditor({ ImageExtension, MarkdownLinkExtension, PluginBlockExtension, + Table.configure({ + resizable: true, + }), + TableRow, + TableHeader, + TableCell, Placeholder.configure({ includeChildren: true, placeholder: ({ node }) => { @@ -1877,6 +2027,13 @@ export function PortableTextEditor({ onStateChange: setSlashMenuState, getState: () => slashMenuStateRef.current, }), + // Table extensions after slash commands so suggestion keyboard handlers have priority + Table.configure({ + resizable: true, + }), + TableRow, + TableHeader, + TableCell, CharacterCount, Focus.configure({ className: "has-focus", @@ -2098,6 +2255,7 @@ export function PortableTextEditor({ )} +
{editable && } @@ -2289,6 +2447,95 @@ function EditorBubbleMenu({ editor }: { editor: Editor }) { ); } +/** + * Table Bubble Menu - appears when cursor is in a table + * Shows table editing options: add/remove rows/columns, delete table + */ +function TableBubbleMenu({ editor }: { editor: Editor }) { + if (!editor.isActive("table")) { + return null; + } + + return ( + e.isActive("table")} + className="z-[100] flex items-center gap-0.5 rounded-lg border bg-kumo-base p-1 shadow-lg" + > + {/* Add column before */} + editor.chain().focus().addColumnBefore().run()} + title="Add column before" + > + + + + + {/* Add column after */} + editor.chain().focus().addColumnAfter().run()} + title="Add column after" + > + + + + + {/* Delete column */} + editor.chain().focus().deleteColumn().run()} + title="Delete column" + > + + + +
+ + {/* Add row before */} + editor.chain().focus().addRowBefore().run()} + title="Add row before" + > + + + + + {/* Add row after */} + editor.chain().focus().addRowAfter().run()} + title="Add row after" + > + + + + + {/* Delete row */} + editor.chain().focus().deleteRow().run()} title="Delete row"> + + + +
+ + {/* Toggle header row */} + editor.chain().focus().toggleHeaderRow().run()} + active={editor.isActive("tableHeader")} + title="Toggle header row" + > + + + + {/* Delete table */} + editor.chain().focus().deleteTable().run()} title="Delete table"> + + + + ); +} + function BubbleButton({ onClick, active, @@ -2560,6 +2807,15 @@ function EditorToolbar({ >