Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
13 kB · 368 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369// src/editor/extensions/table-keybindings.ts/** * CM6 keymap extension providing table-aware Tab/Shift+Tab/ArrowUp/ArrowDown. * * Wired into CM6Editor with `Prec.high(tableKeybindings())` so it beats * editingKeybindings + indentWithTab, while still losing to the * editorSuggestDispatcher's own Prec.high keymap (which is registered first * → wins at same precedence). * * Each handler: * 1. Resolves `pos = getTablePositionAt(state, head)`. Returns false if * not in a table (default behavior runs). * 2. Computes target offset using the inter-pipe walking pattern from * `pushRowDecorations` in `table-grid.ts`. * 3. Dispatches via `view.dispatch({selection, annotations: 'select'})`. * Last-cell-of-last-body-row Tab also dispatches a row insertion with * `'input'` annotation (uses `addRowBelow` from `table-commands.ts`). */import { EditorSelection, Transaction, type Extension } from '@codemirror/state'import { keymap, type EditorView } from '@codemirror/view'import type { SyntaxNode } from '@lezer/common'import { getTablePositionAt, type TablePosition } from './markdown-context'import { addRowBelow } from '../commands/table-commands'import { dispatchTableResult } from '../dispatch-text-transform'
// --------------------------------------------------------------------------// Internal helpers — inter-pipe walking, mirroring pushRowDecorations.// --------------------------------------------------------------------------
interface RowDelimiters { /** Absolute [from, to) ranges of every TableDelimiter in the row. */ delims: { from: number; to: number }[]}
function getRowDelims(row: SyntaxNode): RowDelimiters { const delims: { from: number; to: number }[] = [] let c = row.firstChild while (c) { if (c.name === 'TableDelimiter') delims.push({ from: c.from, to: c.to }) c = c.nextSibling } return { delims }}
function getTableStructure(table: SyntaxNode): { header: SyntaxNode separator: SyntaxNode bodyRows: SyntaxNode[]} | null { let header: SyntaxNode | null = null let separator: SyntaxNode | null = null const bodyRows: SyntaxNode[] = [] let c = table.firstChild while (c) { if (c.name === 'TableHeader') header = c else if (c.name === 'TableDelimiter' && separator === null) separator = c else if (c.name === 'TableRow') bodyRows.push(c) c = c.nextSibling } if (!header || !separator) return null return { header, separator, bodyRows }}
/** cellOffset = 0 of column `col` on a header/body row = delim[col].to. */function cellStartOf(row: SyntaxNode, col: number): number | null { const { delims } = getRowDelims(row) if (col < 0 || col >= delims.length - 1) return null return delims[col].to}
/** End of cell (cellOffset = gapLen) on a header/body row = delim[col + 1].from. */function cellEndOf(row: SyntaxNode, col: number): number | null { const { delims } = getRowDelims(row) if (col < 0 || col >= delims.length - 1) return null return delims[col + 1].from}
// --------------------------------------------------------------------------// Tab// --------------------------------------------------------------------------
export function handleTab(view: EditorView): boolean { const state = view.state const head = state.selection.main.head const pos = getTablePositionAt(state, head) if (!pos) return false
const struct = getTableStructure(pos.table) if (!struct) return false
const colCount = (() => { const { delims } = getRowDelims(struct.header) return Math.max(0, delims.length - 1) })() if (colCount === 0) return false const col = pos.columnIndex < 0 ? 0 : pos.columnIndex
// Case A: not on last column → next column same row. if (col < colCount - 1) { let target: number | null = null if (pos.rowKind === 'header') target = cellStartOf(struct.header, col + 1) else if (pos.rowKind === 'body') target = cellStartOf(struct.bodyRows[pos.rowIndex], col + 1) else if (pos.rowKind === 'separator') target = cellStartOf(struct.bodyRows[0] ?? struct.header, col + 1) if (target == null) return false view.dispatch({ selection: EditorSelection.cursor(target), annotations: Transaction.userEvent.of('select'), }) return true }
// Case B: last column. Move to next row's col 0, or extend table. if (pos.rowKind === 'header') { if (struct.bodyRows.length > 0) { // Header last cell → body[0] col 0. const target = cellStartOf(struct.bodyRows[0], 0) if (target == null) return false view.dispatch({ selection: EditorSelection.cursor(target), annotations: Transaction.userEvent.of('select'), }) return true } // Body-less table: extend with new body row at separator.line.to. const result = addRowBelow(state, pos) if (!result) return false return dispatchTableResult(view, result, false) }
if (pos.rowKind === 'separator') { // Treat as header semantics. if (struct.bodyRows.length > 0) { const target = cellStartOf(struct.bodyRows[0], 0) if (target == null) return false view.dispatch({ selection: EditorSelection.cursor(target), annotations: Transaction.userEvent.of('select'), }) return true } return false }
// body row if (pos.rowIndex < struct.bodyRows.length - 1) { const next = struct.bodyRows[pos.rowIndex + 1] const target = cellStartOf(next, 0) if (target == null) return false view.dispatch({ selection: EditorSelection.cursor(target), annotations: Transaction.userEvent.of('select'), }) return true }
// Last cell of last body row → extend table. const result = addRowBelow(state, pos) if (!result) return false return dispatchTableResult(view, result, false)}
// --------------------------------------------------------------------------// Shift+Tab// --------------------------------------------------------------------------
export function handleShiftTab(view: EditorView): boolean { const state = view.state const head = state.selection.main.head const pos = getTablePositionAt(state, head) if (!pos) return false
const struct = getTableStructure(pos.table) if (!struct) return false
const colCount = (() => { const { delims } = getRowDelims(struct.header) return Math.max(0, delims.length - 1) })() if (colCount === 0) return false const col = pos.columnIndex < 0 ? 0 : pos.columnIndex
// Case A: not on first column → prev column same row, at end-of-cell. if (col > 0) { let target: number | null = null if (pos.rowKind === 'header') target = cellEndOf(struct.header, col - 1) else if (pos.rowKind === 'body') target = cellEndOf(struct.bodyRows[pos.rowIndex], col - 1) else if (pos.rowKind === 'separator') target = cellEndOf(struct.bodyRows[0] ?? struct.header, col - 1) if (target == null) return false view.dispatch({ selection: EditorSelection.cursor(target), annotations: Transaction.userEvent.of('select'), }) return true }
// Case B: first column. if (pos.rowKind === 'header') { // Exit upward: line above header's end, or offset 0 if header is on doc line 1. const headerLine = state.doc.lineAt(struct.header.from) if (headerLine.number === 1) { view.dispatch({ selection: EditorSelection.cursor(0), annotations: Transaction.userEvent.of('select'), }) } else { const lineAbove = state.doc.line(headerLine.number - 1) view.dispatch({ selection: EditorSelection.cursor(lineAbove.to), annotations: Transaction.userEvent.of('select'), }) } return true }
if (pos.rowKind === 'separator' || (pos.rowKind === 'body' && pos.rowIndex === 0)) { // Skip separator: from body[0] col 0, jump to header's last cell end. const target = cellEndOf(struct.header, colCount - 1) if (target == null) return false view.dispatch({ selection: EditorSelection.cursor(target), annotations: Transaction.userEvent.of('select'), }) return true }
// Body row, col 0, rowIndex > 0: jump to previous body row's last cell end. const prev = struct.bodyRows[pos.rowIndex - 1] const target = cellEndOf(prev, colCount - 1) if (target == null) return false view.dispatch({ selection: EditorSelection.cursor(target), annotations: Transaction.userEvent.of('select'), }) return true}
// --------------------------------------------------------------------------// ArrowUp / ArrowDown// --------------------------------------------------------------------------
function handleArrow(view: EditorView, direction: 'up' | 'down'): boolean { const state = view.state const head = state.selection.main.head // When the cursor sits at the very end of a line (head === line.to), // the syntax-tree resolver may bind to the trailing `\n` rather than // the row content, and `getTablePositionAt` returns null. In that // case, probe one position to the left — we're still visually in // the same row line. This is critical for ArrowDown/Up at end-of-row, // which would otherwise fall through to default CM6 vertical motion // and mis-route across the zero-height separator row (#269). let pos = getTablePositionAt(state, head) if (!pos && head > 0) { const line = state.doc.lineAt(head) if (head === line.to) { pos = getTablePositionAt(state, head - 1) } } if (!pos) return false
const struct = getTableStructure(pos.table) if (!struct) return false
const col = pos.columnIndex < 0 ? 0 : pos.columnIndex
// The separator row is structurally a single TableDelimiter (no // child cells), so `cellStartOf` doesn't apply — there's no // column-equivalent offset on the separator to map the user's // current column to. We position the cursor at offset 0 of the // separator's line (the very start, before the leading `|`) so // ArrowUp/ArrowDown still visits the line, matching the user's // mental model that each line is a stop. The user's column is // intentionally not preserved across the separator transition; // the next ArrowUp/ArrowDown from the separator routes through // `cellStartOf(struct.header / struct.bodyRows[0], col)` so the // column is re-derived from the (preserved) `pos.columnIndex` of // the original starting row. See #269. const separatorLineStart = state.doc.lineAt(struct.separator.from).from
if (pos.rowKind === 'header') { if (direction === 'up') return false // Down: separator (one line below header). view.dispatch({ selection: EditorSelection.cursor(separatorLineStart), annotations: Transaction.userEvent.of('select'), }) return true }
if (pos.rowKind === 'separator') { if (direction === 'down') { if (struct.bodyRows.length === 0) return false const target = cellStartOf(struct.bodyRows[0], col) if (target == null) return false view.dispatch({ selection: EditorSelection.cursor(target), annotations: Transaction.userEvent.of('select'), }) return true } // up const target = cellStartOf(struct.header, col) if (target == null) return false view.dispatch({ selection: EditorSelection.cursor(target), annotations: Transaction.userEvent.of('select'), }) return true }
// body row if (direction === 'up') { if (pos.rowIndex === 0) { // Up from body[0] → separator. view.dispatch({ selection: EditorSelection.cursor(separatorLineStart), annotations: Transaction.userEvent.of('select'), }) return true } const prev = struct.bodyRows[pos.rowIndex - 1] const target = cellStartOf(prev, Math.min(col, getColCount(prev) - 1)) if (target == null) return false view.dispatch({ selection: EditorSelection.cursor(target), annotations: Transaction.userEvent.of('select'), }) return true } // down if (pos.rowIndex >= struct.bodyRows.length - 1) return false const next = struct.bodyRows[pos.rowIndex + 1] const target = cellStartOf(next, Math.min(col, getColCount(next) - 1)) if (target == null) return false view.dispatch({ selection: EditorSelection.cursor(target), annotations: Transaction.userEvent.of('select'), }) return true}
function getColCount(row: SyntaxNode): number { const { delims } = getRowDelims(row) return Math.max(0, delims.length - 1)}
export function handleArrowUp(view: EditorView): boolean { return handleArrow(view, 'up')}
export function handleArrowDown(view: EditorView): boolean { return handleArrow(view, 'down')}
// --------------------------------------------------------------------------// Extension// --------------------------------------------------------------------------
export function tableKeybindings(): Extension { return keymap.of([ { key: 'Tab', run: handleTab }, { key: 'Shift-Tab', run: handleShiftTab }, { key: 'ArrowUp', run: handleArrowUp }, { key: 'ArrowDown', run: handleArrowDown }, ])}
// Re-export TablePosition for downstream consumers.export type { TablePosition }