Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/table-support.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 5 additions & 1 deletion packages/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@
"@tiptap/extension-link": "catalog:",
"@tiptap/extension-node-range": "catalog:",
"@tiptap/extension-placeholder": "catalog:",
"@tiptap/extension-table": "catalog:",
"@tiptap/extension-table-cell": "catalog:",
"@tiptap/extension-table-header": "catalog:",
"@tiptap/extension-table-row": "catalog:",
"@tiptap/extension-text-align": "catalog:",
"@tiptap/extension-typography": "catalog:",
"@tiptap/extension-underline": "catalog:",
Expand Down Expand Up @@ -106,4 +110,4 @@
],
"author": "Matt Kane",
"license": "MIT"
}
}
242 changes: 242 additions & 0 deletions packages/admin/src/components/PortableTextEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,11 @@ import {
CodeBlock,
Stack,
Eye,
Table as TableIcon,
Plus,
Trash,
Rows,
Columns,
DotsSixVertical,
CaretDown,
type Icon,
Expand All @@ -70,6 +73,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";
Expand Down Expand Up @@ -296,6 +303,68 @@ function convertPMNode(node: {
style: "lineBreak",
};

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[];
}>;

const contentSpans: PortableTextSpan[] = [];
const cellMarkDefs: PortableTextMarkDef[] = [];
for (const paragraph of cellContent) {
if (paragraph.type === "paragraph") {
const { children, markDefs } = convertInlineContent(paragraph.content || []);
contentSpans.push(...children);
cellMarkDefs.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,
markDefs: cellMarkDefs.length > 0 ? cellMarkDefs : undefined,
};
});

return {
_type: "tableRow" as const,
_key: `${tableKey}_r${rowIndex}`,
cells,
};
});

return {
_type: "table",
_key: tableKey,
rows,
hasHeaderRow: rows[0]?.cells.some((cell) => cell.isHeader) ?? false,

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.

hasHeaderRow is derived as "row 0 has any header cell". Combined with the read path at line 667 (cell.isHeader || (tableBlock.hasHeaderRow && rowIndex === 0)), a table where row 0 is a mix of <th> and <td> will round-trip with the entire row promoted to headers on the next load. Probably OK — toggleHeaderRow flips the whole row in TipTap — but worth noting if anyone later adds toggleHeaderCell to the bubble menu.

};
}

case "pluginBlock": {
const { blockType, id: pluginId, data } = node.attrs ?? {};
return {
Expand Down Expand Up @@ -570,6 +639,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,
Expand Down Expand Up @@ -802,6 +931,21 @@ const defaultSlashCommands: SlashCommandItem[] = [
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
},
},
{
id: "table",
title: msg`Table`,
description: msg`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 @@ -1895,6 +2039,12 @@ export function PortableTextEditor({
ImageExtension,
MarkdownLinkExtension,
PluginBlockExtension,
Table.configure({
resizable: true,
}),
TableRow,
TableHeader,
TableCell,
Placeholder.configure({
includeChildren: true,
placeholder: ({ node }) => {
Expand Down Expand Up @@ -2139,6 +2289,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 @@ -2330,6 +2481,87 @@ function EditorBubbleMenu({ editor }: { editor: Editor }) {
);
}

/**
* Table Bubble Menu - appears when cursor is in a table.
* Shows table editing options: add/remove rows/columns, toggle header, delete table.
*/
function TableBubbleMenu({ editor }: { editor: Editor }) {
const { t } = useLingui();

if (!editor.isActive("table")) {
return null;
}

return (
<BubbleMenu
editor={editor}
options={{
placement: "top",
offset: 8,
}}
shouldShow={({ editor: activeEditor }) => activeEditor.isActive("table")}
className="z-[100] flex items-center gap-0.5 rounded-lg border bg-kumo-base p-1 shadow-lg"
>
<BubbleButton
onClick={() => editor.chain().focus().addColumnBefore().run()}
title={t`Add column before`}
>
<Columns className="h-4 w-4" />
<Plus className="absolute -left-0.5 h-2 w-2" />
</BubbleButton>
<BubbleButton
onClick={() => editor.chain().focus().addColumnAfter().run()}
title={t`Add column after`}
>
<Columns className="h-4 w-4" />
<Plus className="absolute -right-0.5 h-2 w-2" />
</BubbleButton>
<BubbleButton
onClick={() => editor.chain().focus().deleteColumn().run()}
title={t`Delete column`}
>
<Columns className="h-4 w-4 text-kumo-danger" />
</BubbleButton>

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

<BubbleButton
onClick={() => editor.chain().focus().addRowBefore().run()}
title={t`Add row before`}
>
<Rows className="h-4 w-4" />
<Plus className="absolute -top-0.5 h-2 w-2" />
</BubbleButton>
<BubbleButton
onClick={() => editor.chain().focus().addRowAfter().run()}
title={t`Add row after`}
>
<Rows className="h-4 w-4" />
<Plus className="absolute -bottom-0.5 h-2 w-2" />
</BubbleButton>
<BubbleButton onClick={() => editor.chain().focus().deleteRow().run()} title={t`Delete row`}>
<Rows className="h-4 w-4 text-kumo-danger" />
</BubbleButton>

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

<BubbleButton
onClick={() => editor.chain().focus().toggleHeaderRow().run()}
active={editor.isActive("tableHeader")}

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.

Minor UX note (non-blocking): editor.isActive("tableHeader") reflects whether the cursor is currently inside a <th>, not whether the table has a header row. So the toggle button will look "off" whenever the user is in a body cell — even on a table that does have a header row. If you want the indicator to track the table's header-row state, you'd need to walk the table node and check if its first row contains any tableHeader cells. Fine to leave for a follow-up.

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

function BubbleButton({
onClick,
active,
Expand Down Expand Up @@ -2371,6 +2603,7 @@ function EditorToolbar({
focusMode: FocusMode;
onFocusModeChange: (mode: FocusMode) => void;
}) {
const { t } = useLingui();
const [mediaPickerOpen, setMediaPickerOpen] = React.useState(false);
const [showLinkPopover, setShowLinkPopover] = React.useState(false);
const [linkUrl, setLinkUrl] = React.useState("");
Expand Down Expand Up @@ -2601,6 +2834,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={t`Insert Table`}
>
<TableIcon className="h-4 w-4" aria-hidden="true" />
</ToolbarButton>
</ToolbarGroup>

<ToolbarSeparator />
Expand Down
Loading
Loading