import type { ReactNode } from "react"; import type { Editor } from "@tiptap/react"; import type { Node as PMNode } from "@tiptap/pm/model"; import { moveTableColumn, moveTableRow } from "@tiptap/pm/tables"; import { TableMap } from "@tiptap/pm/tables"; import { ArrowDown, ArrowLeft, ArrowLeftRight, ArrowRight, ArrowUp, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Columns3, EqualApproximately, Heading, RotateCcw, Rows3, Trash2, } from "lucide-react"; import { findTableElement } from "../extensions/table/dom"; import { columnCellSelection, rowCellSelection, setCaretInCell } from "../extensions/table/selection"; import { activeCellInfo, resolveOpenMenuState } from "../extensions/table/state"; import type { OpenMenuState } from "../extensions/table/state"; export interface TableMenuAction { key: string; icon: ReactNode; label: string; onSelect: () => void; disabled?: boolean; danger?: boolean; } export type TableMenuSection = TableMenuAction[]; interface BaseResolvedTarget { table: PMNode; tablePos: number; tableKey: string; rowCount: number; colCount: number; } interface ResolvedRowTarget extends BaseResolvedTarget { kind: "row"; index: number; anchorCellPos: number; row: number; col: number; } interface ResolvedColumnTarget extends BaseResolvedTarget { kind: "col"; index: number; anchorCellPos: number; row: number; col: number; } interface ResolvedCornerTarget extends BaseResolvedTarget { kind: "corner"; index: null; } type TableWidthCommandChain = ReturnType & { resetTableColumnWidths: (tablePos: number) => TableWidthCommandChain; distributeTableColumnsEvenly: (tablePos: number) => TableWidthCommandChain; fitTableColumnsToContent: (tablePos: number) => TableWidthCommandChain; }; function withTableWidthCommands(editor: Editor): TableWidthCommandChain { return editor.chain().focus(null, { scrollIntoView: false }) as TableWidthCommandChain; } export function buildRowMenuSections({ editor, openMenu, onDone, }: { editor: Editor; openMenu: OpenMenuState; onDone: (focusTrigger?: boolean) => void; }): TableMenuSection[] { const target = resolveRowTarget(editor, openMenu); if (!target) return []; const run = (fn: (resolved: ResolvedRowTarget) => boolean) => { const current = resolveRowTarget(editor, openMenu); if (current) fn(current); onDone(false); }; return [ [ { key: "row-select", icon: , label: "Select row", onSelect: () => run((resolved) => { return selectRowTarget(editor, resolved); }), }, ], [ { key: "row-insert-above", icon: , label: "Insert row above", onSelect: () => run((resolved) => { focusRowTarget(editor, resolved); return editor.chain().focus(null, { scrollIntoView: false }).addRowBefore().run(); }), }, { key: "row-insert-below", icon: , label: "Insert row below", onSelect: () => run((resolved) => { focusRowTarget(editor, resolved); return editor.chain().focus(null, { scrollIntoView: false }).addRowAfter().run(); }), }, ], [ { key: "row-move-up", icon: , label: "Move row up", disabled: target.index === 0, onSelect: () => run((resolved) => { if (resolved.index <= 0) return false; moveTableRow({ from: resolved.index, to: resolved.index - 1, pos: resolved.tablePos + 1, select: true })( editor.state, editor.view.dispatch.bind(editor.view), ); return true; }), }, { key: "row-move-down", icon: , label: "Move row down", disabled: target.index >= target.rowCount - 1, onSelect: () => run((resolved) => { if (resolved.index >= resolved.rowCount - 1) return false; moveTableRow({ from: resolved.index, to: resolved.index + 1, pos: resolved.tablePos + 1, select: true })( editor.state, editor.view.dispatch.bind(editor.view), ); return true; }), }, ], [ { key: "row-delete", icon: , label: "Delete row", danger: true, disabled: target.rowCount <= 1, onSelect: () => run((resolved) => { focusRowTarget(editor, resolved); return editor.chain().focus(null, { scrollIntoView: false }).deleteRow().run(); }), }, ], ]; } export function buildColumnMenuSections({ editor, openMenu, onDone, }: { editor: Editor; openMenu: OpenMenuState; onDone: (focusTrigger?: boolean) => void; }): TableMenuSection[] { const target = resolveColumnTarget(editor, openMenu); if (!target) return []; const run = (fn: (resolved: ResolvedColumnTarget) => boolean) => { const current = resolveColumnTarget(editor, openMenu); if (current) fn(current); onDone(false); }; return [ [ { key: "col-select", icon: , label: "Select column", onSelect: () => run((resolved) => { return selectColumnTarget(editor, resolved); }), }, ], [ { key: "col-insert-left", icon: , label: "Insert column left", onSelect: () => run((resolved) => { if (!focusColumnTarget(editor, resolved)) return false; return editor.chain().focus(null, { scrollIntoView: false }).addColumnBefore().run(); }), }, { key: "col-insert-right", icon: , label: "Insert column right", onSelect: () => run((resolved) => { if (!focusColumnTarget(editor, resolved)) return false; return editor.chain().focus(null, { scrollIntoView: false }).addColumnAfter().run(); }), }, ], [ { key: "col-move-left", icon: , label: "Move column left", disabled: target.index === 0, onSelect: () => run((resolved) => { if (resolved.index <= 0) return false; moveTableColumn({ from: resolved.index, to: resolved.index - 1, pos: resolved.tablePos + 1, select: true })( editor.state, editor.view.dispatch.bind(editor.view), ); return true; }), }, { key: "col-move-right", icon: , label: "Move column right", disabled: target.index >= target.colCount - 1, onSelect: () => run((resolved) => { if (resolved.index >= resolved.colCount - 1) return false; moveTableColumn({ from: resolved.index, to: resolved.index + 1, pos: resolved.tablePos + 1, select: true })( editor.state, editor.view.dispatch.bind(editor.view), ); return true; }), }, ], [ { key: "col-delete", icon: , label: "Delete column", danger: true, disabled: target.colCount <= 1, onSelect: () => run((resolved) => { const active = activeCellInfo(editor.state); const activeRow = active?.tablePos === resolved.tablePos ? active.row : resolved.row; if (!focusColumnTarget(editor, resolved)) return false; const didDelete = editor.chain().focus(null, { scrollIntoView: false }).deleteColumn().run(); if (!didDelete) return false; const nextTable = editor.state.doc.nodeAt(resolved.tablePos); if (!nextTable || nextTable.type.spec.tableRole !== "table") return true; const nextMap = TableMap.get(nextTable); const targetRow = Math.min(activeRow, nextMap.height - 1); const targetCol = Math.min(resolved.col, nextMap.width - 1); setCaretInCell(editor.view, nextTable, resolved.tablePos, targetRow, targetCol); if (targetCol === nextMap.width - 1) { requestAnimationFrame(() => { const wrapper = findTableElement(editor.view, resolved.tablePos)?.closest(".tableWrapper"); if (!wrapper) return; wrapper.scrollLeft = wrapper.scrollWidth; }); } return true; }), }, ], ]; } export function buildTableMenuSections({ editor, openMenu, canResetWidths, onDone, }: { editor: Editor; openMenu: OpenMenuState; canResetWidths: boolean; onDone: (focusTrigger?: boolean) => void; }): TableMenuSection[] { const target = resolveCornerTarget(editor, openMenu); if (!target) return []; const run = (fn: (resolved: ResolvedCornerTarget) => boolean) => { const current = resolveCornerTarget(editor, openMenu); if (current) fn(current); onDone(false); }; return [ [ { key: "table-toggle-header-row", icon: , label: "Toggle header row", onSelect: () => run((resolved) => { focusTableTarget(editor, resolved); return editor.chain().focus(null, { scrollIntoView: false }).toggleHeaderRow().run(); }), }, { key: "table-toggle-header-column", icon: , label: "Toggle header column", onSelect: () => run((resolved) => { focusTableTarget(editor, resolved); return editor.chain().focus(null, { scrollIntoView: false }).toggleHeaderColumn().run(); }), }, ], [ { key: "table-reset-widths", icon: , label: "Reset column widths", disabled: !canResetWidths, onSelect: () => run((resolved) => withTableWidthCommands(editor).resetTableColumnWidths(resolved.tablePos).run()), }, { key: "table-distribute-widths", icon: , label: "Distribute columns evenly", onSelect: () => run((resolved) => withTableWidthCommands(editor).distributeTableColumnsEvenly(resolved.tablePos).run()), }, { key: "table-fit-widths", icon: , label: "Fit all columns to content", onSelect: () => run((resolved) => withTableWidthCommands(editor).fitTableColumnsToContent(resolved.tablePos).run()), }, ], [ { key: "table-delete", icon: , label: "Delete table", danger: true, onSelect: () => run((resolved) => { focusTableTarget(editor, resolved); return editor.chain().focus(null, { scrollIntoView: false }).deleteTable().run(); }), }, ], ]; } function resolveRowTarget(editor: Editor, openMenu: OpenMenuState): ResolvedRowTarget | null { const resolved = resolveOpenMenuState(editor.state.doc, openMenu); return resolved?.kind === "row" ? (resolved as ResolvedRowTarget) : null; } function resolveColumnTarget(editor: Editor, openMenu: OpenMenuState): ResolvedColumnTarget | null { const resolved = resolveOpenMenuState(editor.state.doc, openMenu); return resolved?.kind === "col" ? (resolved as ResolvedColumnTarget) : null; } function resolveCornerTarget(editor: Editor, openMenu: OpenMenuState): ResolvedCornerTarget | null { const resolved = resolveOpenMenuState(editor.state.doc, openMenu); return resolved?.kind === "corner" ? (resolved as ResolvedCornerTarget) : null; } function focusRowTarget(editor: Editor, target: ResolvedRowTarget) { const selection = rowCellSelection(editor.state.doc, target.tablePos, target.table, target.row); if (selection) { editor.view.dispatch(editor.state.tr.setSelection(selection)); return; } setCaretInCell(editor.view, target.table, target.tablePos, target.row, 0); } function focusColumnTarget(editor: Editor, target: ResolvedColumnTarget): boolean { const selection = columnCellSelection(editor.state.doc, target.tablePos, target.table, target.col); if (!selection) return false; editor.view.dispatch(editor.state.tr.setSelection(selection)); return true; } function selectRowTarget(editor: Editor, target: ResolvedRowTarget): boolean { const selection = rowCellSelection(editor.state.doc, target.tablePos, target.table, target.row); if (!selection) return false; editor.view.dispatch(editor.state.tr.setSelection(selection)); editor.view.focus(); return true; } function selectColumnTarget(editor: Editor, target: ResolvedColumnTarget): boolean { const selection = columnCellSelection(editor.state.doc, target.tablePos, target.table, target.col); if (!selection) return false; editor.view.dispatch(editor.state.tr.setSelection(selection)); editor.view.focus(); return true; } function focusTableTarget(editor: Editor, target: ResolvedCornerTarget) { setCaretInCell(editor.view, target.table, target.tablePos, 0, 0); }