diff --git a/.changeset/collection-groups.md b/.changeset/collection-groups.md new file mode 100644 index 00000000..5c4a01e1 --- /dev/null +++ b/.changeset/collection-groups.md @@ -0,0 +1,9 @@ +--- +"@emdash/core": minor +--- + +Add collection grouping support for plugin organization + +- Add `group` and `sortOrder` fields to collections +- Plugins can define `group` in seed.json to organize collections in admin sidebar +- Collections sorted by `sortOrder` within groups 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..7e966232 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,72 @@ 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 +906,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 +1650,12 @@ export function PortableTextEditor({ ImageExtension, MarkdownLinkExtension, PluginBlockExtension, + Table.configure({ + resizable: true, + }), + TableRow, + TableHeader, + TableCell, Placeholder.configure({ includeChildren: true, placeholder: ({ node }) => { @@ -1736,6 +1893,7 @@ export function PortableTextEditor({ )} +
{editable && } @@ -1927,6 +2085,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, @@ -2198,6 +2445,15 @@ function EditorToolbar({ >