diff --git a/.changeset/table-support.md b/.changeset/table-support.md
new file mode 100644
index 0000000000..a9297bf59b
--- /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 e217bb9946..67332b2799 100644
--- a/packages/admin/package.json
+++ b/packages/admin/package.json
@@ -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:",
@@ -106,4 +110,4 @@
],
"author": "Matt Kane",
"license": "MIT"
-}
\ No newline at end of file
+}
diff --git a/packages/admin/src/components/PortableTextEditor.tsx b/packages/admin/src/components/PortableTextEditor.tsx
index c0a7b592c4..c1756fb16d 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,
type Icon,
@@ -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";
@@ -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,
+ };
+ }
+
case "pluginBlock": {
const { blockType, id: pluginId, data } = node.attrs ?? {};
return {
@@ -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,
@@ -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();
+ },
+ },
];
/**
@@ -1895,6 +2039,12 @@ export function PortableTextEditor({
ImageExtension,
MarkdownLinkExtension,
PluginBlockExtension,
+ Table.configure({
+ resizable: true,
+ }),
+ TableRow,
+ TableHeader,
+ TableCell,
Placeholder.configure({
includeChildren: true,
placeholder: ({ node }) => {
@@ -2139,6 +2289,7 @@ export function PortableTextEditor({
)}
+
{editable &&
}
@@ -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 (
+
activeEditor.isActive("table")}
+ className="z-[100] flex items-center gap-0.5 rounded-lg border bg-kumo-base p-1 shadow-lg"
+ >
+ editor.chain().focus().addColumnBefore().run()}
+ title={t`Add column before`}
+ >
+
+
+
+ editor.chain().focus().addColumnAfter().run()}
+ title={t`Add column after`}
+ >
+
+
+
+ editor.chain().focus().deleteColumn().run()}
+ title={t`Delete column`}
+ >
+
+
+
+
+
+ editor.chain().focus().addRowBefore().run()}
+ title={t`Add row before`}
+ >
+
+
+
+ editor.chain().focus().addRowAfter().run()}
+ title={t`Add row after`}
+ >
+
+
+
+ editor.chain().focus().deleteRow().run()} title={t`Delete row`}>
+
+
+
+
+
+ editor.chain().focus().toggleHeaderRow().run()}
+ active={editor.isActive("tableHeader")}
+ title={t`Toggle header row`}
+ >
+
+
+ editor.chain().focus().deleteTable().run()}
+ title={t`Delete table`}
+ >
+
+
+
+ );
+}
+
function BubbleButton({
onClick,
active,
@@ -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("");
@@ -2601,6 +2834,15 @@ function EditorToolbar({
>
+
+ editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()
+ }
+ active={editor.isActive("table")}
+ title={t`Insert Table`}
+ >
+
+
diff --git a/packages/admin/src/locales/ja/messages.po b/packages/admin/src/locales/ja/messages.po
index e3d6b7f1a0..4521954376 100644
--- a/packages/admin/src/locales/ja/messages.po
+++ b/packages/admin/src/locales/ja/messages.po
@@ -443,6 +443,14 @@ msgstr "有効"
msgid "Add"
msgstr "追加"
+#: packages/admin/src/components/PortableTextEditor.tsx:2507
+msgid "Add column before"
+msgstr "前に列を追加"
+
+#: packages/admin/src/components/PortableTextEditor.tsx:2514
+msgid "Add column after"
+msgstr "後に列を追加"
+
#. placeholder {0}: field.item_label
#: packages/admin/src/components/PortableTextEditor.tsx:1321
msgid "Add {0}"
@@ -501,6 +509,14 @@ msgstr ""
msgid "Add Item"
msgstr "アイテムを追加"
+#: packages/admin/src/components/PortableTextEditor.tsx:2530
+msgid "Add row before"
+msgstr "前に行を追加"
+
+#: packages/admin/src/components/PortableTextEditor.tsx:2537
+msgid "Add row after"
+msgstr "後に行を追加"
+
#: packages/admin/src/components/MenuEditor.tsx:399
msgid "Add links to build your navigation menu"
msgstr "ナビゲーションメニューを構築するためにリンクを追加"
@@ -1547,6 +1563,10 @@ msgstr "{0}を削除しますか?"
msgid "Delete Comment?"
msgstr "コメントを削除しますか?"
+#: packages/admin/src/components/PortableTextEditor.tsx:2521
+msgid "Delete column"
+msgstr "列を削除"
+
#: packages/admin/src/components/ContentTypeList.tsx:142
msgid "Delete Content Type?"
msgstr "コンテンツタイプを削除しますか?"
@@ -1585,10 +1605,18 @@ msgstr "リダイレクト{0}を削除"
msgid "Delete Redirect?"
msgstr "リダイレクトを削除しますか?"
+#: packages/admin/src/components/PortableTextEditor.tsx:2542
+msgid "Delete row"
+msgstr "行を削除"
+
#: packages/admin/src/components/Sections.tsx:294
msgid "Delete Section?"
msgstr "セクションを削除しますか?"
+#: packages/admin/src/components/PortableTextEditor.tsx:2558
+msgid "Delete table"
+msgstr "テーブルを削除"
+
#: packages/admin/src/components/ContentList.tsx:346
msgid "Deleted"
msgstr "削除済み"
@@ -2509,6 +2537,10 @@ msgstr "コードブロックを挿入"
msgid "Insert a horizontal rule"
msgstr "水平線を挿入"
+#: packages/admin/src/components/PortableTextEditor.tsx:937
+msgid "Insert a table"
+msgstr "テーブルを挿入"
+
#: packages/admin/src/components/PortableTextEditor.tsx:1810
msgid "Insert a reusable section"
msgstr "再利用可能なセクションを挿入"
@@ -2525,6 +2557,10 @@ msgstr "URLから挿入"
msgid "Insert Section"
msgstr "セクションを挿入"
+#: packages/admin/src/components/PortableTextEditor.tsx:2842
+msgid "Insert Table"
+msgstr "テーブルを挿入"
+
#: packages/admin/src/components/settings/SocialSettings.tsx:161
msgid "Instagram"
msgstr "Instagram"
@@ -4890,6 +4926,10 @@ msgstr "テーマのマーケットプレイスは空です。後で確認して
msgid "Theme"
msgstr "テーマ"
+#: packages/admin/src/components/PortableTextEditor.tsx:936
+msgid "Table"
+msgstr "テーブル"
+
#. placeholder {0}: section.themeId
#: packages/admin/src/components/SectionEditor.tsx:282
msgid "Theme ID: {0}"
@@ -5032,6 +5072,10 @@ msgstr "選択"
msgid "Toggle theme (current: {label})"
msgstr "テーマを切り替え(現在: {label})"
+#: packages/admin/src/components/PortableTextEditor.tsx:2551
+msgid "Toggle header row"
+msgstr "ヘッダー行を切り替え"
+
#. placeholder {0}: newToken.info.name
#: packages/admin/src/components/settings/ApiTokenSettings.tsx:202
msgid "Token created: {0}"
diff --git a/packages/admin/src/styles.css b/packages/admin/src/styles.css
index ce0de8ee6e..bb09846938 100644
--- a/packages/admin/src/styles.css
+++ b/packages/admin/src/styles.css
@@ -231,6 +231,47 @@ body {
margin-top: 0 !important;
}
+/* TipTap table styles */
+.ProseMirror table {
+ border-collapse: collapse;
+ width: 100%;
+ margin: 1rem 0;
+ table-layout: fixed;
+ overflow: hidden;
+}
+
+.ProseMirror td,
+.ProseMirror th {
+ border: 1px solid var(--color-kumo-line);
+ padding: 0.5rem 0.75rem;
+ vertical-align: top;
+ position: relative;
+ min-width: 80px;
+}
+
+.ProseMirror th {
+ background-color: var(--color-kumo-tint);
+ font-weight: 600;
+}
+
+.ProseMirror .selectedCell {
+ background-color: var(--color-kumo-info-tint);
+}
+
+.ProseMirror .column-resize-handle {
+ position: absolute;
+ right: -2px;
+ top: 0;
+ bottom: -2px;
+ width: 4px;
+ background-color: var(--color-kumo-brand);
+ pointer-events: none;
+}
+
+.ProseMirror.resize-cursor {
+ cursor: col-resize;
+}
+
/**
* Prevent dialogs from overflowing the viewport on small screens.
* Kumo's size variants set min-width (e.g. min-w-[32rem] for "lg")
diff --git a/packages/admin/tests/components/PortableTextEditor.table.test.ts b/packages/admin/tests/components/PortableTextEditor.table.test.ts
new file mode 100644
index 0000000000..4e3a777c45
--- /dev/null
+++ b/packages/admin/tests/components/PortableTextEditor.table.test.ts
@@ -0,0 +1,401 @@
+import { describe, it, expect } from "vitest";
+
+import {
+ _portableTextToProsemirror,
+ _prosemirrorToPortableText,
+} from "../../src/components/PortableTextEditor";
+
+describe("Table conversion: PortableText ↔ ProseMirror", () => {
+ describe("PortableText → ProseMirror", () => {
+ it("converts simple table with text", () => {
+ const ptBlocks = [
+ {
+ _type: "table",
+ _key: "t1",
+ hasHeaderRow: true,
+ rows: [
+ {
+ _type: "tableRow",
+ _key: "r1",
+ cells: [
+ {
+ _type: "tableCell",
+ _key: "c1",
+ isHeader: true,
+ content: [{ _type: "span", _key: "s1", text: "Header 1" }],
+ },
+ {
+ _type: "tableCell",
+ _key: "c2",
+ isHeader: true,
+ content: [{ _type: "span", _key: "s2", text: "Header 2" }],
+ },
+ ],
+ },
+ {
+ _type: "tableRow",
+ _key: "r2",
+ cells: [
+ {
+ _type: "tableCell",
+ _key: "c3",
+ content: [{ _type: "span", _key: "s3", text: "Cell 1" }],
+ },
+ {
+ _type: "tableCell",
+ _key: "c4",
+ content: [{ _type: "span", _key: "s4", text: "Cell 2" }],
+ },
+ ],
+ },
+ ],
+ },
+ ];
+
+ const result = _portableTextToProsemirror(ptBlocks);
+
+ expect(result.type).toBe("doc");
+ expect(result.content).toHaveLength(1);
+
+ const table = result.content[0];
+ expect(table.type).toBe("table");
+ expect(table.content).toHaveLength(2);
+
+ const headerRow = table.content[0];
+ expect(headerRow.type).toBe("tableRow");
+ expect(headerRow.content[0].type).toBe("tableHeader");
+ expect(headerRow.content[1].type).toBe("tableHeader");
+
+ const dataRow = table.content[1];
+ expect(dataRow.type).toBe("tableRow");
+ expect(dataRow.content[0].type).toBe("tableCell");
+ });
+
+ it("converts table with text formatting marks", () => {
+ const ptBlocks = [
+ {
+ _type: "table",
+ _key: "t1",
+ rows: [
+ {
+ _type: "tableRow",
+ _key: "r1",
+ cells: [
+ {
+ _type: "tableCell",
+ _key: "c1",
+ content: [{ _type: "span", _key: "s1", text: "Bold", marks: ["strong"] }],
+ },
+ {
+ _type: "tableCell",
+ _key: "c2",
+ content: [{ _type: "span", _key: "s2", text: "Italic", marks: ["em"] }],
+ },
+ ],
+ },
+ ],
+ },
+ ];
+
+ const result = _portableTextToProsemirror(ptBlocks);
+
+ const table = result.content[0];
+ const cell1 = table.content[0].content[0];
+ const cell1Para = cell1.content[0];
+ const cell1Text = cell1Para.content[0];
+
+ expect(cell1Text.text).toBe("Bold");
+ expect(cell1Text.marks).toContainEqual({ type: "bold" });
+
+ const cell2 = table.content[0].content[1];
+ const cell2Para = cell2.content[0];
+ const cell2Text = cell2Para.content[0];
+
+ expect(cell2Text.text).toBe("Italic");
+ expect(cell2Text.marks).toContainEqual({ type: "italic" });
+ });
+
+ it("converts table with links (markDefs)", () => {
+ const ptBlocks = [
+ {
+ _type: "table",
+ _key: "t1",
+ markDefs: [
+ {
+ _type: "link",
+ _key: "link1",
+ href: "https://example.com",
+ blank: true,
+ },
+ ],
+ rows: [
+ {
+ _type: "tableRow",
+ _key: "r1",
+ cells: [
+ {
+ _type: "tableCell",
+ _key: "c1",
+ content: [{ _type: "span", _key: "s1", text: "Click here", marks: ["link1"] }],
+ },
+ ],
+ },
+ ],
+ },
+ ];
+
+ const result = _portableTextToProsemirror(ptBlocks);
+
+ const table = result.content[0];
+ const cell = table.content[0].content[0];
+ const para = cell.content[0];
+ const text = para.content[0];
+
+ expect(text.text).toBe("Click here");
+ expect(text.marks).toContainEqual({
+ type: "link",
+ attrs: { href: "https://example.com", target: "_blank" },
+ });
+ });
+ });
+
+ describe("ProseMirror → PortableText", () => {
+ it("converts simple table to PT", () => {
+ const pmDoc = {
+ type: "doc",
+ content: [
+ {
+ type: "table",
+ content: [
+ {
+ type: "tableRow",
+ content: [
+ {
+ type: "tableHeader",
+ content: [
+ {
+ type: "paragraph",
+ content: [{ type: "text", text: "Header 1" }],
+ },
+ ],
+ },
+ {
+ type: "tableHeader",
+ content: [
+ {
+ type: "paragraph",
+ content: [{ type: "text", text: "Header 2" }],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ type: "tableRow",
+ content: [
+ {
+ type: "tableCell",
+ content: [
+ {
+ type: "paragraph",
+ content: [{ type: "text", text: "Cell 1" }],
+ },
+ ],
+ },
+ {
+ type: "tableCell",
+ content: [
+ {
+ type: "paragraph",
+ content: [{ type: "text", text: "Cell 2" }],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ };
+
+ const result = _prosemirrorToPortableText(pmDoc);
+
+ expect(result).toHaveLength(1);
+ const table = result[0] as {
+ _type: string;
+ rows: Array<{
+ cells: Array<{ content: Array<{ text: string }>; isHeader: boolean }>;
+ }>;
+ hasHeaderRow: boolean;
+ };
+
+ expect(table._type).toBe("table");
+ expect(table.hasHeaderRow).toBe(true);
+ expect(table.rows).toHaveLength(2);
+ expect(table.rows[0].cells[0].isHeader).toBe(true);
+ expect(table.rows[0].cells[0].content[0].text).toBe("Header 1");
+ expect(table.rows[1].cells[0].isHeader).toBe(false);
+ expect(table.rows[1].cells[0].content[0].text).toBe("Cell 1");
+ });
+
+ it("converts table with text marks to PT", () => {
+ const pmDoc = {
+ type: "doc",
+ content: [
+ {
+ type: "table",
+ content: [
+ {
+ type: "tableRow",
+ content: [
+ {
+ type: "tableCell",
+ content: [
+ {
+ type: "paragraph",
+ content: [
+ {
+ type: "text",
+ text: "Bold text",
+ marks: [{ type: "bold" }],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ };
+
+ const result = _prosemirrorToPortableText(pmDoc);
+
+ const table = result[0] as {
+ rows: Array<{
+ cells: Array<{ content: Array<{ text: string; marks?: string[] }> }>;
+ }>;
+ };
+
+ expect(table.rows[0].cells[0].content[0].text).toBe("Bold text");
+ expect(table.rows[0].cells[0].content[0].marks).toContain("strong");
+ });
+
+ it("converts table with links to PT (preserves markDefs)", () => {
+ const pmDoc = {
+ type: "doc",
+ content: [
+ {
+ type: "table",
+ content: [
+ {
+ type: "tableRow",
+ content: [
+ {
+ type: "tableCell",
+ content: [
+ {
+ type: "paragraph",
+ content: [
+ {
+ type: "text",
+ text: "Link text",
+ marks: [
+ {
+ type: "link",
+ attrs: {
+ href: "https://example.com",
+ target: "_blank",
+ },
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ };
+
+ const result = _prosemirrorToPortableText(pmDoc);
+
+ const table = result[0] as {
+ rows: Array<{
+ cells: Array<{
+ content: Array<{ text: string; marks?: string[] }>;
+ markDefs?: Array<{ _key: string; _type: string; href: string; blank?: boolean }>;
+ }>;
+ }>;
+ };
+
+ const cell = table.rows[0].cells[0];
+ expect(cell.markDefs).toBeDefined();
+ expect(cell.markDefs).toHaveLength(1);
+ expect(cell.markDefs![0]._type).toBe("link");
+ expect(cell.markDefs![0].href).toBe("https://example.com");
+
+ const linkMarkKey = cell.markDefs![0]._key;
+ expect(cell.content[0].marks).toContain(linkMarkKey);
+ });
+ });
+
+ describe("Round-trip conversion", () => {
+ it("PT → PM → PT preserves table structure", () => {
+ const original = [
+ {
+ _type: "table",
+ _key: "t1",
+ hasHeaderRow: true,
+ rows: [
+ {
+ _type: "tableRow",
+ _key: "r1",
+ cells: [
+ {
+ _type: "tableCell",
+ _key: "c1",
+ isHeader: true,
+ content: [{ _type: "span", _key: "s1", text: "Header" }],
+ },
+ ],
+ },
+ {
+ _type: "tableRow",
+ _key: "r2",
+ cells: [
+ {
+ _type: "tableCell",
+ _key: "c2",
+ content: [{ _type: "span", _key: "s2", text: "Data" }],
+ },
+ ],
+ },
+ ],
+ },
+ ];
+
+ const pm = _portableTextToProsemirror(original);
+ const roundTripped = _prosemirrorToPortableText(pm);
+
+ const table = roundTripped[0] as {
+ _type: string;
+ hasHeaderRow: boolean;
+ rows: Array<{
+ cells: Array<{ content: Array<{ text: string }>; isHeader: boolean }>;
+ }>;
+ };
+
+ expect(table._type).toBe("table");
+ expect(table.hasHeaderRow).toBe(true);
+ expect(table.rows[0].cells[0].isHeader).toBe(true);
+ expect(table.rows[0].cells[0].content[0].text).toBe("Header");
+ expect(table.rows[1].cells[0].isHeader).toBe(false);
+ expect(table.rows[1].cells[0].content[0].text).toBe("Data");
+ });
+ });
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 931364cb4a..583adede02 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -90,6 +90,18 @@ catalogs:
'@tiptap/extension-placeholder':
specifier: ^3.20.0
version: 3.20.0
+ '@tiptap/extension-table':
+ specifier: ^3.20.0
+ version: 3.20.0
+ '@tiptap/extension-table-cell':
+ specifier: ^3.20.0
+ version: 3.20.0
+ '@tiptap/extension-table-header':
+ specifier: ^3.20.0
+ version: 3.20.0
+ '@tiptap/extension-table-row':
+ specifier: ^3.20.0
+ version: 3.20.0
'@tiptap/extension-text-align':
specifier: ^3.20.0
version: 3.20.0
@@ -725,6 +737,18 @@ importers:
'@tiptap/extension-placeholder':
specifier: 'catalog:'
version: 3.20.0(@tiptap/extensions@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0))
+ '@tiptap/extension-table':
+ specifier: 'catalog:'
+ version: 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)
+ '@tiptap/extension-table-cell':
+ specifier: 'catalog:'
+ version: 3.20.0(@tiptap/extension-table@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0))
+ '@tiptap/extension-table-header':
+ specifier: 'catalog:'
+ version: 3.20.0(@tiptap/extension-table@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0))
+ '@tiptap/extension-table-row':
+ specifier: 'catalog:'
+ version: 3.20.0(@tiptap/extension-table@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0))
'@tiptap/extension-text-align':
specifier: 'catalog:'
version: 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))
@@ -5185,6 +5209,27 @@ packages:
peerDependencies:
'@tiptap/core': ^3.20.0
+ '@tiptap/extension-table-cell@3.20.0':
+ resolution: {integrity: sha512-9Dg4zda3UWwtpBwSG7b9BeQy5oT27a/yEIBeARuxe19bloMLZgqpPRtnSrOK0OAITtVnjA+NZdKPcVLRMS2E8A==}
+ peerDependencies:
+ '@tiptap/extension-table': ^3.20.0
+
+ '@tiptap/extension-table-header@3.20.0':
+ resolution: {integrity: sha512-2tVHHlihpeHO/gh2uU46gAX3NTGdKR+yDmfLlO2l0QAvx2TXNfNzX2pOM4MmyostW5Ko9TCWV4x0D9h3IQDhPw==}
+ peerDependencies:
+ '@tiptap/extension-table': ^3.20.0
+
+ '@tiptap/extension-table-row@3.20.0':
+ resolution: {integrity: sha512-clkfQahkYW/U48QBh1rPZv3AWWSC9AqGKp2DLTH/SGIorM/NwI0jpPtBETMlvowyQu0ivlH9B896smEph+Do2A==}
+ peerDependencies:
+ '@tiptap/extension-table': ^3.20.0
+
+ '@tiptap/extension-table@3.20.0':
+ resolution: {integrity: sha512-vaaMtQ2KnSSr8WVwgWf7NYNzPwrHx/6T0ekA5CxV8qNUEpXIaLXa5+tE7tJHWEdNR2KY3gUJ46D3lfOkxyFrBQ==}
+ peerDependencies:
+ '@tiptap/core': ^3.20.0
+ '@tiptap/pm': ^3.20.0
+
'@tiptap/extension-text-align@3.20.0':
resolution: {integrity: sha512-4s0r+bovtH6yeGDUD+Ui8j5WOV5koB5P6AuzOMqoLwaFGRSkKf64ly6DXjjmjIgnYCLZN/XO6llaQKVVdvad2g==}
peerDependencies:
@@ -13074,6 +13119,23 @@ snapshots:
dependencies:
'@tiptap/core': 3.20.0(@tiptap/pm@3.20.0)
+ '@tiptap/extension-table-cell@3.20.0(@tiptap/extension-table@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0))':
+ dependencies:
+ '@tiptap/extension-table': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)
+
+ '@tiptap/extension-table-header@3.20.0(@tiptap/extension-table@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0))':
+ dependencies:
+ '@tiptap/extension-table': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)
+
+ '@tiptap/extension-table-row@3.20.0(@tiptap/extension-table@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0))':
+ dependencies:
+ '@tiptap/extension-table': 3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)
+
+ '@tiptap/extension-table@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))(@tiptap/pm@3.20.0)':
+ dependencies:
+ '@tiptap/core': 3.20.0(@tiptap/pm@3.20.0)
+ '@tiptap/pm': 3.20.0
+
'@tiptap/extension-text-align@3.20.0(@tiptap/core@3.20.0(@tiptap/pm@3.20.0))':
dependencies:
'@tiptap/core': 3.20.0(@tiptap/pm@3.20.0)
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 07c60551ae..51b23a9357 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -42,6 +42,10 @@ catalog:
"@tiptap/extension-link": ^3.20.0
"@tiptap/extension-node-range": ^3.20.0
"@tiptap/extension-placeholder": ^3.20.0
+ "@tiptap/extension-table": ^3.20.0
+ "@tiptap/extension-table-cell": ^3.20.0
+ "@tiptap/extension-table-header": ^3.20.0
+ "@tiptap/extension-table-row": ^3.20.0
"@tiptap/extension-text-align": ^3.20.0
"@tiptap/extension-typography": ^3.20.0
"@tiptap/extension-underline": ^3.20.0