Skip to main content
The Table Handle component provides interactive controls for tables in your editor. It includes column and row handles that enable adding, deleting, and reordering table elements through drag-and-drop operations.

Features

  • Column and row manipulation
  • Drag-and-drop reordering
  • Context menus for table operations
  • Visual drag preview
  • Drop indicators
  • Automatic positioning
  • Framework-agnostic

Installation

import {
  TableHandleRoot,
  TableHandleColumnRoot,
  TableHandleColumnTrigger,
  TableHandleRowRoot,
  TableHandleRowTrigger,
  TableHandlePopoverContent,
  TableHandlePopoverItem,
  TableHandleDragPreview,
  TableHandleDropIndicator,
} from 'prosekit/react/table-handle'

Basic Usage

import { ProseKit } from 'prosekit/react'
import {
  TableHandleRoot,
  TableHandleColumnRoot,
  TableHandleColumnTrigger,
  TableHandleRowRoot,
  TableHandleRowTrigger,
  TableHandleDragPreview,
  TableHandleDropIndicator,
} from 'prosekit/react/table-handle'

export default function Editor() {
  return (
    <ProseKit editor={editor}>
      <div ref={editor.mount} />
      
      <TableHandleRoot>
        {/* Visual feedback during drag */}
        <TableHandleDragPreview />
        <TableHandleDropIndicator />
        
        {/* Column handle */}
        <TableHandleColumnRoot>
          <TableHandleColumnTrigger>
            <ColumnIcon />
          </TableHandleColumnTrigger>
        </TableHandleColumnRoot>
        
        {/* Row handle */}
        <TableHandleRowRoot>
          <TableHandleRowTrigger>
            <RowIcon />
          </TableHandleRowTrigger>
        </TableHandleRowRoot>
      </TableHandleRoot>
    </ProseKit>
  )
}

Components

TableHandleRoot

Container for all table handle components. Must wrap all other table handle components.
className
string
CSS class name for styling.

TableHandleColumnRoot

Container for column handle trigger and popover.
placement
string
default:"top"
Position of the handle relative to the table cell.
hoist
boolean
default:"false"
Whether to use the browser Popover API.
className
string
CSS class name for styling.

TableHandleColumnTrigger

The draggable trigger that appears above columns.
className
string
CSS class name for styling.

TableHandleRowRoot

Container for row handle trigger and popover.
placement
string
default:"left"
Position of the handle relative to the table cell. Use right for RTL layouts.
hoist
boolean
default:"false"
Whether to use the browser Popover API.
className
string
CSS class name for styling.

TableHandleRowTrigger

The draggable trigger that appears beside rows.
className
string
CSS class name for styling.

TableHandlePopoverContent

Container for menu items that appears when clicking a handle.
className
string
CSS class name for styling.

TableHandlePopoverItem

Individual menu item.
onSelect
() => void
required
Callback executed when the item is selected.
className
string
CSS class name for styling.

TableHandleDragPreview

Visual preview of the column/row being dragged. Should be rendered once.
className
string
CSS class name for styling.

TableHandleDropIndicator

Visual indicator showing where the dragged column/row will be dropped. Should be rendered once.
className
string
CSS class name for styling.

Complete Example

import { useEditor, useEditorDerivedValue } from 'prosekit/react'
import {
  TableHandleRoot,
  TableHandleColumnRoot,
  TableHandleColumnTrigger,
  TableHandleRowRoot,
  TableHandleRowTrigger,
  TableHandlePopoverContent,
  TableHandlePopoverItem,
  TableHandleDragPreview,
  TableHandleDropIndicator,
} from 'prosekit/react/table-handle'

function getTableCommands(editor: Editor) {
  return {
    // Column commands
    addColumnBefore: {
      canExec: editor.commands.addTableColumnBefore.canExec(),
      command: () => editor.commands.addTableColumnBefore(),
    },
    addColumnAfter: {
      canExec: editor.commands.addTableColumnAfter.canExec(),
      command: () => editor.commands.addTableColumnAfter(),
    },
    deleteColumn: {
      canExec: editor.commands.deleteTableColumn.canExec(),
      command: () => editor.commands.deleteTableColumn(),
    },
    
    // Row commands
    addRowAbove: {
      canExec: editor.commands.addTableRowAbove.canExec(),
      command: () => editor.commands.addTableRowAbove(),
    },
    addRowBelow: {
      canExec: editor.commands.addTableRowBelow.canExec(),
      command: () => editor.commands.addTableRowBelow(),
    },
    deleteRow: {
      canExec: editor.commands.deleteTableRow.canExec(),
      command: () => editor.commands.deleteTableRow(),
    },
    
    // General
    clearContents: {
      canExec: editor.commands.deleteCellSelection.canExec(),
      command: () => editor.commands.deleteCellSelection(),
    },
    deleteTable: {
      canExec: editor.commands.deleteTable.canExec(),
      command: () => editor.commands.deleteTable(),
    },
  }
}

