Problem
The Seed editor is a fork of BlockNote living in frontend/packages/editor/src/blocknote/. The upstream BlockNote ships a Table block built on prosemirror-tables, but our fork was before it was implemented.
Tables are now one of the biggest gaps in our editor. They show up in PDFs, in pasted Markdown, in copied web content, and in any document that needs to present a small dataset. We currently drop them on import.
Solution
Add Table, TableRow, and TableColumn to BlockNode schema. A Table block contains both TableRow children and TableColumn children. Blocks live inside a TableRow and carry a columnId attribute referencing its TableColumn by ID.
Row order is determined by the order of
TableRowblocks under theTable.Column order is determined by the order of
TableColumnblocks under theTable.A cell's position is its row (parent) and its
columnId(reference). The physical order of cells inside a row is ignored, but we do apply move operations to them (see )
New schema code:
export const HMBlockTableSchema = z.object({
type: z.literal('Table'),
...blockBaseProperties,
attributes: z.object({
...parentBlockAttributes,
}).optional().default({}),
}).strict()
export const HMBlockTableRowSchema = z.object({
type: z.literal('TableRow'),
...blockBaseProperties,
attributes: z.object({
...parentBlockAttributes,
isHeader: z.boolean().optional().default(false), // header row
}).optional().default({}),
}).strict()
export const HMBlockTableColumnSchema = z.object({
type: z.literal('TableColumn'),
...blockBaseProperties,
attributes: z.object({
...parentBlockAttributes,
width: z.number().optional(), // px or %
isHeader: z.boolean().optional().default(false), // header column
}).optional().default({}),
}).strict()Pros:
Concurrent edits don't corrupt the table. Reordering columns is a single
MoveBlockop on aTableColumn. Adding a row is a singleMoveBlockinsert of aTableRowwith cells. If both happen at the same time, both succeed and the cells still land under the correct columns (cells reference columns by ID, not by position).Each cell is an existing block type with its own ID, text, and annotations.
Column metadata (
width,isHeader) lives onTableColumnblocks, which means concurrent column resize is a single attribute edit on a single block. Header columns are supported by the same mechanism as header rows.Stable cell IDs survive insert/delete/reorder, which keeps comments, version diffs, and per-cell permalinks sensible.
Cons:
Three new entries in
HMBlockKnownSchemaand four new converter cases.Larger block count for table-heavy documents (a 10×10 table = 1 + 10 + 100 = 111 blocks). Need to confirm this doesn't blow up indexing or render performance.
Heterogeneous
Table.children(mix ofTableRowandTableColumnblocks) is a small break from the "every parent has one kind of child" intuition.prosemirror-tablesoperates on physical row/column indexes, not column IDs. We need to extend the vendored TipTap extension so cells carrycolumnIdin PM attrs, and so column-insert/move/delete commands mint and moveTableColumnblocks accordingly.Light corruption is possible if a column is deleted concurrently with a cell being added that references it. The cell is "orphaned" and won't render. We normalize on read (drop orphans) and accept this as a rare edge case.
Editor implementation
Dependencies
Add 4 packages to packages/editor/package.json, all pinned at 2.0.3 to match the rest of @tiptap/*:
@tiptap/extension-table@tiptap/extension-table-row@tiptap/extension-table-cell@tiptap/extension-table-header
Each is a thin Tiptap wrapper around prosemirror-tables. Together they provide the four ProseMirror nodes, the tableEditing plugin, the column resize plugin, and Tiptap style commands (insertTable, addRowBefore, addColumnAfter, etc.).
Also bring in prosemirror-tables/style/tables.css (resize handle styling, column-resize cursor) and override it with Tailwind to match the theme.
The modifications we will make to the TipTap extension:
Wrap each node's DOM output in BlockNote's
bn-block-contentdiv withdata-content-type="table"etc.Restrict cell content to inline only.
Persist
idon thetable,tableRow,tableCell, andtableHeaderPM nodes so they round-trip through the BlockNode format.TableColumnis not a PM node, it lives only at the BlockNode layer.Add
columnIdas an attribute on both thetableCellandtableHeaderPM nodes. This is the key bridge betweenprosemirror-tablesphysical position model and our column by reference model.Add explicit
moveColumnLeft/moveColumnRightcommands that operate on theTableColumnblock (single move op) rather than rewriting cells across every row. (Decide where the commands will live in the UI)Modify
addColumn/deleteColumncommands to account for theTableColumnblocks.Backspace to delete empty table handler.
React block spec
New file packages/editor/src/table.tsx following the embed-block pattern:
export const TableBlock = createReactBlockSpec({
type: 'table',
propSchema: {
...defaultProps,
},
containsInlineContent: false, // children are TableRow blocks
render: ({block, editor}) => <TableView block={block} editor={editor} />,
parseHTML: [{tag: 'table', priority: 1000, ...}],
})
Register in schema.ts under hmBlockSchema and the read-only full-schema.ts. Slash-menu entry mirrors how embed is registered.
UI surfaces
Slash menu: "Table" inserts a 3×3 empty table.
Floating toolbar/menu (when inside a cell): add row above/below, add column left/right, delete row, delete column, toggle header row, delete table. These map to
prosemirror-tablescommands. (Needs designs!)Column resize: drag handle on cell borders writes to the
TableColumnblock'swidthattribute. CSS fromprosemirror-tables/style/tables.cssand Tailwind overrides to match our theme.Mobile / narrow viewport: wrap the table in a horizontally-scrollable container (
overflow-x-auto) so it never breaks the document layout.
Editor to hm block conversion
editorblock-to-hmblock.ts:
Extend
toHMBlockType()with'table' → 'Table','tableRow' → 'TableRow','tableColumn' → 'TableColumn','tableCell' → 'TableCell'.In
editorBlockToHMBlock(), add four switch cases. The cell case copiescolumnIdfrom PM attrs into wire-format attributes. The column case copieswidthandisHeader. The row case copiesisHeader.Recursion in
editorBlocksToHMBlockNodes()already handles nested children, so the Table → (Rows and Columns) → Cells tree just works.
hmblock-to-editorblock.ts:
Extend
toEditorBlockType()with the reverse mappings.Cell handling in
hmBlockToEditorBlock()is a near-clone of Paragraph (text + annotations + attributes), withcolumnIdpreserved on the editor block.On read, drop cells whose
columnIddoesn't match any siblingTableColumn, and emit empty cells for missing column/row combinations so the visual grid is rectangular.
Add a roundtrip test covering: empty table, 2×2 table with mixed annotations per cell, table with header row, table with header column, column reorder via TableColumn move.
Published rendering
Because readonly-viewer.tsx initializes the same BlockNote editor in editable: false mode, our React block spec renders identically in published view.
The only packages/ui/ change is to make sure blocks-content-utils.tsx tree traversal recognizes Table descendants and walks through both TableRow and TableColumn children.
Comments and quote ranges
Because Approach A makes each cell its own block with its own text and annotations, quote ranges land cell-scoped for free. For example, quoting "the third cell of the second row" is a quote range inside TableCell's text. No new range model needed.
Cross-cell quoting ("from cell A2 to cell B3") is explicitly out of scope. This matches how we handle quotes that cross block boundaries today (we don't).
Import / export
Markdown
Markdown GFM tables (| a | b |\n|---|---|\n| 1 | 2 |) translate to Table - TableRow - TableCell. First row maps to header row, alignment markers (:---:, ---:) get dropped for initial implementation. (Need to decide where this lives. The existing converter at MarkdownToBlocks.ts is tied to a live editor instance, which is why the CLI has its own copy.)
HTML paste
parseHTML rules on the block spec catch <table>/<tr>/<td>/<th> during paste. ProseMirror's HTML parser handles nested structure if our schema declares it correctly. We mint TableColumn blocks from the first row's column count, and assign columnId to each cell. We flatten multi-paragraph cells to inline (matching BlockNote upstream's parseTableContent() strategy) since cells are inline-only in initial implementation.
PDF import
The PDF import skill in frontend/apps/cli is currently LLM-driven. We extend the structured output schema with a table block type. The LLM already extracts table structure from scanned/native PDFs reliably, so this is mostly a schema-extension exercise.
Export
Markdown export and HTML export both need a Table case. Need to correctly recreate the columns by IDs, which complicates it. The current export paths live alongside the converters in @seed-hypermedia/client (verify locations during implementation)
Risks
@tiptap/extension-table@2.0.3compatibility. Sharing the2.0.3line with the rest of@tiptap/*means we get a version pair the Tiptap team shipped together, so very low risk.Block count blow-up. A 20×10 table = 211 blocks. Need to confirm the backend indexer, draft storage, and IndexedDB layer (
seed_draftsDB per project memory) handle this gracefully.gRPC 4MB message size (per project memory). Very large pasted tables could approach this when serialized with rich annotations.
Cell
columnIdintegrity. The Zod schema can't enforce "columnId references a TableColumn in the same Table". We need to validate on read and on commands.childrenTypefor Table/Row. Tables shouldn't fit childrenType. (MakechildrenTypeoptional on Table/Row blocks?)
Phased delivery
I'd cut this in three PRs, each landable independently, ramping risk:
Phase 1 - Wire format and read-only render (1 week)
Add
HMBlockTableSchema,HMBlockTableRowSchema,HMBlockTableColumnSchema,HMBlockTableCellSchematohm-types.ts.Add converters in both directions (including normalization of orphans/missing cells on read).
Add
@tiptap/extension-tabledependency (similar to link and code-block) and add thecolumnIdattribute extension on the cell node.Add
TableBlockReact spec, register inschema.tsandfull-schema.ts.Read-only rendering works in
readonly-viewer.tsxbecause of the shared block spec.Slash-menu insert (a default 3×3 table is enough to test the rest).
Tests: roundtrip, Zod validation, snapshot of read-only render, normalization (orphaned cells dropped, missing cells filled).
Phase 2 - Editing
Floating cell menu (add/remove row/col, toggle header, delete table). (Again needs designs)
Column resize writes to
TableColumn.width.Backspace to delete empty table.
Tab/Shift-Tab navigation.
The columnID aware commands (insert/delete/move) maintain
TableColumnblocks and propagatecolumnId.Tests: editor commands (insert, addRow, addColumn, delete), Playwright E2E for cell selection and resize.
Phase 3 - Import/export (1 week)
Markdown GFM paste/parse and export.
HTML paste.
PDF import LLM schema extension.
Tests: paste fixtures (Google Docs, Notion), Markdown roundtrip.
No-goes
Explicit no-goes (defer to later):
Nested blocks inside cells (images, embeds, code, sub-tables). Initially, cells are inline only, which matches BlockNote upstream's
tableParagraphconstraint.Cell-level styling (per-cell background, alignment overrides). Header-row and header-column styling are the only differentiation.
colspan/rowspanmerging. Adding optional Zod fields later is backward-compatible, so we add them when we add merging UI.Sorting, filtering, formulas.
Cross-cell comment/quote ranges. Quotes will be cell-scoped.
Multiple children and nested groups in a table cell.
Open questions
Are we okay with the "cells are inline-only in initial implementation" constraint? 
Does anyone object to adding tiptap's table extension to the editor package's dependency tree?
Is "no cross-cell quotes" acceptable?
Should the Markdown to editor blocks converter move into
@seed-hypermedia/clientso CLI and desktop share a single path, or should tables import land in the existing editor package converter and CLI's parallel converter, with consolidation as a follow-up project?
Do you like what you are reading? Subscribe to receive updates.
Unsubscribe anytime