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.

Tables in TLDraw

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 tables
export 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 integration
export 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.

Table tool creation flow

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.

Floating table toolbar in action

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.

Rich text toolbar with table controls

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,
}
}

Main toolbar with table tool

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:

  1. Async initialization matters: Rich text editors need time to initialize. Always use retry mechanisms when programmatically inserting content. Perhaps there is a better way?

  2. Event handling is crucial: Use stopEventPropagation liberally to prevent toolbar clicks from affecting the main canvas.

  3. Cursor positioning is tricky: When programmatically creating tables, getting the cursor in the right place requires careful DOM traversal and timing.

Remaining issues

  1. The rich text isnt working properly inside the table cells (so I can’t make things bold / change individual text colours).

  2. 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.