import { Node } from '@tiptap/core'; import { GapCursor } from '@tiptap/pm/gapcursor'; import { Selection } from '@tiptap/pm/state'; import type { Node as PMNode, Schema } from 'prosemirror-model'; import { addColumnAfter, addColumnBefore, addRowAfter, addRowBefore, deleteColumn, deleteRow, deleteTable, goToNextCell, mergeCells, splitCell, tableEditing, toggleHeaderRow, } from 'prosemirror-tables'; import { attributesFromSpec, extendWithTableRole, nodeSpec, passthroughNodeIO, } from '../spec-utils'; declare module '@tiptap/core' { interface Commands { documentTable: { insertTable: (options?: { rows?: number; cols?: number; withHeaderRow?: boolean; }) => ReturnType; addColumnBefore: () => ReturnType; addColumnAfter: () => ReturnType; deleteColumn: () => ReturnType; addRowBefore: () => ReturnType; addRowAfter: () => ReturnType; deleteRow: () => ReturnType; deleteTable: () => ReturnType; mergeCells: () => ReturnType; splitCell: () => ReturnType; goToNextCell: () => ReturnType; goToPreviousCell: () => ReturnType; toggleHeaderRow: () => ReturnType; }; } } /** `type.createAndFill()` picks a default child (an empty paragraph) that * satisfies the cell's `block+` content expression. */ function buildTableRow(schema: Schema, cols: number, header: boolean): PMNode { const cellType = schema.nodes[header ? 'table_header' : 'table_cell']!; const cells = Array.from({ length: cols }, () => cellType.createAndFill()!); return schema.nodes.table_row!.create(null, cells); } function buildTable(schema: Schema, rows: number, cols: number, withHeaderRow: boolean): PMNode { const rowNodes = Array.from({ length: rows }, (_, index) => buildTableRow(schema, cols, withHeaderRow && index === 0), ); return schema.nodes.table!.create(null, rowNodes); } const tableSpec = nodeSpec('table'); export const Table = Node.create({ name: 'table', group: tableSpec.group, content: tableSpec.content, isolating: tableSpec.isolating, ...passthroughNodeIO(tableSpec), ...extendWithTableRole('table', 'table'), addProseMirrorPlugins() { return [tableEditing()]; }, addKeyboardShortcuts() { return { // Word-style navigation (issue #338): Tab moves cell-wise and appends // a new row from the last cell. Outside a table every branch returns // false, so Tab keeps its browser default (focus moves on) and the // editor is no keyboard trap — from inside a table the arrow keys // lead out via the gap cursor (#335), then Tab leaves the editor. Tab: () => { if (this.editor.commands.goToNextCell()) return true; if (!this.editor.can().addRowAfter()) return false; return this.editor.chain().addRowAfter().goToNextCell().run(); }, 'Shift-Tab': () => this.editor.commands.goToPreviousCell(), // The documented exit (aria-describedby hint, #338): the gap cursor is // only reachable per arrow key from the table's edge cells, so Escape // is the exit that works from EVERY cell. Falls back to a gap cursor // when no textblock follows the table (#335 guarantees the position). Escape: () => this.editor.commands.command(({ state, dispatch }) => { const { $head } = state.selection; for (let depth = $head.depth; depth > 0; depth -= 1) { if ($head.node(depth).type.spec.tableRole !== 'table') continue; const $after = state.doc.resolve($head.after(depth)); const selection = Selection.findFrom($after, 1, true) ?? new GapCursor($after); if (dispatch) dispatch(state.tr.setSelection(selection).scrollIntoView()); return true; } return false; }), }; }, addCommands() { return { insertTable: ({ rows = 3, cols = 3, withHeaderRow = true } = {}) => ({ chain, editor }) => chain() .insertContent(buildTable(editor.schema, rows, cols, withHeaderRow).toJSON()) .run(), addColumnBefore: () => ({ state, dispatch }) => addColumnBefore(state, dispatch), addColumnAfter: () => ({ state, dispatch }) => addColumnAfter(state, dispatch), deleteColumn: () => ({ state, dispatch }) => deleteColumn(state, dispatch), addRowBefore: () => ({ state, dispatch }) => addRowBefore(state, dispatch), addRowAfter: () => ({ state, dispatch }) => addRowAfter(state, dispatch), deleteRow: () => ({ state, dispatch }) => deleteRow(state, dispatch), deleteTable: () => ({ state, dispatch }) => deleteTable(state, dispatch), mergeCells: () => ({ state, dispatch }) => mergeCells(state, dispatch), splitCell: () => ({ state, dispatch }) => splitCell(state, dispatch), goToNextCell: () => ({ state, dispatch }) => goToNextCell(1)(state, dispatch), goToPreviousCell: () => ({ state, dispatch }) => goToNextCell(-1)(state, dispatch), toggleHeaderRow: () => ({ state, dispatch }) => toggleHeaderRow(state, dispatch), }; }, }); const tableRowSpec = nodeSpec('table_row'); export const TableRow = Node.create({ name: 'table_row', content: tableRowSpec.content, ...passthroughNodeIO(tableRowSpec), ...extendWithTableRole('table_row', 'row'), }); const tableCellSpec = nodeSpec('table_cell'); export const TableCell = Node.create({ name: 'table_cell', content: tableCellSpec.content, isolating: tableCellSpec.isolating, addAttributes() { return attributesFromSpec(tableCellSpec); }, ...passthroughNodeIO(tableCellSpec), ...extendWithTableRole('table_cell', 'cell'), }); const tableHeaderSpec = nodeSpec('table_header'); export const TableHeader = Node.create({ name: 'table_header', content: tableHeaderSpec.content, isolating: tableHeaderSpec.isolating, addAttributes() { return attributesFromSpec(tableHeaderSpec); }, ...passthroughNodeIO(tableHeaderSpec), ...extendWithTableRole('table_header', 'header_cell'), });