Tables Project ProposalThis is a proposal for implementing tables in HM editor

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 TableRow blocks under the Table.

  • Column order is determined by the order of TableColumn blocks under the Table.

  • 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 MoveBlock op on a TableColumn. Adding a row is a single MoveBlock insert of a TableRow with 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 on TableColumn blocks, 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 HMBlockKnownSchema and 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 of TableRow and TableColumn blocks) is a small break from the "every parent has one kind of child" intuition.

  • prosemirror-tables operates on physical row/column indexes, not column IDs. We need to extend the vendored TipTap extension so cells carry columnId in PM attrs, and so column-insert/move/delete commands mint and move TableColumn blocks 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-content div with data-content-type="table" etc.

  • Restrict cell content to inline only.

  • Persist id on the table, tableRow, tableCell, and tableHeader PM nodes so they round-trip through the BlockNode format. TableColumn is not a PM node, it lives only at the BlockNode layer.

  • Add columnId as an attribute on both the tableCell and tableHeader PM nodes. This is the key bridge between prosemirror-tables physical position model and our column by reference model.

  • Add explicit moveColumnLeft / moveColumnRight commands that operate on the TableColumn block (single move op) rather than rewriting cells across every row. (Decide where the commands will live in the UI)

  • Modify addColumn / deleteColumn commands to account for the TableColumn blocks.

  • 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-tables commands. (Needs designs!)

  • Column resize: drag handle on cell borders writes to the TableColumn block's width attribute. CSS from prosemirror-tables/style/tables.css and 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 copies columnId from PM attrs into wire-format attributes. The column case copies width and isHeader. The row case copies isHeader.

  • 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), with columnId preserved on the editor block.

  • On read, drop cells whose columnId doesn't match any sibling TableColumn, 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

  1. @tiptap/extension-table@2.0.3 compatibility. Sharing the 2.0.3 line with the rest of @tiptap/* means we get a version pair the Tiptap team shipped together, so very low risk.

  2. Block count blow-up. A 20×10 table = 211 blocks. Need to confirm the backend indexer, draft storage, and IndexedDB layer (seed_drafts DB per project memory) handle this gracefully.

  3. gRPC 4MB message size (per project memory). Very large pasted tables could approach this when serialized with rich annotations.

  4. Cell columnId integrity. The Zod schema can't enforce "columnId references a TableColumn in the same Table". We need to validate on read and on commands.

  5. childrenType for Table/Row. Tables shouldn't fit childrenType. (Make childrenType optional 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, HMBlockTableCellSchema to hm-types.ts.

  • Add converters in both directions (including normalization of orphans/missing cells on read).

  • Add @tiptap/extension-table dependency (similar to link and code-block) and add the columnId attribute extension on the cell node.

  • Add TableBlock React spec, register in schema.ts and full-schema.ts.

  • Read-only rendering works in readonly-viewer.tsx because 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 TableColumn blocks and propagate columnId.

  • 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 tableParagraph constraint.

  • Cell-level styling (per-cell background, alignment overrides). Header-row and header-column styling are the only differentiation.

  • colspan / rowspan merging. 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/client so 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