export default function TableHandle({ dir = 'ltr' }) {
  const editor = useEditor()
  const commands = useEditorDerivedValue(getTableCommands)

  return (
    <TableHandleRoot>
      <TableHandleDragPreview />
      <TableHandleDropIndicator />
      
      {/* Column handle */}
      <TableHandleColumnRoot className="table-column-handle">
        <TableHandleColumnTrigger className="table-column-trigger">
          <svg width="16" height="16" viewBox="0 0 16 16">
            <path d="M3 4h10M3 8h10M3 12h10" stroke="currentColor" />
          </svg>
        </TableHandleColumnTrigger>
        
        <TableHandlePopoverContent className="table-handle-menu">
          {commands.addColumnBefore.canExec && (
            <TableHandlePopoverItem
              onSelect={commands.addColumnBefore.command}
              className="table-menu-item"
            >
              Insert Left
            </TableHandlePopoverItem>
          )}
          
          {commands.addColumnAfter.canExec && (
            <TableHandlePopoverItem
              onSelect={commands.addColumnAfter.command}
              className="table-menu-item"
            >
              Insert Right
            </TableHandlePopoverItem>
          )}
          
          {commands.clearContents.canExec && (
            <TableHandlePopoverItem
              onSelect={commands.clearContents.command}
              className="table-menu-item"
            >
              Clear Contents
            </TableHandlePopoverItem>
          )}
          
          {commands.deleteColumn.canExec && (
            <TableHandlePopoverItem
              onSelect={commands.deleteColumn.command}
              className="table-menu-item"
            >
              Delete Column
            </TableHandlePopoverItem>
          )}
          
          {commands.deleteTable.canExec && (
            <TableHandlePopoverItem
              onSelect={commands.deleteTable.command}
              className="table-menu-item danger"
            >
              Delete Table
            </TableHandlePopoverItem>
          )}
        </TableHandlePopoverContent>
      </TableHandleColumnRoot>
      
      {/* Row handle */}
      <TableHandleRowRoot 
        placement={dir === 'rtl' ? 'right' : 'left'}
        className="table-row-handle"
      >
        <TableHandleRowTrigger className="table-row-trigger">
          <svg width="16" height="16" viewBox="0 0 16 16">
            <path d="M4 3v10M8 3v10M12 3v10" stroke="currentColor" />
          </svg>
        </TableHandleRowTrigger>
        
        <TableHandlePopoverContent className="table-handle-menu">
          {commands.addRowAbove.canExec && (
            <TableHandlePopoverItem
              onSelect={commands.addRowAbove.command}
              className="table-menu-item"
            >
              Insert Above
            </TableHandlePopoverItem>
          )}
          
          {commands.addRowBelow.canExec && (
            <TableHandlePopoverItem
              onSelect={commands.addRowBelow.command}
              className="table-menu-item"
            >
              Insert Below
            </TableHandlePopoverItem>
          )}
          
          {commands.clearContents.canExec && (
            <TableHandlePopoverItem
              onSelect={commands.clearContents.command}
              className="table-menu-item"
            >
              Clear Contents
            </TableHandlePopoverItem>
          )}
          
          {commands.deleteRow.canExec && (
            <TableHandlePopoverItem
              onSelect={commands.deleteRow.command}
              className="table-menu-item"
            >
              Delete Row
            </TableHandlePopoverItem>
          )}
          
          {commands.deleteTable.canExec && (
            <TableHandlePopoverItem
              onSelect={commands.deleteTable.command}
              className="table-menu-item danger"
            >
              Delete Table
            </TableHandlePopoverItem>
          )}
        </TableHandlePopoverContent>
      </TableHandleRowRoot>
    </TableHandleRoot>
  )
}

Styling

.table-column-handle,
.table-row-handle {
  position: absolute;
}

.table-column-trigger,
.table-row-trigger {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 4px;
  background: white;
  border: 1px solid #e5e7eb;
  border-radius: 4px;
  cursor: grab;
  transition: all 0.15s;
}

.table-column-trigger:hover,
.table-row-trigger:hover {
  background: #f3f4f6;
}

.table-column-trigger:active,
.table-row-trigger:active {
  cursor: grabbing;
}

.table-handle-menu {
  background: white;
  border: 1px solid #e5e7eb;
  border-radius: 8px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  padding: 4px;
  min-width: 160px;
}

.table-menu-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 8px 12px;
  border: none;
  background: transparent;
  border-radius: 4px;
  cursor: pointer;
  transition: background 0.15s;
  width: 100%;
  text-align: left;
}

.table-menu-item:hover {
  background: #f3f4f6;
}

.table-menu-item.danger {
  color: #dc2626;
}

.table-menu-item.danger:hover {
  background: #fee2e2;
}

Table Commands

The table handle uses these editor commands:

Column Operations

  • addTableColumnBefore() - Insert column to the left
  • addTableColumnAfter() - Insert column to the right
  • deleteTableColumn() - Delete the current column

Row Operations

  • addTableRowAbove() - Insert row above
  • addTableRowBelow() - Insert row below
  • deleteTableRow() - Delete the current row

General Operations

  • deleteCellSelection() - Clear selected cells’ contents
  • deleteTable() - Delete the entire table

Drag and Drop

Column Reordering

  1. Click and hold the column trigger
  2. Drag horizontally
  3. Drop indicator shows target position
  4. Release to reorder

Row Reordering

  1. Click and hold the row trigger
  2. Drag vertically
  3. Drop indicator shows target position
  4. Release to reorder

RTL Support

For right-to-left languages, position row handles on the right:
<TableHandleRowRoot placement="right">
  {/* ... */}
</TableHandleRowRoot>

Accessibility

  • Keyboard accessible menus
  • ARIA labels for handles
  • Focus management
  • Screen reader support

Best Practices

  1. Always include both preview and indicator: These provide essential visual feedback during drag operations
  2. Conditional menu items: Only show commands that can be executed in the current context
  3. Destructive actions: Visually distinguish dangerous actions like “Delete Table”
  4. RTL support: Adjust row handle placement for right-to-left languages
  5. Mobile: Consider disabling drag-and-drop on touch devices or providing an alternative UI

Common Issues

Handles don’t appear

  • Ensure TableHandleRoot wraps all components
  • Verify the table extension is installed
  • Check CSS isn’t hiding the handles

Drag doesn’t work

  • Make sure both TableHandleDragPreview and TableHandleDropIndicator are rendered
  • Verify drag events aren’t being prevented
  • Check that TableHandlePopoverContent is inside the appropriate Root component
  • Verify commands are available (check canExec)

See Also