Skip to content

feat(tiptap): add native table support with round-trip MDC conversion#444

Open
nvdai2401 wants to merge 20 commits into
nuxt-content:mainfrom
nvdai2401:maico/tiptap-table-support
Open

feat(tiptap): add native table support with round-trip MDC conversion#444
nvdai2401 wants to merge 20 commits into
nuxt-content:mainfrom
nvdai2401:maico/tiptap-table-support

Conversation

@nvdai2401
Copy link
Copy Markdown

@nvdai2401 nvdai2401 commented Apr 30, 2026

Summary

Adds native TipTap table editing to the Studio editor. Users can:

  • Type /table to insert a 2x2 table with a header row
  • Click into cells and type, navigate with Tab/Shift+Tab
  • Open existing markdown files with tables and edit them as TipTap tables
  • Add/remove rows and columns via a floating bubble menu inside the table
  • Round-trip table content through MDC ↔ markdown without data loss

Approach

Uses upstream @tiptap/extension-table directly with native ProseMirror rendering — no custom NodeView, so cell selection and keyboard navigation behave as users expect from a standard TipTap editor.

The implementation followed a TDD cycle: failing round-trip test first, then bidirectional MDC conversion (mdcToTiptap for table/thead/tbody/tr/th/td, tiptapToMdc for the inverse). Cell content is wrapped in a paragraph in TipTap (its schema requires block content in cells) and unwrapped on export.

A floating BubbleMenu (gated on editor.isActive('table')) exposes TipTap's built-in table commands as buttons: add row above/below, add column left/right, toggle header, delete row/column/table.

What's in scope (v1)

  • Insert/edit native tables via /table slash command
  • Round-trip MDC ↔ TipTap conversion for tables
  • Floating bubble menu with row/column/table actions
  • Editor styles (borders, padding, header background, dark mode)

Not in scope (deferred)

  • Column resizing (resizable: false for v1)
  • Cell merging/splitting

Test plan

  • Integration test: simple table round-trip (markdown → MDC → TipTap → MDC → markdown)
  • Integration test: bold + link inside cells
  • Integration test: empty cells
  • Integration test: single-column table
  • Manual: /table insertion in dev playground
  • Manual: cell editing, Tab navigation
  • Manual: row/column add/delete via bubble menu
  • Manual: open existing markdown file with table — renders as editable table
  • Manual: round-trip back to markdown matches GFM table syntax
  • Reviewer: confirm optimizeDeps config in src/app/vite.config.ts doesn't break other Vite consumers
  • Reviewer: confirm new dep @tiptap/extension-table@^3.22.2 is acceptable

Notable changes & gotchas

  • Vite optimizeDeps: @tiptap/extension-table is excluded from optimizeDeps and @tiptap/pm/tables is included instead. Without this, prosemirror-tables loaded twice (once inlined inside the @tiptap/extension-table pre-bundle, once as a direct ES module from prosemirror-tables/dist/index.js), causing Duplicate use of selection JSON ID cell.
  • Handler ordering: useTiptapEditor's customHandlers now spreads dynamic component handlers before the explicit image/video/table handlers so a project-defined component named table (e.g. Nuxt UI's <UTable>) doesn't shadow our native handler.
  • Slash menu de-dup: useTiptapEditor filters componentItems to skip names listed in NATIVE_OVERRIDE_COMPONENTS (currently ['table']), so the slash menu shows one Table entry instead of two.
  • Drag-handle Turn into: For tables, the Turn into submenu options are force-disabled (matching how Card/Element behave) — without this, ProseMirror would happily replace the table with a paragraph and silently drop every cell.
  • Test infrastructure: A small vitest.config.ts alias for nuxt-studio/app and a minimal src/module/tsconfig.json were added so tests run without requiring pnpm dev:prepare. These are benign in CI on Node 22, but happy to drop them if reviewers prefer.

Testing recording

https://www.loom.com/share/9d81adf0698c4e5bbb20d8f474dda2b0

🤖 Generated with Claude Code

@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented Apr 30, 2026

@nvdai2401 is attempting to deploy a commit to the Nuxt Team on Vercel.

A member of the Team first needs to authorize it.

nvdai2401 and others added 19 commits April 30, 2026 15:53
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds integration test for table → MDC → TipTap → MDC → markdown
round-trip. Fails at expect(tableNode).toBeDefined() because mdcToTiptapMap
has no table handler yet (TDD RED phase).

Also adds vitest infrastructure fixes needed to run tests without a full
dev:prepare build: resolve alias for self-referential nuxt-studio/app/utils
import, and a src/module/tsconfig.json so vite's esbuild transform doesn't
need the gitignored .nuxt/tsconfig.json.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds table/tableRow/tableHeader/tableCell entries to tiptapToMDCMap
with createTableElement and createTableCellElement factory functions.
Also fixes the round-trip test assertions to account for GFM column
padding (the serializer pads columns to the longest cell value).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Inserting a table threw "Duplicate use of selection JSON ID cell"
because prosemirror-tables was being loaded twice in the studio app's
Vite bundle: once inlined inside the @tiptap/extension-table optimizeDeps
pre-bundle, and once as a direct unbundled import via @tiptap/pm/tables.

Both registered the cell selection on the same shared Selection class.

