import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react' import type { Editor } from '@tiptap/core' import { TextSelection, type Command } from '@tiptap/pm/state' import { addColumnAfter, addColumnBefore, addRowAfter, addRowBefore, deleteColumn, deleteRow, deleteTable, toggleHeader } from '@tiptap/pm/tables' import { IconCheck, IconColumnInsertLeft, IconColumnInsertRight, IconGripHorizontal, IconGripVertical, IconPlus, IconRowInsertBottom, IconRowInsertTop, IconTableRow, IconTrash } from '@tabler/icons-react' import { cn } from './cn' import { IconButton } from './IconButton' import { FLOATING_SURFACE_CLASSES } from './looks' import { Menu, MenuItem, MenuSeparator } from './Menu' export interface EditorTableControlsProps { /** The editor, with TableKit (`@tiptap/extension-table`) in its extensions. Not editable, or not there yet: no controls. */ editor: Editor | null /** The `EditorContent`. */ children: ReactNode className?: string } /** The cell the pointer is on, and the table and row it is in. */ interface Hovered { table: HTMLTableElement row: HTMLTableRowElement cell: HTMLTableCellElement } /** Where the controls go, measured from the frame: they depend on the document, not on a size of ours. */ interface Placed { tableBox: { top: number; left: number; width: number; height: number } rowBox: { top: number; height: number } columnBox: { left: number; width: number } } /* Every row runs the table command with the caret in a cell, as the command expects — the hovered cell's, or for a ➕ bar the last row's or the last column's — and leaves the editor focused there, as the block menu does: the person is still in the field, so an editor that saves when they leave it saves this too. */ /** Puts the caret in `cell` and runs `command` there. */ function inCell(editor: Editor, cell: HTMLTableCellElement, command: Command): boolean { const { view } = editor if (!view.dom.contains(cell)) return false const at = view.posAtDOM(cell, 0) view.dispatch(view.state.tr.setSelection(TextSelection.near(view.state.doc.resolve(at)))) const done = command(view.state, view.dispatch, view) editor.commands.focus() return done } /** A row of header cells only: the table's header row. */ function isHeaderRow(row: HTMLTableRowElement): boolean { return row.cells.length > 0 && Array.from(row.cells).every((c) => c.tagName === 'TH') } /** * The cell's row, or the whole table when it is its only one: a table holds at * least one row (prosemirror-tables refuses to delete it). Deleting the header * row makes the next row the header, so the table still opens with one. */ export function deleteTableRow(editor: Editor, cell: HTMLTableCellElement): boolean { const row = cell.closest('tr') const table = cell.closest('table') if (!row || !table || table.rows.length <= 1) return inCell(editor, cell, deleteTable) const header = isHeaderRow(row) return inCell(editor, cell, header ? (state, dispatch, view) => deleteRow(state, dispatch) && (!view || toggleHeader('row')(view.state, view.dispatch)) : deleteRow) } /** * The table's first row becomes its header row, or an ordinary row again: * whichever cell of the table `cell` is. A table with no header row has no * text spelling, so the agent keeps it as written (SPEC §13.3, "No spelling"). */ export function toggleTableHeaderRow(editor: Editor, cell: HTMLTableCellElement): boolean { return inCell(editor, cell, toggleHeader('row')) } /** The same for a column: the last one deletes the table. */ export function deleteTableColumn(editor: Editor, cell: HTMLTableCellElement): boolean { const row = cell.closest('tr') return inCell(editor, cell, row && row.cells.length > 1 ? deleteColumn : deleteTable) } /** A row after the last one: the ➕ under the table. */ export function appendTableRow(editor: Editor, table: HTMLTableElement): boolean { const last = table.rows[table.rows.length - 1]?.cells[0] return !!last && inCell(editor, last, addRowAfter) } /** A column after the last one: the ➕ right of the table. */ export function appendTableColumn(editor: Editor, table: HTMLTableElement): boolean { const first = table.rows[0] const last = first?.cells[first.cells.length - 1] return !!last && inCell(editor, last, addColumnAfter) } function place(frame: HTMLElement, { table, row, cell }: Hovered): Placed { const at = frame.getBoundingClientRect() const t = table.getBoundingClientRect() const r = row.getBoundingClientRect() const c = cell.getBoundingClientRect() return { tableBox: { top: t.top - at.top, left: t.left - at.left, width: t.width, height: t.height }, rowBox: { top: r.top - at.top, height: r.height }, columnBox: { left: c.left - at.left, width: c.width }, } } const ICON = { size: 16, stroke: 1.5, className: 'text-text-secondary' } as const /** * A ➕ press keeps the focus where it is, as the selection toolbar's buttons do: * the person never left the field, so an editor that saves when they leave it * does not save half-way, and History gets one line for the edit, not one per * press. (A handle's menu takes the focus, as every menu does.) */ const keepFocus = (e: MouseEvent) => e.preventDefault() /** What a handle and a ➕ bar sit in: the elevated surface's border and fill, opaque over the table's lines, with no shadow at this size. */ const HANDLE_BOX = cn(FLOATING_SURFACE_CLASSES, 'shadow-none') /** * A table's own controls in an editor, as in Notion: point at a cell and a * handle sits on its row's left edge and on its column's top edge; press one * for Insert above and below (left and right), Delete, and Delete table. The * first row's menu also turns the header row on and off (Header row, ticked * while it is on); with it off, the first row is an ordinary row. On * the last row a ➕ bar under the table adds a row, and on the last column one * right of it adds a column. Tab in * the last cell adds a row too — that is TableKit's own. * * Deleting a table's only row or only column deletes the table, which cannot * be left with none. * * Built on the editor library's own table commands; the controls are ours. * Wrap the `EditorContent` in it, inside an `EditorBlockHandle` if there is * one: the block handle stays the table's, in the gutter. */ export function EditorTableControls({ editor, children, className }: EditorTableControlsProps) { const frame = useRef(null) // The cell pointed at and where its controls go, as one: the boxes mean nothing without their elements. const [hovered, setHovered] = useState<(Hovered & Placed) | null>(null) // A menu that is up keeps its row and column, wherever the pointer goes. const [menu, setMenu] = useState<'row' | 'column' | null>(null) const editable = !!editor && editor.isEditable const follow = useCallback( (target: EventTarget | null) => { if (!editor || !frame.current || !(target instanceof Element)) return // On a control of ours: keep what it was drawn for. if (target.closest('[data-table-control]')) return const cell = target.closest('td, th') const row = cell?.closest('tr') const table = row?.closest('table') if (!cell || !row || !table || !editor.view.dom.contains(table)) { setHovered(null) return } // Measured on every move, the same cell too: a panel resized or a cell's text wrapping moves the edges under a still pointer. const next = { table, row, cell, ...place(frame.current, { table, row, cell }) } setHovered((was) => (was && was.cell === cell && JSON.stringify([was.tableBox, was.rowBox, was.columnBox]) === JSON.stringify([next.tableBox, next.rowBox, next.columnBox]) ? was : next)) }, [editor], ) /* Any change to the document moves everything under the controls, so they go until the pointer moves again: a row added or deleted here, and someone else's edit loaded into the field — which an app does without an `update` (`emitUpdate: false`), and which redraws the cells the controls point at. */ useEffect(() => { if (!editor) return const changed = ({ transaction }: { transaction: { docChanged: boolean } }) => { if (transaction.docChanged && !menu) setHovered(null) } editor.on('transaction', changed) return () => { editor.off('transaction', changed) } }, [editor, menu]) const shown = editable ? hovered : null const run = (act: () => boolean) => () => { if (editor) act() setMenu(null) setHovered(null) } return (
{ if (!menu) follow(e.target) }} onMouseLeave={() => { if (!menu) setHovered(null) }} > {children} {shown && editor && ( <> {/* The row's handle, centred on the table's left edge: the box is ours, so the table's line does not show through it. */}
setMenu(open ? 'row' : null)} trigger={ } > {/* The header row is switched where it lives: on the first row's menu, ticked while that row is the header. */} {shown.row.rowIndex === 0 && ( <> } trailing={isHeaderRow(shown.row) ? : undefined} onClick={run(() => toggleTableHeaderRow(editor, shown.cell))} /> )} {/* Nothing goes above a header row: it is the table's first row, and a header anywhere else is no header (RichText, the agent's text). */} {!isHeaderRow(shown.row) && } onClick={run(() => inCell(editor, shown.cell, addRowBefore))} />} } onClick={run(() => inCell(editor, shown.cell, addRowAfter))} /> } onClick={run(() => deleteTableRow(editor, shown.cell))} /> } onClick={run(() => inCell(editor, shown.cell, deleteTable))} />
{/* The column's handle, centred on the table's top edge. */}
setMenu(open ? 'column' : null)} trigger={ } > } onClick={run(() => inCell(editor, shown.cell, addColumnBefore))} /> } onClick={run(() => inCell(editor, shown.cell, addColumnAfter))} /> } onClick={run(() => deleteTableColumn(editor, shown.cell))} /> } onClick={run(() => inCell(editor, shown.cell, deleteTable))} />
{/* The ➕ bars, flush with the table so the pointer reaches them from a cell without leaving it — the row's from the last row, the column's from the last column. Anywhere else the one under the table would lie over the top of the next block for nothing. */} {shown.row === shown.table.rows[shown.table.rows.length - 1] && (
appendTableRow(editor, shown.table))}>
)} {shown.cell === shown.row.cells[shown.row.cells.length - 1] && (
appendTableColumn(editor, shown.table))}>
)} )}
) }