Shift+Arrow.TableKit includes TablePlugin, TableRowPlugin, and TableCellPlugin with their Plate UI components. The copied row controls require DndKit.
pnpm dlx shadcn@latest add @plate/table @plate/dndpnpm dlx shadcn@latest add @plate/table @plate/dnd'use client';
import {
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
CombineIcon,
EraserIcon,
Grid2X2Icon,
GripVertical,
PaintBucketIcon,
SquareSplitHorizontalIcon,
Trash2Icon,
XIcon,
} from 'lucide-react';
import { PathApi } from 'platejs';
import { useDraggable, useDropLine } from 'platejs/dnd/react';
import {
type EditorElementProps,
EditorElement,
useComposedRef,
useEditor,
useEditorReadOnly,
useEditorSelector,
TableElement: Renders the table.TableRowElement: Renders rows and their controls.TableCellElement: Renders tableCell nodes as <td> or, when header: true, <th>.import { createEditor } from 'platejs/react';
import { DndKit } from '@/components/editor/dnd';
import { TableKit } from '@/components/editor/table';
const editor = createEditor({
plugins: [...TableKit, ...DndKit],
});import { createEditor } from 'platejs/react';
import { DndKit } from '@/components/editor/dnd';
Use the installed table descriptor for insertion, selection reads, and updates:
import { createEditor } from 'platejs/react';
import { TablePlugin } from 'platejs/table/react';
const editor = createEditor({ plugins: [TablePlugin] });
const table = editor.plugin(TablePlugin);
table.update.insert({ rows: 3, columns: 4, header: true }, { select: true });
table.update.insertColumn({ before: true });
if (table.read.canMerge()) {
table.update.merge();
}import { createEditor } from 'platejs/react';
import { TablePlugin } from 'platejs/table/react';
const editor = createEditor({ plugins: [TablePlugin] });
const table = editor.plugin(TablePlugin);
table.update.insert({ rows: 3, columns: 4, header: true }, { select: true });
table.update.insertColumn({ before: true });
if (table.read.canMerge()) {
table.update.merge();
}For headless use, import createEditor from platejs and BaseTablePlugin from platejs/table.
Omit at to use the invoking editor view's selection. Pass at to address a specific target independently of that selection:
const selected = table.read.selection();
if (selected) {
table.update.insertRow({ at: selected.focus, before: true });
const cell = table.read.cell({ at: selected.anchor });
if (cell) {
table.update.setCellBackground({ at: cell.entry[0], color: '#fef9c3' });
}
}const selected = table.read.selection();
if (selected) {
table.update.insertRow({ at: selected.focus, before: true });
const cell = table.read.cell({ at: selected.anchor });
if (cell) {
table.update.setCellBackground({ at: cell.entry[0], color: '#fef9c3' });
}
}TableTargetOptions.at accepts a table, row, or cell target, or a NodeSelection. A node target can be a path, point, range, live node, or NodeKey. A deleted key or detached node does not fall back to the current selection. Paths are local to the invoking root; root-bearing targets retain their root.
read.cell accepts a cell target and returns null unless it resolves to one cell. Use read.selection for a multi-cell selection. Its cells contain the exact selected entries; bounds does not fill gaps in a nonrectangular selection.
Group related changes in one update:
editor.update((tx) => {
const table = tx.plugin(TablePlugin);
table.setCellBackground({ color: '#fef9c3' });
table.setBorders({
border: 'outer',
value: { color: '#334155', style: 'solid', width: 2 },
});
});editor.update((tx) => {
const table = tx.plugin(TablePlugin);
table.setCellBackground({ color: '#fef9c3' });
table.setBorders({
border: 'outer',
value: { color: '#334155', style: 'solid', width: 2 },
});
});Set color: null to clear a background. A border value replaces that edge's formatting; null clears the override, and { width: 0 } hides it. Omitted border fields use the renderer defaults.
read.borders() returns true, false, or 'mixed' for each border predicate. Check 'mixed' explicitly in controls. toggleBorders hides an all-visible border and shows an off or mixed border; border: 'none' toggles the no-border state.
TablePlugin installs the row and cell descriptors as required dependencies. Add those descriptors directly when configuring their components. The copied row renderer requires DndKit.
import { createEditor } from 'platejs/react';
import { TableCellPlugin, TablePlugin, TableRowPlugin } from 'platejs/table/react';
import { DndKit } from '@/components/editor/dnd';
import {
TableCellElement,
TableElement,
TableRowElement,
} from '@/components/editor/table';
const editor = createEditor({
plugins: [
...DndKit,
TablePlugin.configure({
component: TableElement,
initialState: {
allowCellSpanEditing: true,
defaultTableWidth: 600,
expandOnPaste: true
Add TableToolbarButton to your Toolbar to insert tables.
Set initialState.allowCellSpanEditing to false to disable both operations. Existing spans can still be displayed and pasted.
TablePlugin handles keyboard navigation, selection, deletion, and clipboard input. Tab and Shift+Tab move between cells; Tab in the last cell can insert a row. Same-cell text drags retain native text selection. Clearing a structural cell selection preserves its exact membership and direction.
Merge combines the complete rich content of the selected cells in row-major order, including media-only content and owned roots. It retains the destination cell and surviving node identities. Split keeps the original content in its anchor cell and creates empty peer cells.
Structural table paste requires a closed ContentSlice containing exactly one table. Open slices, row or cell lists, and tables with siblings are fitted as complete ordinary content. Ordinary content can be broadcast to each selected cell.
Pasting a table into a selected rectangle requires complete tiles. Partial tiles and destination spans crossing the intended coverage are rejected; fully covered spans can be replaced. With expandOnPaste: false, overflow is rejected rather than cropped. An unsuccessful fit leaves the document and selection unchanged.
Structural cell copy and cut require a rectangular selection that fully covers its spans. A nonrectangular selection still supports deletion, formatting, and ordinary-content paste. Cut clears cells only after a successful clipboard write. Exact slices retain rich content and reachable owned roots; CSV and TSV are text representations.
Table widths are stored in columnWidths; null means an unknown imported width. api.columnWidths(table) resolves every logical column to a number without persisting fallback values. The renderer owns temporary size overrides. Each row stores its own height.
api.createResize(table, target) captures starting sizes and returns a function from pixel delta to TableResize. Preview calculation does not change the document:
import { TablePlugin } from 'platejs/table/react';
const selected = editor.plugin(TablePlugin).read.selection();
if (selected) {
const preview = editor.plugin(TablePlugin).api.createResize(selected.table[0], {
edge: 'right',
colIndex: 0,
});
editor.plugin(TablePlugin).update.resize({
at: selected.tableKey,
resize: preview(24),
});
}import { TablePlugin } from 'platejs/table/react';
const selected = editor.plugin(TablePlugin).read.selection();
if (selected) {
const preview = editor.plugin(TablePlugin).api.createResize(selected.table[0], {
edge: 'right',
colIndex: 0,
});
editor.plugin(TablePlugin).update.resize({
at: selected.tableKey,
resize: preview(24),
});
}Targets are { edge: 'right', colIndex }, { edge: 'left' }, or { edge: 'bottom', rowIndex, height }. A bottom target needs the measured starting row height. Interior boundaries preserve adjacent columns' combined width; the left boundary exchanges first-column width for indentation. Imported widths below minColumnWidth retain their starting width as the gesture's lower bound.
A horizontal resize commit contains a nonempty columns array of { colIndex, width }. A left-edge commit also requires marginLeft; a right-edge commit has no margin. A bottom-edge commit contains rowIndex and height.
Configure these values through initialState:
Allows merge and split commands. Default: true.
Supplies the table width used to resolve missing column widths. A non-null value must be positive and finite. Default: null.
Allows table paste to grow the destination. Does not restrict explicit row or column insertion. Default: true.
Positive finite minimum for resize gestures. Default: 48.
Required structural descriptor for rows. A row can have no direct cells when spans from earlier rows cover it.
Required structural descriptor for all cells. Header cells use type: 'tableCell' and header: true. Spans use positive safe integer colSpan and rowSpan values; omitted spans are one.
In the reference below, table is editor.plugin(TablePlugin). Public table types are exported from platejs/table.
| Pure API | Result | Purpose |
|---|---|---|
table.api.columnWidths(tableElement) | readonly number[] | Resolve widths for every logical column; array length is the column count. |
table.api.createResize(tableElement, target) | (delta: number) => TableResize | Calculate constrained resize previews. |
| Read | Result | Purpose |
|---|---|---|
table.read.selection(options?) | TableSelection | null | Exact cell membership, direction, table identity, and bounds. |
table.read.cell(options?) | TableCellInfo | null | One cell's entry, logical coordinates, spans, borders, and size. |
table.read.borders(options?) | TableBorderStates | null | Aggregate top, right, bottom, left, none, and outer as true | false | 'mixed'. |
table.read.canMerge(options?) | boolean | Whether a span-complete rectangular multi-cell target can merge under the current policy and view permissions. |
table.read.canSplit(options?) | boolean | Whether one spanning cell can split under the current policy and view permissions. |
Read options are TableTargetOptions, except cell, which takes { at?: TableCellTarget }.
TableSelection contains table, tableKey, cells, anchor, focus, bounds, rectangular, and optional root. table and cells are live element entries; tableKey, anchor, and focus are NodeKey values. rectangular indicates complete rectangular span coverage.
TableCellInfo contains entry, row, col, rowSpan, colSpan, borders, size, and optional root. size contains width and minHeight. Resolved borders contain color, style, and width: bottom/right are present; top/left are present at the corresponding table edge. In both read results, an omitted root means the primary root.
Every table update returns boolean: true for an accepted change, false for an unavailable target, rejected operation, or unchanged request. Invalid dimensions and numeric options throw before publication. The command rechecks eligibility even after a capability read.
| Update | Options | Purpose |
|---|---|---|
table.update.insert(options?, placement?) | TableCreateOptions, TableInsertPlacement | Insert a table; defaults to two rows and two columns. |
table.update.insertColumn(options?) | TableAxisInsertOptions | Insert at a logical column boundary. |
table.update.insertRow(options?) | TableAxisInsertOptions | Insert at a logical row boundary. |
table.update.removeColumn(options?) | TableTargetOptions | Remove targeted columns; remove the table when none remain. |
table.update.removeRow(options?) | TableTargetOptions | Remove targeted rows; remove the table when none remain. |
table.update.remove(options?) | TableTargetOptions | Remove the containing table. |
table.update.merge(options?) | TableTargetOptions | Merge a complete rectangular selection. |
table.update.split(options?) | TableTargetOptions | Split one spanning cell. |
table.update.setCellBackground(options) | { at?, color: string | null } | Set or clear backgrounds on exact selected cells. |
table.update.setBorders(options) | { at?, border, value: TableCellBorder | null } | Replace or clear border formatting. |
table.update.toggleBorders(options) | { at?, border } | Toggle a border predicate uniformly. |
table.update.resize(options) | TableResizeOptions | Commit { at?, resize }. |
table.update.setColumnWidth(options) | TableColumnWidthOptions | Set { at?, colIndex, width }. |
table.update.setRowHeight(options) | TableRowHeightOptions | Set { at?, rowIndex, height }. |
TableCreateOptions contains rows, columns, and header. Counts must be positive safe integers. header: true makes the first row a header row. In TableInsertPlacement, choose either at for exact insertion or after for a live block target, never both. Other block insertion options, including select and replaceEmpty, remain available. Without explicit placement, insertion follows the containing table or block.
TableAxisInsertOptions contains at, before, header, and select. For a spanning cell, insertion uses its logical leading or trailing boundary. Table targets prepend or append. Row and column indexes for sizing are zero-based; widths and heights must be positive and finite.
Background and border updates accept TableTargetOptions.at, including exact nonrectangular cell selections. setBorders.border is 'top', 'right', 'bottom', 'left', 'all', or 'outer'; toggleBorders.border accepts the four sides, 'none', or 'outer'. TableCellBorder contains optional color, style, and non-negative finite width.
Sizing updates accept at?: TableNodeTarget. Inside an editor.update callback, call the same update methods directly on tx.plugin(TablePlugin).
TablePlugin paints structural cell selections on the rendered cell hosts. A custom TableCellPlugin component must attach props.attributes, including its ref, to exactly one canonical <td> or <th> element.
Import from platejs/table/react and call useTableResize({ element, tableRef, onResize, onResizeEnd }). The returned function accepts a pointer event and TableResizeHandle. Bottom handles supply rowIndex; the hook measures the starting height.
Render onResize previews locally and clear overrides in onResizeEnd. Primary pointer release commits once. Cancellation, window blur, read-only changes, table replacement, and unmount discard the gesture.
'use client';
import {
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
CombineIcon,
EraserIcon,
Grid2X2Icon,
GripVertical,
PaintBucketIcon,
SquareSplitHorizontalIcon,
Trash2Icon,
XIcon,
} from 'lucide-react';
import { PathApi } from 'platejs';
import { useDraggable, useDropLine } from 'platejs/dnd/react';
import {
type EditorElementProps,
EditorElement,
useComposedRef,
useEditor,
useEditorReadOnly,
useEditorSelector,
useElement,
useElementSelected,
useFocusedLast,
useElementSelector,
usePath,
} from 'platejs/react';
import {
TableCellPlugin,
TablePlugin,
TableRowPlugin,
useTableResize,
} from 'platejs/table/react';
import * as React from 'react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuTrigger,
} from '@/components/editor/dropdown-menu';
import {
FloatingPopover,
FloatingPopoverAnchor,
FloatingPopoverContent,
} from '@/components/editor/floating-popover';
import {
Toolbar,
ToolbarButton,
ToolbarGroup,
ToolbarMenuGroup,
} from '@/components/editor/toolbar';
import {
ColorDropdownMenuItems,
DEFAULT_COLORS,
} from './font-color-toolbar-button';
type TableResizeDirection = 'bottom' | 'left' | 'right';
type TableResizeStartOptions = {
colIndex: number;
direction: TableResizeDirection;
handleKey: string;
rowIndex: number;
};
type TableResizeContextValue = {
hasMultiRowSelection: boolean;
rowSizeOverrides: Map<number, number>;
clearResizePreview: (handleKey: string) => void;
showResizePreview: (
event: React.PointerEvent<HTMLDivElement>,
options: TableResizeStartOptions
) => void;
startResize: (
event: React.PointerEvent<HTMLDivElement>,
options: TableResizeStartOptions
) => void;
};
const TABLE_CONTROL_COLUMN_WIDTH = 8;
const TABLE_DEFERRED_COLUMN_RESIZE_CELL_COUNT = 1200;
const TABLE_MULTI_SELECTION_TOOLBAR_DELAY_MS = 150;
const TableResizeContext = React.createContext<TableResizeContextValue | null>(
null
);
function useTableResizeContext() {
const context = React.useContext(TableResizeContext);
if (!context) {
throw new Error('TableResizeContext is missing');
}
return context;
}
const paintIndicator = (
ref: React.RefObject<HTMLDivElement | null>,
offset: number | null
) => {
const indicator = ref.current;
if (!indicator) return;
indicator.style.display = offset === null ? 'none' : 'block';
if (offset === null) indicator.style.removeProperty('left');
else indicator.style.left = `${offset}px`;
};
export function TableElement(props: EditorElementProps<typeof TablePlugin>) {
const { children } = props;
const editor = useEditor();
const { api } = useEditor().plugin(TablePlugin);
const readOnly = useEditorReadOnly();
const hasControls = !readOnly;
const controlColumnWidth = hasControls ? TABLE_CONTROL_COLUMN_WIDTH : 0;
const dragIndicatorRef = React.useRef<HTMLDivElement>(null);
const hoverIndicatorRef = React.useRef<HTMLDivElement>(null);
const tableRef = React.useRef<HTMLTableElement>(null);
const [colSizeOverrides, setColSizeOverrides] = React.useState(
new Map<number, number>()
);
const [rowSizeOverrides, setRowHeightOverrides] = React.useState(
new Map<number, number>()
);
const [marginLeftOverride, overrideMarginLeft] = React.useState<
number | null
>(null);
const overrideRowSize = React.useCallback(
(index: number, size: number | null) => {
setRowHeightOverrides((overrides) => {
const next = new Map(overrides);
if (size === null) next.delete(index);
else next.set(index, size);
return next;
});
},
[setRowHeightOverrides]
);
const marginLeft = marginLeftOverride ?? props.element.marginLeft ?? 0;
const baseColSizes = api.columnWidths(props.element);
const columnWidths = baseColSizes.map(
(width, index) => colSizeOverrides.get(index) ?? width
);
const deferColumnResize =
(columnWidths?.length ?? 0) * (props.element.children?.length ?? 0) >
TABLE_DEFERRED_COLUMN_RESIZE_CELL_COUNT;
const wrapperRef = React.useRef<HTMLDivElement>(null);
const tableNodeKey = props.editor.key(props.element);
const hasExpandedCellSelection = useEditorSelector((innerEditor) => {
const view = innerEditor.plugin(TablePlugin).read.selection();
return Boolean(view?.tableKey === tableNodeKey && view.cells.length > 1);
});
const hasMultiRowSelection = useEditorSelector((innerEditor) => {
const view = innerEditor.plugin(TablePlugin).read.selection();
return Boolean(
view?.rectangular &&
view.tableKey === tableNodeKey &&
view.cells.length > 1 &&
view.bounds.maxRow > view.bounds.minRow
);
});
const activeHandleKeyRef = React.useRef<string | null>(null);
const activeRowElementRef = React.useRef<HTMLTableRowElement | null>(null);
const previewHandleKeyRef = React.useRef<string | null>(null);
const clearResize = () => {
activeHandleKeyRef.current = null;
previewHandleKeyRef.current = null;
if (activeRowElementRef.current) {
delete activeRowElementRef.current.dataset.tableResizing;
activeRowElementRef.current = null;
}
paintIndicator(dragIndicatorRef, null);
paintIndicator(hoverIndicatorRef, null);
setColSizeOverrides(new Map());
setRowHeightOverrides(new Map());
overrideMarginLeft(null);
};
const beginResize = useTableResize({
element: props.element,
tableRef,
onResizeEnd: clearResize,
onResize: (resize) => {
if (resize.edge === 'bottom') {
overrideRowSize(resize.rowIndex, resize.height);
return;
}
const first = resize.columns[0];
const offset =
resize.edge === 'left'
? controlColumnWidth + resize.marginLeft - marginLeft
: controlColumnWidth +
baseColSizes
.slice(0, first.colIndex)
.reduce((total, width) => total + width, 0) +
first.width;
paintIndicator(
deferColumnResize ? dragIndicatorRef : hoverIndicatorRef,
offset
);
if (deferColumnResize) return;
setColSizeOverrides(
new Map(resize.columns.map(({ colIndex, width }) => [colIndex, width]))
);
if (resize.edge === 'left') {
overrideMarginLeft(resize.marginLeft);
}
},
});
const showResizePreview = React.useCallback(
(
event: React.PointerEvent<HTMLDivElement>,
{ direction, handleKey }: TableResizeStartOptions
) => {
if (
activeHandleKeyRef.current ||
event.buttons !== 0 ||
direction === 'bottom'
) {
return;
}
const wrapper = wrapperRef.current;
if (!wrapper) return;
previewHandleKeyRef.current = handleKey;
const handleRect = event.currentTarget.getBoundingClientRect();
paintIndicator(
hoverIndicatorRef,
handleRect.left -
wrapper.getBoundingClientRect().left +
handleRect.width / 2
);
},
[]
);
const clearResizePreview = React.useCallback((handleKey: string) => {
if (
activeHandleKeyRef.current ||
previewHandleKeyRef.current !== handleKey
) {
return;
}
previewHandleKeyRef.current = null;
paintIndicator(hoverIndicatorRef, null);
}, []);
const startResize = React.useCallback(
(
event: React.PointerEvent<HTMLDivElement>,
{ colIndex, direction, handleKey, rowIndex }: TableResizeStartOptions
) => {
if (
!beginResize(
event,
direction === 'bottom'
? { edge: 'bottom', rowIndex }
: direction === 'left'
? { edge: 'left' }
: { edge: 'right', colIndex }
)
) {
return;
}
activeHandleKeyRef.current = handleKey;
previewHandleKeyRef.current = null;
const table = tableRef.current;
const row = table?.rows.item(rowIndex);
activeRowElementRef.current = row ?? null;
if (row) row.dataset.tableResizing = 'true';
if (direction === 'bottom' || !table) return;
if (!deferColumnResize) {
setRowHeightOverrides(
new Map(
Array.from(table.rows, (entry, index) => [
index,
entry.getBoundingClientRect().height,
])
)
);
}
paintIndicator(hoverIndicatorRef, null);
paintIndicator(
deferColumnResize ? dragIndicatorRef : hoverIndicatorRef,
controlColumnWidth +
(direction === 'left'
? 0
: baseColSizes
.slice(0, colIndex + 1)
.reduce((total, width) => total + width, 0))
);
},
[
baseColSizes,
beginResize,
controlColumnWidth,
deferColumnResize,
setRowHeightOverrides,
]
);
const tableResizeContext = React.useMemo(
() => ({
clearResizePreview,
hasMultiRowSelection,
rowSizeOverrides,
showResizePreview,
startResize,
}),
[
clearResizePreview,
hasMultiRowSelection,
rowSizeOverrides,
showResizePreview,
startResize,
]
);
const resolvedColSizes = columnWidths;
const tableStyle = React.useMemo(
() => ({
width: `${
resolvedColSizes.reduce((total, colSize) => total + colSize, 0) +
controlColumnWidth
}px`,
}),
[controlColumnWidth, resolvedColSizes]
);
const content = (
<EditorElement
{...props}
attributes={{
...props.attributes,
'data-node-selection-highlight': 'self',
}}
className={cn(
'overflow-x-auto py-5',
hasControls && '-ml-2 *:data-[slot=node-selection-highlight]:left-2'
)}
style={{ paddingLeft: marginLeft }}
>
<TableResizeContext value={tableResizeContext}>
<div
ref={wrapperRef}
className="relative w-fit [&:active:not(:has([data-table-resize-handle]:active))_[data-table-resize-handle]]:cursor-text"
>
<div
ref={dragIndicatorRef}
className="pointer-events-none absolute inset-y-0 z-36 hidden w-[3px] -translate-x-[1.5px] bg-ring/70"
contentEditable={false}
/>
<div
ref={hoverIndicatorRef}
className="pointer-events-none absolute inset-y-0 z-35 hidden w-[3px] -translate-x-[1.5px] bg-ring/80"
contentEditable={false}
/>
{/* oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- [P0 behavior-boundary] A new table interaction collapses the prior expanded cell selection. */}
<table
ref={tableRef}
className={cn(
'mr-0 ml-px table h-px table-fixed border-collapse',
hasExpandedCellSelection && '[&_*::selection]:bg-transparent'
)}
style={tableStyle}
onMouseDown={() => {
if (
(editor.plugin(TablePlugin).read.selection()?.cells.length ??
0) > 1
) {
editor.update.selection.collapse();
}
}}
>
{resolvedColSizes.length > 0 && (
<colgroup>
{hasControls && (
<col
style={{
maxWidth: TABLE_CONTROL_COLUMN_WIDTH,
minWidth: TABLE_CONTROL_COLUMN_WIDTH,
width: TABLE_CONTROL_COLUMN_WIDTH,
}}
/>
)}
{resolvedColSizes.map((colSize, index) => (
<col
key={index}
style={{
maxWidth: colSize,
minWidth: colSize,
width: colSize,
}}
/>
))}
</colgroup>
)}
<tbody className="min-w-full">{children}</tbody>
</table>
</div>
</TableResizeContext>
</EditorElement>
);
if (readOnly) {
return content;
}
return <TableFloatingToolbar>{content}</TableFloatingToolbar>;
}
function TableFloatingToolbar({
children,
...props
}: React.ComponentProps<typeof FloatingPopoverContent>) {
const selectedCellCount = useEditorSelector(
(editor) => editor.plugin(TablePlugin).read.selection()?.cells.length ?? 0
);
const selected = useElementSelected();
const collapsedInside = useEditorSelector(
(editor) => selected && editor.read.selection.isCollapsed()
);
const isFocusedLast = useFocusedLast();
const isCollapsedToolbarOpen = isFocusedLast && collapsedInside;
const isExpandedSelectionPending =
isFocusedLast && !collapsedInside && selectedCellCount > 1;
const isToolbarOpen = isCollapsedToolbarOpen || isExpandedSelectionPending;
return (
<FloatingPopover open={isToolbarOpen} modal={false}>
<FloatingPopoverAnchor element={children as React.ReactElement} />
{isCollapsedToolbarOpen && (
<TableFloatingToolbarContent {...props} collapsedInside />
)}
{isExpandedSelectionPending && (
<DelayedExpandedSelectionTableFloatingToolbarContent {...props} />
)}
</FloatingPopover>
);
}
function DelayedExpandedSelectionTableFloatingToolbarContent(
props: React.ComponentProps<typeof FloatingPopoverContent>
) {
const [isReady, setIsReady] = React.useState(false);
React.useEffect(() => {
const timeoutId = window.setTimeout(() => {
setIsReady(true);
}, TABLE_MULTI_SELECTION_TOOLBAR_DELAY_MS);
return () => {
window.clearTimeout(timeoutId);
};
}, []);
if (!isReady) return null;
return <TableFloatingToolbarContent {...props} />;
}
function TableFloatingToolbarContent({
collapsedInside = false,
...props
}: React.ComponentProps<typeof FloatingPopoverContent> & {
collapsedInside?: boolean;
}) {
const editor = useEditor();
const element = useElement(TablePlugin);
const canMergeSelection = useEditorSelector((innerEditor) =>
innerEditor.plugin(TablePlugin).read.canMerge()
);
const canSplitSelection = useEditorSelector((innerEditor) =>
innerEditor.plugin(TablePlugin).read.canSplit()
);
const canMerge = !collapsedInside && canMergeSelection;
const canSplit = canSplitSelection;
if (!collapsedInside && !canMerge && !canSplit) return null;
return (
<FloatingPopoverContent
className="w-auto border-0 bg-transparent p-0 shadow-none ring-0"
onInitialFocus={(e) => {
e.preventDefault();
}}
contentEditable={false}
{...props}
>
<Toolbar
className="scrollbar-hide flex w-auto max-w-[80vw] flex-row overflow-x-auto rounded-md border bg-popover p-1 shadow-md print:hidden"
contentEditable={false}
>
<ToolbarGroup>
<ColorDropdownMenu tooltip="Background color">
<PaintBucketIcon />
</ColorDropdownMenu>
{canMerge && (
<ToolbarButton
aria-label="Merge cells"
onClick={() => {
editor.plugin(TablePlugin).update.merge();
}}
tooltip="Merge cells"
>
<CombineIcon />
</ToolbarButton>
)}
{canSplit && (
<ToolbarButton
aria-label="Split cell"
onClick={() => {
editor.plugin(TablePlugin).update.split();
}}
tooltip="Split cell"
>
<SquareSplitHorizontalIcon />
</ToolbarButton>
)}
<DropdownMenu modal={false}>
<DropdownMenuTrigger>
<ToolbarButton aria-label="Cell borders" tooltip="Cell borders">
<Grid2X2Icon />
</ToolbarButton>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<TableBordersDropdownMenuContent />
</DropdownMenuPortal>
</DropdownMenu>
{collapsedInside && (
<ToolbarGroup>
<ToolbarButton
aria-label="Delete table"
onClick={() => {
editor.update.nodes.remove({ at: element });
editor.api.dom.focus();
}}
tooltip="Delete table"
>
<Trash2Icon />
</ToolbarButton>
</ToolbarGroup>
)}
</ToolbarGroup>
{collapsedInside && (
<ToolbarGroup>
<ToolbarButton
aria-label="Insert row before"
onClick={() => {
editor.plugin(TablePlugin).update.insertRow({ before: true });
}}
tooltip="Insert row before"
>
<ArrowUp />
</ToolbarButton>
<ToolbarButton
aria-label="Insert row after"
onClick={() => {
editor.plugin(TablePlugin).update.insertRow();
}}
tooltip="Insert row after"
>
<ArrowDown />
</ToolbarButton>
<ToolbarButton
aria-label="Delete row"
onClick={() => {
editor.plugin(TablePlugin).update.removeRow();
}}
tooltip="Delete row"
>
<XIcon />
</ToolbarButton>
</ToolbarGroup>
)}
{collapsedInside && (
<ToolbarGroup>
<ToolbarButton
aria-label="Insert column before"
onClick={() => {
editor
.plugin(TablePlugin)
.update.insertColumn({ before: true });
}}
tooltip="Insert column before"
>
<ArrowLeft />
</ToolbarButton>
<ToolbarButton
aria-label="Insert column after"
onClick={() => {
editor.plugin(TablePlugin).update.insertColumn();
}}
tooltip="Insert column after"
>
<ArrowRight />
</ToolbarButton>
<ToolbarButton
aria-label="Delete column"
onClick={() => {
editor.plugin(TablePlugin).update.removeColumn();
}}
tooltip="Delete column"
>
<XIcon />
</ToolbarButton>
</ToolbarGroup>
)}
</Toolbar>
</FloatingPopoverContent>
);
}
function TableBordersDropdownMenuContent(
props: React.ComponentProps<typeof DropdownMenuContent>
) {
const editor = useEditor();
const borderStates = useEditorSelector((innerEditor) =>
innerEditor.plugin(TablePlugin).read.borders()
);
if (!borderStates) return null;
return (
<DropdownMenuContent
className="min-w-[220px]"
onFinalFocus={(e) => {
e.preventDefault();
editor.api.dom.focus();
}}
align="start"
side="right"
sideOffset={0}
{...props}
>
<DropdownMenuGroup>
<DropdownMenuCheckboxItem
checked={borderStates.top === true}
onCheckedChange={() => {
editor.plugin(TablePlugin).update.toggleBorders({ border: 'top' });
}}
>
<BorderIcon side="top" />
<div>Top Border</div>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={borderStates.right === true}
onCheckedChange={() => {
editor
.plugin(TablePlugin)
.update.toggleBorders({ border: 'right' });
}}
>
<BorderIcon side="right" />
<div>Right Border</div>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={borderStates.bottom === true}
onCheckedChange={() => {
editor
.plugin(TablePlugin)
.update.toggleBorders({ border: 'bottom' });
}}
>
<BorderIcon side="bottom" />
<div>Bottom Border</div>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={borderStates.left === true}
onCheckedChange={() => {
editor.plugin(TablePlugin).update.toggleBorders({ border: 'left' });
}}
>
<BorderIcon side="left" />
<div>Left Border</div>
</DropdownMenuCheckboxItem>
</DropdownMenuGroup>
<DropdownMenuGroup>
<DropdownMenuCheckboxItem
checked={borderStates.none === true}
onCheckedChange={() => {
editor.plugin(TablePlugin).update.toggleBorders({ border: 'none' });
}}
>
<BorderIcon side="none" />
<div>No Border</div>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={borderStates.outer === true}
onCheckedChange={() => {
editor
.plugin(TablePlugin)
.update.toggleBorders({ border: 'outer' });
}}
>
<BorderIcon side="outer" />
<div>Outside Borders</div>
</DropdownMenuCheckboxItem>
</DropdownMenuGroup>
</DropdownMenuContent>
);
}
function ColorDropdownMenu({
children,
tooltip,
}: {
children: React.ReactNode;
tooltip: string;
}) {
const [open, setOpen] = React.useState(false);
const editor = useEditor();
const onUpdateColor = React.useCallback(
(color: string) => {
setOpen(false);
editor.plugin(TablePlugin).update.setCellBackground({ color });
},
[editor]
);
const onClearColor = React.useCallback(() => {
setOpen(false);
editor.plugin(TablePlugin).update.setCellBackground({ color: null });
}, [editor]);
return (
<DropdownMenu open={open} onOpenChange={setOpen} modal={false}>
<DropdownMenuTrigger>
<ToolbarButton aria-label={tooltip} tooltip={tooltip}>
{children}
</ToolbarButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<ToolbarMenuGroup label="Colors">
<ColorDropdownMenuItems
className="px-2"
colors={DEFAULT_COLORS}
updateColor={onUpdateColor}
/>
</ToolbarMenuGroup>
<DropdownMenuGroup>
<DropdownMenuItem className="p-2" onClick={onClearColor}>
<EraserIcon />
<span>Clear</span>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
export function TableRowElement({
children,
...props
}: EditorElementProps<typeof TableRowPlugin>) {
const { element } = props;
const readOnly = useEditorReadOnly();
const rowIndex = usePath((path) => path.at(-1));
if (rowIndex === undefined) {
throw new Error('Table row path must include an index.');
}
const rowSize = useElementSelector(TableRowPlugin, (node) => node.height);
const { hasMultiRowSelection, rowSizeOverrides } = useTableResizeContext();
const rowMinHeight = rowSizeOverrides.get(rowIndex) ?? rowSize;
const hasControls = !readOnly;
const { isDragging, nodeRef, previewRef, handleRef } = useDraggable({
element,
type: element.type,
canDropNode: ({ dragEntry, dropEntry, editor, sourceEditor }) =>
sourceEditor === editor &&
PathApi.equals(
PathApi.parent(dragEntry[1]),
PathApi.parent(dropEntry[1])
),
onDropHandler: (editor, { dragItem }) => {
if (!('key' in dragItem)) return;
const key = Array.isArray(dragItem.key) ? dragItem.key[0] : dragItem.key;
if (key) {
const path = editor.read.nodes.path(key);
if (!path) return;
const range = editor.read.ranges.get(path);
if (!range) return;
editor.update.selection.set(range);
editor.api.dom.focus();
}
},
});
return (
<EditorElement
{...props}
ref={useComposedRef(props.ref, previewRef, nodeRef)}
as="tr"
className={cn(
'group/row hover:[&>td>.editor-row-drag-handle]:opacity-100 data-[table-resizing=true]:[&>td>.editor-row-drag-handle]:opacity-0',
isDragging && 'opacity-50'
)}
style={
{
'--tableRowMinHeight': rowMinHeight ? `${rowMinHeight}px` : undefined,
} as React.CSSProperties
}
>
{hasControls && (
<td
className="w-2 max-w-2 min-w-2 p-0 select-none"
contentEditable={false}
>
{!hasMultiRowSelection && (
<>
<RowDragHandle dragRef={handleRef} />
<RowDropLine />
</>
)}
</td>
)}
{children}
</EditorElement>
);
}
function RowDragHandle({ dragRef }: { dragRef: React.Ref<HTMLButtonElement> }) {
const editor = useEditor();
const element = useElement(TableRowPlugin);
return (
<Button
ref={dragRef}
aria-label="Select or move row"
variant="outline"
className={cn(
'-translate-y-1/2 absolute top-1/2 left-0 z-51 h-6 w-4 p-0 focus-visible:ring-0 focus-visible:ring-offset-0',
'cursor-grab active:cursor-grabbing',
'editor-row-drag-handle opacity-0 transition-opacity duration-100'
)}
onClick={() => {
const range = editor.read.ranges.get(element);
if (!range) return;
editor.update.selection.set(range);
editor.api.dom.focus();
}}
>
<GripVertical className="text-muted-foreground" />
</Button>
);
}
function RowDropLine() {
const { dropLine } = useDropLine();
if (!dropLine) return null;
return (
<div
className={cn(
'absolute inset-x-0 left-2 z-50 h-0.5 bg-brand/50',
dropLine === 'top' ? '-top-px' : '-bottom-px'
)}
/>
);
}
export function TableCellElement(
props: EditorElementProps<typeof TableCellPlugin>
) {
const editor = useEditor();
const readOnly = useEditorReadOnly();
const { element } = props;
const isHeader = element.header === true;
const cellInfo = useElementSelector(
TablePlugin,
() => editor.plugin(TablePlugin).read.cell({ at: element }),
{
equalityFn: (next, previous) =>
next?.col === previous?.col &&
next?.row === previous?.row &&
next?.colSpan === previous?.colSpan &&
next?.rowSpan === previous?.rowSpan &&
next?.borders === previous?.borders,
}
);
const indices = cellInfo ?? { col: 0, row: 0 };
const borders = cellInfo?.borders;
const colSpan = cellInfo?.colSpan ?? element.colSpan ?? 1;
const rowSpan = cellInfo?.rowSpan ?? element.rowSpan ?? 1;
const colIndex = indices.col + colSpan - 1;
const rowIndex = indices.row + rowSpan - 1;
return (
<EditorElement
{...props}
as={isHeader ? 'th' : 'td'}
className={cn(
'relative h-full overflow-visible border-none bg-background p-0',
element.backgroundColor ? 'bg-(--cellBackground)' : 'bg-background',
isHeader && 'text-left font-normal *:m-0',
'before:size-full',
'data-[table-cell-selected=true]:before:z-10',
'data-[table-cell-selected=true]:before:bg-brand/5',
"before:absolute before:box-border before:select-none before:content-['']",
borders?.bottom.width && 'before:border-b before:border-b-border',
borders?.right.width && 'before:border-r before:border-r-border',
borders?.left?.width && 'before:border-l before:border-l-border',
borders?.top?.width && 'before:border-t before:border-t-border'
)}
style={
{
'--cellBackground': element.backgroundColor,
} as React.CSSProperties
}
attributes={{
...props.attributes,
colSpan,
rowSpan,
}}
>
<div
className="relative z-20 box-border h-full px-3 py-2"
style={
rowSpan === 1
? { minHeight: 'var(--tableRowMinHeight, 0px)' }
: undefined
}
>
{props.children}
</div>
{!readOnly && (
<TableCellResizeControls colIndex={colIndex} rowIndex={rowIndex} />
)}
</EditorElement>
);
}
function TableCellResizeControls({
colIndex,
rowIndex,
}: {
colIndex: number;
rowIndex: number;
}) {
const { clearResizePreview, showResizePreview, startResize } =
useTableResizeContext();
const rightHandleKey = `right:${rowIndex}:${colIndex}`;
const bottomHandleKey = `bottom:${rowIndex}:${colIndex}`;
const leftHandleKey = `left:${rowIndex}:${colIndex}`;
const isLeftHandle = colIndex === 0;
return (
<div
className="pointer-events-none absolute inset-0 z-30 select-none"
contentEditable={false}
data-editor-root-chrome-ignore="true"
suppressContentEditableWarning={true}
>
<div
className="pointer-events-auto absolute -top-2 -right-1 z-40 h-[calc(100%_+_8px)] w-2 cursor-col-resize touch-none"
data-table-resize-handle="column-end"
onPointerEnter={(event) => {
showResizePreview(event, {
colIndex,
direction: 'right',
handleKey: rightHandleKey,
rowIndex,
});
}}
onPointerMove={(event) => {
showResizePreview(event, {
colIndex,
direction: 'right',
handleKey: rightHandleKey,
rowIndex,
});
}}
onPointerLeave={() => {
clearResizePreview(rightHandleKey);
}}
onPointerDown={(event) => {
startResize(event, {
colIndex,
direction: 'right',
handleKey: rightHandleKey,
rowIndex,
});
}}
/>
<div
className="pointer-events-auto absolute -bottom-1 left-0 z-40 h-2 w-full cursor-row-resize touch-none"
data-table-resize-handle="row-end"
onPointerEnter={(event) => {
showResizePreview(event, {
colIndex,
direction: 'bottom',
handleKey: bottomHandleKey,
rowIndex,
});
}}
onPointerMove={(event) => {
showResizePreview(event, {
colIndex,
direction: 'bottom',
handleKey: bottomHandleKey,
rowIndex,
});
}}
onPointerLeave={() => {
clearResizePreview(bottomHandleKey);
}}
onPointerDown={(event) => {
startResize(event, {
colIndex,
direction: 'bottom',
handleKey: bottomHandleKey,
rowIndex,
});
}}
/>
{isLeftHandle && (
<div
className="pointer-events-auto absolute top-0 -left-1 z-40 h-full w-2 cursor-col-resize touch-none"
data-table-resize-handle="column-start"
onPointerEnter={(event) => {
showResizePreview(event, {
colIndex,
direction: 'left',
handleKey: leftHandleKey,
rowIndex,
});
}}
onPointerMove={(event) => {
showResizePreview(event, {
colIndex,
direction: 'left',
handleKey: leftHandleKey,
rowIndex,
});
}}
onPointerLeave={() => {
clearResizePreview(leftHandleKey);
}}
onPointerDown={(event) => {
startResize(event, {
colIndex,
direction: 'left',
handleKey: leftHandleKey,
rowIndex,
});
}}
/>
)}
</div>
);
}
export const TableKit = [
TablePlugin.configure({
component: TableElement,
initialState: { defaultTableWidth: 600 },
}),
TableRowPlugin.configure({ component: TableRowElement }),
TableCellPlugin.configure({ component: TableCellElement }),
];
function BorderIcon({
side,
}: {
side: 'top' | 'right' | 'bottom' | 'left' | 'none' | 'outer';
}) {
const border = {
top: 'M1 1h12',
right: 'M13 1v12',
bottom: 'M1 13h12',
left: 'M1 1v12',
none: '',
outer: 'M1 1h12v12H1z',
}[side];
return (
<svg
aria-hidden="true"
fill="none"
height="15"
viewBox="0 0 15 15"
width="15"
>
<path
d="M1 1h12v12H1z M7 1v12 M1 7h12"
stroke="currentColor"
strokeDasharray="0 2"
strokeLinecap="round"
/>
<path d={border} stroke="currentColor" strokeWidth="1.5" />
</svg>
);
}import { createEditor } from 'platejs/react';
import { TableCellPlugin, TablePlugin, TableRowPlugin } from 'platejs/table/react';
import { DndKit } from '@/components/editor/dnd';
import {
TableCellElement,
TableElement,
TableRowElement,
} from '@/components/editor/table';
const editor = createEditor({
plugins: [
...DndKit,
TablePlugin.configure({
component: TableElement,
initialState: {
allowCellSpanEditing: true,
defaultTableWidth: 600,
expandOnPaste: true,
minColumnWidth: 48,
},
}),
TableRowPlugin.configure({ component: TableRowElement }),
TableCellPlugin.configure({ component: TableCellElement }),
],
});