Tab used to fall through to the browser's focus navigation everywhere. Inside tables it now moves cell-wise (Shift-Tab backwards) and appends a new row from the last cell, Word-style. Outside tables every branch returns false, so Tab keeps leaving the editor. Capturing Tab inside tables needs a documented way out (WCAG 2.1.2): Escape places the cursor after the table -- unlike the arrow keys, which reach the gap cursor (#335) only from the table's edge cells, it works from every cell, including from a cell selection. When no textblock follows the table it falls back to the gap cursor position. The mechanism is announced to assistive tech via an aria-describedby hint on the editor surface (visually hidden, de+en). e2e: cell round trip per Tab/Shift-Tab with typed markers, row append from the last cell, and the full keyboard-only exit (Escape, then Tab leaves the editor). The table specs now settle briefly after the insert -- right after it the collab sync can swallow a click's selection update, which had the markers landing in stale selections. Closes #338 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012aoPvnakfBP28nAfijgUY9
199 lines
6.2 KiB
TypeScript
199 lines
6.2 KiB
TypeScript
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<ReturnType> {
|
|
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'),
|
|
});
|