Adding Table Editing to TLDraw with TipTap Extensions
While building a PowerPoint-to-TLDraw converter, I needed to add comprehensive table editing capabilities. TLDraw’s rich text editing is powered by TipTap, which has excellent table support through its extension system. However, integrating tables seamlessly into TLDraw’s interface required careful consideration of three key components: the TipTap extensions, the floating toolbar system, and the main toolbar integration.
Important
If you don’t want to read this, but just try it out, visit: Powerpoint Paste into TLDraw. The TLDraw canvas there has it already integrated. The source is here Github Source.
Here’s how I implemented a complete table editing experience that feels native to TLDraw (with a few small quirks).
The Foundation: TipTap Table Extensions
TipTap provides a complete suite of table extensions, but they need proper configuration to work well within TLDraw’s constraints. The key is configuring them with appropriate defaults and CSS classes for styling integration.
Note
I used v2.26.1
of tiptap as this is what is integrated into TLDraw at time of writing, just installed normally via npm.
import { Table } from '@tiptap/extension-table'import { TableRow } from '@tiptap/extension-table-row'import { TableCell } from '@tiptap/extension-table-cell'import { TableHeader } from '@tiptap/extension-table-header'
// Custom extension to handle Tab key in tablesexport const TableTabHandler = Extension.create({ name: 'tableTabHandler', priority: 1000,
addKeyboardShortcuts() { return { Tab: ({ editor }) => { if (editor.isActive('table')) { editor.commands.goToNextCell() return true } return false }, 'Shift-Tab': ({ editor }) => { if (editor.isActive('table')) { editor.commands.goToPreviousCell() return true } return false }, } }})
// TableExtension for TipTap v2.26.1 with TLDraw integrationexport const TableExtension = Table.configure({ resizable: true, // Enable resizing for edit mode handleWidth: 5, HTMLAttributes: { class: 'tldraw-table', style: 'width: auto; max-width: 500px;', // Limit initial width }, cellMinWidth: 60, lastColumnResizable: true, allowTableNodeSelection: false,})
export const TableRowExtension = TableRow.configure({ HTMLAttributes: { class: 'tldraw-table-row', },})
export const TableCellExtension = TableCell.configure({ HTMLAttributes: { class: 'tldraw-table-cell', },})
export const TableHeaderExtension = TableHeader.configure({ HTMLAttributes: { class: 'tldraw-table-header', },})
TABS … painful, but it works
While the above “works” it has a major issue in that if you press tab, it adds a tab to the cell and then goes to the next cell, which is not what you want in tables.
I couldn’t figure out where the tab was being added (I think somewhere inside either TLDraw or TipTap), so it took a sledgehammer to fix it, this code needs to be added at the very top level of the page where you instantiate TLDraw. If anyone knows a more elegant solution please let me know.
// Global keyboard override to prevent tab insertion in tables useEffect(() => { const handleGlobalKeyDown = (e: KeyboardEvent) => { if (e.key === 'Tab' && editorRef.current?.getEditingShapeId()) { const shape = editorRef.current.getShape(editorRef.current.getEditingShapeId()!)
if (shape && shape.type === 'text') { // Check if we're in a table const selection = window.getSelection() if (selection && selection.focusNode) { let node = selection.focusNode while (node) { if (node instanceof Element && (node.tagName === 'TD' || node.tagName === 'TH')) { // Prevent TLDraw's tab insertion e.preventDefault() e.stopPropagation() e.stopImmediatePropagation()
// Call TipTap table navigation directly setTimeout(() => { const proseMirrorElement = document.querySelector('.ProseMirror') as any if (proseMirrorElement && proseMirrorElement.editor) { if (e.shiftKey) { proseMirrorElement.editor.commands.goToPreviousCell() } else { proseMirrorElement.editor.commands.goToNextCell() } } }, 0)
return false } if (node.parentNode === document.body) break node = node.parentNode } } } } }
document.addEventListener('keydown', handleGlobalKeyDown, { capture: true })
return () => { document.removeEventListener('keydown', handleGlobalKeyDown, { capture: true }) } }, [])
Creating the Table Tool
The table tool needs to integrate with TLDraw’s tool system while handling the complexity of creating a text shape and then inserting a table within it. The challenge here was the async nature of rich text editor initialisation, so I will admit it got a bit hacky, but it works.
import { StateNode, createShapeId } from '@tldraw/tldraw'
export class TableTool extends StateNode { static override id = 'table'
override onEnter = () => { this.editor.setCursor({ type: 'cross', rotation: 0 }) }
override onExit = () => { this.editor.setCursor({ type: 'default', rotation: 0 }) }
override onPointerDown = (_info: any) => { const { editor } = this const { currentPagePoint } = editor.inputs
// Create a text shape at the click position const shapeId = createShapeId()
editor.createShape({ id: shapeId, type: 'text', x: currentPagePoint.x - 150, // Center horizontally y: currentPagePoint.y - 25, // Slightly offset vertically props: { w: 300, autoSize: false, } })
// Select and start editing the shape editor.select(shapeId) editor.setEditingShape(shapeId)
// Insert the table after the editor is ready // This seems hacky, but there is no await and it takes time // For the richTextEditor to initialise requestAnimationFrame(() => { let attempts = 0 const tryInsertTable = () => { const richTextEditor = editor.getRichTextEditor() if (richTextEditor) { // Insert table and wait for DOM to update richTextEditor.chain() .clearContent() .insertTable({ rows: 3, cols: 2, withHeaderRow: true }) .run()
// Wait for table to be fully rendered, then position cursor // Without this the cursor is always in the last table cell :( setTimeout(() => { try { // Find the first cell position const { state } = richTextEditor let firstCellPos = null let cellCount = 0
state.doc.descendants((node, pos) => { if (node.type.name === 'tableHeader' || node.type.name === 'tableCell') { if (cellCount === 0) { firstCellPos = pos + 1 } cellCount++ if (cellCount === 1) return false } })
if (firstCellPos !== null) { richTextEditor.commands.setTextSelection(firstCellPos) richTextEditor.commands.focus() } else { richTextEditor.commands.focus('start') }
} catch (error) { console.log('Table cursor positioning error:', error) richTextEditor.commands.focus() }
// Switch back to select tool editor.setCurrentTool('select') }, 150)
} else if (attempts < 10) { attempts++ setTimeout(tryInsertTable, 50) } } tryInsertTable() }) }
override onCancel = () => { this.editor.setCurrentTool('select') }}
So what we had to do was: create the text shape, wait for the rich text editor to be ready, insert the table, then carefully position the cursor in the first cell. The retry mechanism handles the async nature of editor initialization - it would be ideal if there was an event or we could await
these, but I couldn’t figure out a way to do it.
The Floating Table Toolbar
The next problem to solve is the fact that in TLDraw the rich text toolbar only appears if you select text, and of course if you simple are in a table cell you really want a toolbar to add / remove columns and rows to be visible all the time.
So I created a floating toolbar that appears when users are working within a table but aren’t actively selecting text. This provides contextual table operations without cluttering the main UI.
import { useEffect, useState, useRef } from 'react'import { useEditor, useValue, stopEventPropagation } from '@tldraw/tldraw'
export function FloatingTableToolbar() { const editor = useEditor() const textEditor = useValue('textEditor', () => editor.getRichTextEditor(), [editor]) const [isVisible, setIsVisible] = useState(false) const [position, setPosition] = useState({ x: 0, y: 0 }) const toolbarRef = useRef<HTMLDivElement>(null)
useEffect(() => { if (!textEditor) return
const updateToolbar = () => { // Check if we're in a table const inTable = textEditor.isActive('table') || textEditor.isActive('tableCell') || textEditor.isActive('tableHeader') || textEditor.isActive('tableRow')
// Check if text is selected const { state } = textEditor const { from, to } = state.selection const hasTextSelection = from !== to
// Only show floating toolbar if in table but no text is selected setIsVisible(inTable && !hasTextSelection)
if (inTable && !hasTextSelection && toolbarRef.current) { // Position the toolbar above the cursor const start = textEditor.view.coordsAtPos(from) const end = textEditor.view.coordsAtPos(to)
const x = (start.left + end.left) / 2 const y = start.top - 50
setPosition({ x, y }) } }
textEditor.on('transaction', updateToolbar) textEditor.on('selectionUpdate', updateToolbar) textEditor.on('focus', updateToolbar) textEditor.on('blur', () => setIsVisible(false))
updateToolbar()
return () => { textEditor.off('transaction', updateToolbar) textEditor.off('selectionUpdate', updateToolbar) textEditor.off('focus', updateToolbar) textEditor.off('blur', () => setIsVisible(false)) } }, [textEditor])
if (!isVisible || !textEditor) return null
// Table operations const addRowAfter = () => { textEditor.chain().focus().addRowAfter().run() }
const addColumnAfter = () => { textEditor.chain().focus().addColumnAfter().run() }
const deleteRow = () => { textEditor.chain().focus().deleteRow().run() }
const deleteColumn = () => { textEditor.chain().focus().deleteColumn().run() }
const deleteTable = () => { textEditor.chain().focus().deleteTable().run() }
return ( <div ref={toolbarRef} className="floating-table-toolbar" onPointerDown={stopEventPropagation} style={{ position: 'fixed', left: `${position.x}px`, top: `${position.y}px`, transform: 'translateX(-50%)', zIndex: 9999, background: 'white', border: '1px solid #e0e0e0', borderRadius: '6px', boxShadow: '0 2px 8px rgba(0,0,0,0.15)', padding: '4px', display: 'flex', gap: '2px', alignItems: 'center', }} > <button onClick={addRowAfter} title="Add Row Below"> <TableRowAddIcon size={16} /> </button>
<button onClick={deleteRow} title="Delete Row"> <TableRowDeleteIcon size={16} /> </button>
<div style={{ width: '1px', height: '20px', background: '#e0e0e0' }} />
<button onClick={addColumnAfter} title="Add Column After"> <TableColumnAddIcon size={16} /> </button>
<button onClick={deleteColumn} title="Delete Column"> <TableColumnDeleteIcon size={16} /> </button>
<div style={{ width: '1px', height: '20px', background: '#e0e0e0' }} />
<button onClick={deleteTable} title="Delete Table"> <TableDeleteIcon size={16} color="#d32f2f" /> </button> </div> )}
The logic for showing/hiding the floating toolbar is crucial: it appears when the cursor is in a table but no text is selected. This prevents conflicts with the main rich text toolbar while providing easy access to table operations. It also dissapears (see below) when the normal rich text toolbar is shown because you are selecting text.
Rich Text Toolbar Integration
The main rich text toolbar needs table controls that appear contextually. Users should always see the option to insert a table, but table manipulation controls only appear when they’re editing within a table, and of course the floating toolbar needs to be hidden.
export function RichTextToolbar() { const editor = useEditor() const textEditor = useValue('textEditor', () => editor.getRichTextEditor(), [editor])
if (!textEditor) return null
const insertTable = () => { textEditor?.chain() .focus() .insertTable({ rows: 3, cols: 3, withHeaderRow: true }) .goToNextCell() // Move into the first cell .run() }
// Check if cursor is inside a table const isInTable = textEditor?.isActive('table') || textEditor?.isActive('tableCell') || textEditor?.isActive('tableHeader') || textEditor?.isActive('tableRow')
return ( <DefaultRichTextToolbar> {/* Font controls */} <select value={currentFontFamily} onChange={handleFontChange}> {FONT_OPTIONS.map((option) => ( <option key={option.value} value={option.value}> {option.label} </option> ))} </select>
{/* Table Controls */} <div style={{ display: 'flex', gap: '4px', borderLeft: '1px solid #e0e0e0', paddingLeft: '8px', marginLeft: '8px' }}> <button onPointerDown={stopEventPropagation} onClick={insertTable} title="Insert Table (3×3)" style={{ /* button styles */ }} > <TableIcon size={16} /> </button>
{isInTable && ( <> <button onClick={addRowAfter} title="Add Row"> <TableRowAddIcon size={16} /> </button> <button onClick={addColumnAfter} title="Add Column"> <TableColumnAddIcon size={16} /> </button> <button onClick={deleteRow} title="Delete Row"> <TableRowDeleteIcon size={16} /> </button> <button onClick={deleteColumn} title="Delete Column"> <TableColumnDeleteIcon size={16} /> </button> <button onClick={deleteTable} title="Delete Table"> <TableDeleteIcon size={16} color="#d32f2f" /> </button> </> )} </div>
<DefaultRichTextToolbarContent textEditor={textEditor} /> </DefaultRichTextToolbar> )}
The pattern here is progressive disclosure: always show the insert table button, but reveal additional controls only when they’re contextually relevant.
Main Toolbar Integration
Finally, the table tool needs to be available in TLDraw’s main toolbar so you can easily and quickly add a table. This involves both registering the tool and adding the UI button.
First, register the tool in the UI overrides:
export const uiOverrides: TLUiOverrides = { tools(editor, tools) { tools.table = { id: 'table', label: 'Table', icon: 'table', kbd: 'b', // Keyboard shortcut onSelect: () => { editor.setCurrentTool('table') }, } return tools },}
Then create the toolbar button component:
export function TableToolButton() { const editor = useEditor() const tools = useTools() const isTableSelected = useIsToolSelected(tools['table'])
const handleClick = () => { editor.setCurrentTool('table') }
return ( <button className="tlui-button tlui-button__tool" data-testid="tools.table" title="Insert Table (T)" onClick={handleClick} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '40px', height: '40px', background: isTableSelected ? 'var(--color-selected)' : 'transparent', borderRadius: '4px', cursor: 'pointer', }} > <TableIcon size={20} /> </button> )}
And integrate it into the main toolbar:
export function createUIComponents(): TLComponents { return { Toolbar: () => { return ( <DefaultToolbar> <DefaultToolbarContent /> <TableToolButton /> </DefaultToolbar> ) }, RichTextToolbar: RichTextToolbar, }}
Table Styles
Finally, you need to add styles to your main css so it appears as a table:
/* Table styles for TipTap tables in TLDraw - both view and edit modes */.tableWrapper table, .tldraw-table { border-collapse: collapse; margin: 8px 0; overflow: visible; table-layout: fixed; width: auto; display: table; border: 1px solid #333;}
.tldraw-table-cell,.tldraw-table-header { border: 1px solid #333; box-sizing: border-box; min-width: 60px; padding: 6px 10px; position: relative; vertical-align: top; background-color: white;}
.tldraw-table-header { background-color: #e8e8e8; font-weight: bold; text-align: left;}
.tldraw-table-row { border: 1px solid #333;}
/* Selected cell highlighting */.tableWrapper table .selectedCell, .tldraw-table .selectedCell { background-color: rgba(100, 200, 255, 0.1);}
.tableWrapper table .selectedCell:after, .tldraw-table .selectedCell:after { background: rgba(100, 150, 255, 0.2); content: ""; left: 0; right: 0; top: 0; bottom: 0; pointer-events: none; position: absolute; z-index: 2;}
/* Table wrapper */.tableWrapper { margin: 0 0; overflow-x: auto;}
/* Column resize handle */.tableWrapper table .column-resize-handle, .tldraw-table .column-resize-handle { background-color: rgba(100, 150, 255, 1); bottom: -2px; pointer-events: none; position: absolute; right: -2px; top: 0; width: 4px;}
.tableWrapper table th:hover .column-resize-handle,.tableWrapper table td:hover .column-resize-handle { opacity: 1;}
.resize-cursor { cursor: col-resize;}
Bonus content: Rendering PowerPoint Tables
For the PowerPoint converter use case, I also needed to render existing table data into TLDraw. This is the project where I am actually using this: Powerpoint Paste into TLDraw. This involves converting table data into TipTap’s rich text format:
function createTableRichText(tableData: string[][], hasHeader: boolean = true): any { if (!tableData || tableData.length === 0) { return { type: 'doc', content: [ { type: 'paragraph', content: [{ type: 'text', text: 'Empty table' }] } ] } }
const tableRows = tableData.map((rowData, rowIndex) => { const isHeaderRow = hasHeader && rowIndex === 0 const cellType = isHeaderRow ? 'tableHeader' : 'tableCell'
const cells = rowData.map((cellContent) => { const textContent = cellContent && cellContent.trim() ? cellContent : ' ' return { type: cellType, attrs: { colspan: 1, rowspan: 1, colwidth: null }, content: [ { type: 'paragraph', attrs: { dir: 'auto' }, content: [{ type: 'text', text: textContent }] } ] } })
return { type: 'tableRow', content: cells } })
return { type: 'doc', content: [ { type: 'table', content: tableRows } ] }}
export async function renderTableComponent( component: PowerPointComponent, index: number, frameX: number, frameY: number, editor: Editor) { const tableData = component.metadata?.tableData || [] const hasHeader = component.metadata?.hasHeader ?? true
const richText = createTableRichText(tableData, hasHeader)
const tableId = createShapeId() editor.createShape({ id: tableId, type: 'text', x: tableX, y: tableY, props: { richText: richText, color: 'black', size: 's', font: 'sans', w: component.width || 400, autoSize: false } })}
This converter takes raw table data and transforms it into the TipTap document format that the rich text editor expects.
Key Takeaways
Implementing table editing in TLDraw taught me several important lessons:
-
Async initialization matters: Rich text editors need time to initialize. Always use retry mechanisms when programmatically inserting content. Perhaps there is a better way?
-
Event handling is crucial: Use
stopEventPropagation
liberally to prevent toolbar clicks from affecting the main canvas. -
Cursor positioning is tricky: When programmatically creating tables, getting the cursor in the right place requires careful DOM traversal and timing.
Remaining issues
-
The rich text isnt working properly inside the table cells (so I can’t make things bold / change individual text colours).
-
Sometimes the resizing of the inner table and outer TLDraw element is a bit not user friendly.
Can you suggest any improvements? Contact me via Github: Issues.