Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/collection-groups.md
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions packages/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] These are runtime dependencies used by PortableTextEditor.tsx, but they are placed in devDependencies. Every other TipTap extension in this package lives in dependencies. Move them to the correct section.

Additionally, @tiptap/extension-table@3.22.1 declares peer dependencies on @tiptap/core@^3.22.1 and @tiptap/pm@^3.22.1, but the workspace pins all other TipTap packages to 3.20.0. That peer mismatch can cause runtime incompatibilities. Either upgrade all TipTap packages together, or pin the table extensions to the same version the rest of the monorepo uses.

"@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:",
Expand Down
256 changes: 256 additions & 0 deletions packages/admin/src/components/PortableTextEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,22 @@ import {
CodeBlock,
Stack,
Eye,
Table as TableIcon,
Plus,
Trash,
Rows,
Columns,
type Icon,
} from "@phosphor-icons/react";
import { X } from "@phosphor-icons/react";
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";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] This PR is titled "add group and sortOrder to collections" but bundles an entire TipTap table editor feature. That violates AGENTS.md's scope rule:

"Do not touch code outside the scope of your change. No drive-by refactors, no 'while I'm here' improvements."

The table feature also lacks a linked approved Discussion. Please remove all table-related changes from this branch and open a separate PR if you want to pursue tables.

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";
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] This mark mapping is naive and breaks links (and any other mark-def-based marks) inside table cells.

PortableText stores links as markDefs referenced by _key. If a span has marks: ["someLinkKey"], this code passes "someLinkKey" directly as a ProseMirror mark type, which does not exist. The link is silently stripped on round-trip.

The correct fix is to reuse the existing convertInlineContent helper (which already handles markDefs properly) instead of hand-rolling a second inline converter for table cells:

Suggested change
const cells = row.cells.map((cell) => {
// Map PortableText marks to ProseMirror marks
const { children, markDefs } = convertInlineContent(cell.content);
return {
type: cellType,
content: [
{
type: "paragraph",
content: children.length > 0 ? children : undefined,
},
],
};

(You would also need to thread markDefs upward to convertPTBlock the same way the existing block handlers do.)

const cellType =
cell.isHeader || (tableBlock.hasHeaderRow && rowIndex === 0)
? "tableHeader"
: "tableCell";

// Map PortableText marks to ProseMirror marks
const markNameMap: Record<string, string> = {
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,
Expand Down Expand Up @@ -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();
},
},
];

/**
Expand Down Expand Up @@ -1499,6 +1650,12 @@ export function PortableTextEditor({
ImageExtension,
MarkdownLinkExtension,
PluginBlockExtension,
Table.configure({
resizable: true,
}),
TableRow,
TableHeader,
TableCell,
Placeholder.configure({
includeChildren: true,
placeholder: ({ node }) => {
Expand Down Expand Up @@ -1736,6 +1893,7 @@ export function PortableTextEditor({
<EditorToolbar editor={editor} focusMode={focusMode} onFocusModeChange={setFocusMode} />
)}
<EditorBubbleMenu editor={editor} />
<TableBubbleMenu editor={editor} />
<div className="relative overflow-visible">
<EditorContent editor={editor} />
{editable && <DragHandleWrapper editor={editor} />}
Expand Down Expand Up @@ -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 (
<BubbleMenu
editor={editor}
options={{
placement: "top",
offset: 8,
}}
shouldShow={({ editor: e }) => 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 */}
<BubbleButton
onClick={() => editor.chain().focus().addColumnBefore().run()}
title="Add column before"
>
<Columns className="h-4 w-4" />
<Plus className="h-2 w-2 absolute -left-0.5" />
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The Plus icon uses absolute -left-0.5, but BubbleButton (and the underlying Button component) does not set relative. The icon will be positioned against the nearest positioned ancestor — likely the BubbleMenu flex container — rather than the button itself. Add relative to the BubbleButton wrapper (or to the Button's className) so the overlay lines up correctly.

The same issue affects the other Plus icons at -right-0.5, -top-0.5, and -bottom-0.5.

</BubbleButton>

{/* Add column after */}
<BubbleButton
onClick={() => editor.chain().focus().addColumnAfter().run()}
title="Add column after"
>
<Columns className="h-4 w-4" />
<Plus className="h-2 w-2 absolute -right-0.5" />
</BubbleButton>

{/* Delete column */}
<BubbleButton
onClick={() => editor.chain().focus().deleteColumn().run()}
title="Delete column"
>
<Columns className="h-4 w-4 text-kumo-danger" />
</BubbleButton>

<div className="w-px h-6 bg-kumo-line mx-1" />

{/* Add row before */}
<BubbleButton
onClick={() => editor.chain().focus().addRowBefore().run()}
title="Add row before"
>
<Rows className="h-4 w-4" />
<Plus className="h-2 w-2 absolute -top-0.5" />
</BubbleButton>

{/* Add row after */}
<BubbleButton
onClick={() => editor.chain().focus().addRowAfter().run()}
title="Add row after"
>
<Rows className="h-4 w-4" />
<Plus className="h-2 w-2 absolute -bottom-0.5" />
</BubbleButton>

{/* Delete row */}
<BubbleButton onClick={() => editor.chain().focus().deleteRow().run()} title="Delete row">
<Rows className="h-4 w-4 text-kumo-danger" />
</BubbleButton>

<div className="w-px h-6 bg-kumo-line mx-1" />

{/* Toggle header row */}
<BubbleButton
onClick={() => editor.chain().focus().toggleHeaderRow().run()}
active={editor.isActive("tableHeader")}
title="Toggle header row"
>
<TableIcon className="h-4 w-4" />
</BubbleButton>

{/* Delete table */}
<BubbleButton onClick={() => editor.chain().focus().deleteTable().run()} title="Delete table">
<Trash className="h-4 w-4 text-kumo-danger" />
</BubbleButton>
</BubbleMenu>
);
}

function BubbleButton({
onClick,
active,
Expand Down Expand Up @@ -2198,6 +2445,15 @@ function EditorToolbar({
>
<CodeBlock className="h-4 w-4" aria-hidden="true" />
</ToolbarButton>
<ToolbarButton
onClick={() =>
editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()
}
active={editor.isActive("table")}
title="Insert Table"
>
<TableIcon className="h-4 w-4" aria-hidden="true" />
</ToolbarButton>
</ToolbarGroup>

<ToolbarSeparator />
Expand Down
Loading
Loading