Why: import all four extensions (Table/Row/Cell/Header) from a single
package and let Vite control which copy of prosemirror-tables runs.
How to apply: exclude @tiptap/extension-table from optimizeDeps and
include @tiptap/pm/tables instead so the table module is the single
shared instance the unbundled extension-table imports.

Also removes the redundant @tiptap/extension-table-{cell,header,row}
devDependencies — they are thin re-export shims of @tiptap/extension-table
and adding them increased the chance of multi-path resolution.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The customHandlers object spread the dynamic component handlers AFTER
the explicit image/video/table handlers. When a project has a
component named "table" (e.g. Nuxt UI's UTable), its componentHandler
was overwriting tableHandler and the slash command inserted a
collapsed Vue component block instead of the native TipTap table.

Move the spread before the explicit handlers so image/video/table win
even when a same-named component exists in the meta catalog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Without these, tables rendered as bare HTML in the editor — no borders,
no padding, no header background — even though the live preview styled
them correctly via the site's typography CSS. Cells inside the studio
editor now show visible borders, padded content, a highlighted header
row, and a selected-cell indicator. Includes dark-mode variants.

Targets the .studio-table class set on the Table extension's
HTMLAttributes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ProseMirror lets editor.can().setParagraph() return true on a table
selection — choosing it would silently replace the table with a single
paragraph and drop every cell value. Match the UX of custom block
components like Card / Element where these options are greyed out by
mapping the Turn into children to disabled items when the selected
node is a table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…is active

A project's MDC components catalog can contain a 'table' entry (e.g.
Nuxt UI's <UTable>). The slash menu was showing two "Table" entries —
one from getStandardSuggestionItems (our native extension) and one
from componentItems — which is confusing UX, especially since both
now route to the same handler but originate from different code paths.

Filter components named 'table' out of componentItems so only the
native entry remains. Implemented as a NATIVE_OVERRIDE_COMPONENTS set
so future native overrides can opt in the same way.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a contextual bubble menu that appears when the cursor is inside
a table cell, exposing TipTap's built-in table commands as visible
buttons:

- Add row above / below
- Add column left / right
- Toggle header row
- Delete row / column / entire table

Implementation uses @tiptap/vue-3's BubbleMenu with shouldShow gated
on editor.isActive('table') so it only renders inside tables and
doesn't interfere with the regular text-selection bubble toolbar.
Buttons follow the existing UButton + i-lucide-* pattern; delete
table is colored error to signal destructiveness.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@nvdai2401 nvdai2401 force-pushed the maico/tiptap-table-support branch from ad3b249 to 7486e13 Compare April 30, 2026 07:56
@nvdai2401 nvdai2401 marked this pull request as ready for review April 30, 2026 08:07
A 3x3 table feels heavyweight as a starter — 1 header + 2 data rows ×
3 columns is more cells than most users want before they start
filling things in. Drop the default to 1 header + 1 data row × 2
columns; users can grow the table from the bubble menu.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@nvdai2401
Copy link
Copy Markdown
Author

Hey @larbish @TotomInc, I created this PR to add native table support to the Studio editor. It’s been a long-standing gap and I tried to keep the change as minimal and idiomatic as possible.

What’s in: /table slash command inserts a 2x2 GFM table; cells are editable with Tab navigation; existing markdown tables render as editable tables; full round-trip through MDC ↔ markdown (4 integration tests). A floating bubble menu inside tables exposes add/delete row/column and toggle-header actions. Drag-handle “Turn into” is force-disabled for tables (otherwise ProseMirror would silently swap the table for a paragraph).

What I’d appreciate eyes on:

  • Vite optimizeDeps in src/app/vite.config.ts: I had to exclude: ['@tiptap/extension-table'] and include: ['@tiptap/pm/tables'] so prosemirror-tables only loads once. Without it, dev mode threw Duplicate use of selection JSON ID cell. Open to a cleaner approach if you have one.
  • vitest.config.ts alias + src/module/tsconfig.json: small infra changes I needed to run tests on Node 20 without a full dev:prepare. Benign on Node 22 CI but happy to drop them if you prefer.
    Slash menu de-dup (useTiptapEditor.ts): a project-defined <UTable> would have collided with our native handler — I added a NATIVE_OVERRIDE_COMPONENTS set to filter it out. Let me know if there’s a more established pattern for this.

Out of scope for v1: column resizing, cell merging/splitting. Happy to tackle those in a follow-up.

Branch is up to date with main, all 247 tests pass.

@larbish
Copy link
Copy Markdown
Contributor

larbish commented Apr 30, 2026

Hello @nvdai2401, thanks a lot for this PR! That's a great feature.

I'm currently finishing this PR about migrating the nuxt studio parser to comark.dev.

This has a big impact on the transformation between TipTap AST and Comark AST (and no more legacy MDC AST).

I should merge it really soon and I'll provide a skill I've maintained during the all migration so you can easily adapt your PR once this branch is merged.

I'll keep you posted!

@Rigo-m
Copy link
Copy Markdown

Rigo-m commented May 3, 2026

Round-trip mdc conversion is a big upgrade to studio, can actually enable bigger patterns (e.g.: kanban boards and other components)

@nvdai2401
Copy link
Copy Markdown
Author

Hey @larbish, any updates on #355. Could you give an ETA?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants