From 0eee0f8c2e54aee20c60362b37cfc78423546c12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?To=20Trieu=C2=A0?= Date: Sat, 4 Apr 2026 12:24:08 +0700 Subject: [PATCH 1/5] feat(admin): add table support to PortableText editor - Add TipTap table extensions (@tiptap/extension-table, table-row, table-cell, table-header) - Add table to slash commands menu (/table) - Add table button to EditorToolbar - Add TableBubbleMenu for editing tables (add/remove rows/columns, toggle header, delete) - Update prosemirrorToPortableText converter to handle tables - Update portableTextToProsemirror converter to handle tables - Add CSS styles for table editing including dark mode support --- packages/admin/package.json | 4 + .../src/components/PortableTextEditor.tsx | 256 ++++++++++++++++++ packages/admin/src/styles.css | 55 ++++ pnpm-lock.yaml | 113 +++++++- 4 files changed, 416 insertions(+), 12 deletions(-) diff --git a/packages/admin/package.json b/packages/admin/package.json index 3a1ab4b8..cea2db37 100644 --- a/packages/admin/package.json +++ b/packages/admin/package.json @@ -61,6 +61,10 @@ "@tailwindcss/cli": "^4.1.10", "@tailwindcss/typography": "^0.5.19", "@testing-library/react": "^16.3.0", + "@tiptap/extension-table": "^3.22.1", + "@tiptap/extension-table-cell": "^3.22.1", + "@tiptap/extension-table-header": "^3.22.1", + "@tiptap/extension-table-row": "^3.22.1", "@tiptap/suggestion": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", diff --git a/packages/admin/src/components/PortableTextEditor.tsx b/packages/admin/src/components/PortableTextEditor.tsx index d1e0f55b..d0924027 100644 --- a/packages/admin/src/components/PortableTextEditor.tsx +++ b/packages/admin/src/components/PortableTextEditor.tsx @@ -39,6 +39,11 @@ import { CodeBlock, Stack, Eye, + Table as TableIcon, + Plus, + Trash, + Rows, + Columns, type Icon, } from "@phosphor-icons/react"; import { X } from "@phosphor-icons/react"; @@ -46,6 +51,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"; @@ -280,6 +289,67 @@ function convertPMNode(node: { }; } + case "table": { + const tableKey = generateKey(); + const tableContent = (node.content || []) as Array<{ + type: string; + content?: Array<{ + type: string; + content?: unknown[]; + }>; + }>; + + 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[]; + }>; + + // Extract text from paragraphs inside the cell + const contentSpans: PortableTextSpan[] = []; + for (const para of cellContent) { + if (para.type === "paragraph") { + const { children } = convertInlineContent(para.content || []); + contentSpans.push(...children); + } + } + + // Ensure at least one span + 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, + }; + } + default: return null; } @@ -544,6 +614,68 @@ 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; + }>; + }>; + hasHeaderRow?: boolean; + }; + + const rows = (tableBlock.rows || []).map((row, rowIndex) => { + const cells = row.cells.map((cell) => { + const cellType = cell.isHeader || (tableBlock.hasHeaderRow && rowIndex === 0) + ? "tableHeader" + : "tableCell"; + + // Map PortableText marks to ProseMirror marks + const markNameMap: Record = { + strong: "bold", + em: "italic", + underline: "underline", + "strike-through": "strike", + code: "code", + }; + const pmContent = cell.content.map((span) => ({ + type: "text", + text: span.text || "", + marks: span.marks?.map((mark) => ({ + type: markNameMap[mark] || mark + })).filter((m) => m.type) || [], + })); + + 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, @@ -770,6 +902,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(); + }, + }, ]; /** @@ -1499,6 +1646,12 @@ export function PortableTextEditor({ ImageExtension, MarkdownLinkExtension, PluginBlockExtension, + Table.configure({ + resizable: true, + }), + TableRow, + TableHeader, + TableCell, Placeholder.configure({ includeChildren: true, placeholder: ({ node }) => { @@ -1736,6 +1889,7 @@ export function PortableTextEditor({ )} +
{editable && } @@ -1927,6 +2081,101 @@ 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, @@ -2198,6 +2447,13 @@ function EditorToolbar({ >