// src/editor/extensions/table-grid.ts /** * CM6 extension that renders GFM tables as a CSS flex layout in live-preview * mode. (See plan D12 — flex chosen over grid because Spike R4a showed * `display: grid` on a `.cm-line` makes `view.coordsAtPos` return null in * jsdom; flex preserves geometry.) * * Per locked-in user decision: ALWAYS row layout in live-preview, no * proximity reveal. Cells stay as raw editable text; cursor in cell types * into doc. * * For each Table node in the visible viewport this plugin emits: * - Decoration.line per row line with class cm-table-row + row-kind + * row-position, plus inline `--cm-table-cols: ` (used as a * parity / future-styling hook; flex distribution is uniform). * - Decoration.mark covering the separator's TableDelimiter range * with class cm-table-separator-anchor (CSS color: transparent; * keeps the source `|---|---|` in the DOM as caret-targetable * text — see #269). * - Decoration.mark per intra-row TableDelimiter with class * cm-table-pipe-hidden (CSS zero-width flex item with * color: transparent; preserves doc offsets — see #269). * - Decoration.mark per TableCell with class cm-table-cell + * cm-table-cell-align-{left|center|right} (flex: 1 1 0 in CSS). * - Decoration.widget (zero-width) for no-gap empty cells (||) so each * row has exactly N flex items. * * Update triggers: docChanged | viewportChanged | syntax-tree change. * NOT selectionSet — the row layout never depends on cursor position. * * Fallback A coordination: this extension does not need to know about * `inlineFormatDecoration` / `codeMarkerDecoration`. Those extensions * detect TableCell ancestry themselves via `isInsideTableCell` (see plan * D15). The cell wrapper here therefore stays as a single span. */ import { syntaxTree } from '@codemirror/language' import { Decoration, type DecorationSet, EditorView, ViewPlugin, type ViewUpdate, WidgetType, } from '@codemirror/view' import { RangeSetBuilder } from '@codemirror/state' import type { SyntaxNode } from '@lezer/common' import { TABLE_PIPE_HIDDEN, TABLE_SEPARATOR_ANCHOR } from './hidden-text-classes' import { measurePerfSample } from '../../lib/perf-profile' // Idiom 2 (whole-line) — see hidden-text-classes.ts. The separator // row's `|---|---|` text stays in the DOM as editable text wrapped by // a `Decoration.mark` carrying TABLE_SEPARATOR_ANCHOR. CSS makes the // glyphs visually invisible (`color: transparent; user-select: none`) // but the underlying text remains caret-targetable via // caretRangeFromPoint, so ArrowUp/ArrowDown lands on the line. Same // idiom HR uses for `---`. // // Rejected alternative: `Decoration.replace({widget})` rendering an // invisible nbsp. CM6 wraps replace-widgets in `contenteditable="false"` // which the browser excludes from caret hit-testing entirely — // ArrowDown still skipped the line. Keeping the source text in the // DOM avoids that exclusion. Side-effect: the marked text is fully // editable (typing into the separator demotes the table to a // paragraph) — consistent with raw markdown behavior. export type TableAlignment = 'left' | 'center' | 'right' /** * Parse alignment array from a separator row's text (`|:---|---:|---|` → * `['left','right','left']`). Empty leading/trailing splits dropped. * Whitespace inside cells trimmed. Default `'left'` (GFM default). */ export function parseSeparatorAlignment(text: string): TableAlignment[] { const t = text.replace(/\n$/, '') const parts = t.split('|').slice(1, -1) return parts.map((raw): TableAlignment => { const cell = raw.trim() const start = cell.startsWith(':') const end = cell.endsWith(':') if (start && end) return 'center' if (end) return 'right' return 'left' }) } /** * Number of columns in a `TableHeader` row. * * Counted by `TableDelimiter` boundaries (N+1 pipes form N columns), NOT * by `TableCell` children: lezer-markdown's GFM grammar does not emit a * `TableCell` for whitespace-only header cells (e.g. a freshly-inserted * empty 3×3 table), so a TableCell count would return 0 for an * all-empty header. The delimiter-count approach is uniform across * empty-cell and content-filled rows. * * Caught visually during the polish dev-server checkpoint: the TableCell * count made `getColumnCount()` return 0 for an empty header, which * hid the "Delete column" toolbar action's `columnCount > 1` predicate. */ export function countHeaderColumns(header: SyntaxNode): number { let delims = 0 let c = header.firstChild while (c) { if (c.name === 'TableDelimiter') delims++ c = c.nextSibling } return Math.max(0, delims - 1) } /** Pad/truncate the alignment array to match `cols`. Missing → `'left'`. */ export function normalizeAlignment( aligns: TableAlignment[], cols: number, ): TableAlignment[] { const out: TableAlignment[] = [] for (let i = 0; i < cols; i++) out.push(aligns[i] ?? 'left') return out } /** * Phantom widget for empty cells. Used in two scenarios: * 1. Empty cell with whitespace gap (e.g. `| | b |`): the surrounding * `cm-table-cell cm-table-cell-empty` mark provides the flex item; * this widget anchors line height inside it. * 2. Empty cell with NO gap (e.g. `||`): no inter-pipe range exists for * a wrapping mark, so this widget itself becomes the flex item via * its own `cm-table-cell-empty-anchor` span. CSS ::before on the * anchor adds an nbsp so the cell carries line height. * * The `align` field is retained on the widget so tests in Group 5 can * assert which alignment a widget carries. */ class EmptyCellWidget extends WidgetType { constructor(readonly align: TableAlignment) { super() } toDOM(): HTMLElement { const span = document.createElement('span') // Carry alignment on the anchor too so the no-gap case (where there // is no wrapping cm-table-cell mark) still aligns correctly. span.className = `cm-table-cell-empty-anchor cm-table-cell-align-${this.align}` return span } eq(other: EmptyCellWidget): boolean { return this.align === other.align } ignoreEvent(): boolean { return false } } /** * Walk a TableHeader/TableRow's direct children. For each gap between two * consecutive `TableDelimiter`s emit either a per-cell mark covering the * FULL inter-pipe range [prevDelim.to, nextDelim.from) (gap contains a * `TableCell`) or an `EmptyCellWidget` plus a whitespace-covering cell * mark (gap is empty). Every `TableDelimiter` (including leading and * trailing) is hidden. * * IMPORTANT: cell mark range covers the whole inter-pipe gap, NOT the * narrower TableCell.from..to range. lezer-markdown's TableCell excludes * surrounding whitespace; if we marked only TableCell, the bare ' ' chars * would render as anonymous flex items. See D5 / Spike R5. */ function pushRowDecorations( rowNode: SyntaxNode, aligns: TableAlignment[], pushMark: (from: number, to: number, deco: Decoration) => void, pushWidget: (pos: number, deco: Decoration) => void, ): void { const children: SyntaxNode[] = [] let c = rowNode.firstChild while (c) { children.push(c); c = c.nextSibling } // Locate every TableDelimiter index in `children`. const delimIndices: number[] = [] for (let i = 0; i < children.length; i++) { if (children[i].name === 'TableDelimiter') delimIndices.push(i) } // Hide every delimiter. for (const di of delimIndices) { const d = children[di] pushMark(d.from, d.to, Decoration.mark({ class: TABLE_PIPE_HIDDEN })) } // For each consecutive pair of delimiters, emit a cell wrapper covering // the FULL inter-pipe range. If the range contains a TableCell, render // as a normal cell. If the range is whitespace-only (empty cell), still // emit the wrapper AND a zero-width EmptyCellWidget inside it. let columnIndex = 0 for (let p = 0; p < delimIndices.length - 1; p++) { const prev = children[delimIndices[p]] const nextDelim = children[delimIndices[p + 1]] const innerFrom = prev.to const innerTo = nextDelim.from const align = aligns[columnIndex] ?? 'left' // Detect empty-cell case: no TableCell child between prev and nextDelim. // The only child types lezer emits under a TableHeader/TableRow are // TableDelimiter and TableCell (verified by tree probes), and no // delimiter exists between two CONSECUTIVE delimiter indices — so a // gap contains a TableCell iff it has at least one child. const hasCell = delimIndices[p + 1] - delimIndices[p] > 1 if (innerFrom < innerTo) { // Inter-pipe range has at least one char (whitespace or cell content). // Always emit a mark over the full inter-pipe range. Wraps both cell // content AND surrounding whitespace inside a single flex item. // The cm-table-cell-empty CSS rule provides line height for empty // whitespace-only cells via ::before nbsp; no widget needed. pushMark( innerFrom, innerTo, Decoration.mark({ class: `cm-table-cell cm-table-cell-align-${align}` + (hasCell ? '' : ' cm-table-cell-empty'), }), ) } else { // No-gap empty cell ("||"). innerFrom === innerTo, which cannot // contain a TableCell (lezer never emits a zero-length TableCell), // so this is always the empty-cell case. Cannot emit a zero-length // mark (RangeSetBuilder rejects), so emit a widget at that position. // The widget's anchor span IS the flex item and carries its own // ::before nbsp for line height. side: -1 keeps it before the // trailing pipe's mark in DOM order. pushWidget( innerFrom, Decoration.widget({ widget: new EmptyCellWidget(align), side: -1 }), ) } columnIndex++ } } /** * Walk every Table node in the visible viewport and emit: * - line decorations on each row line, * - Decoration.mark over the separator TableDelimiter (class * cm-table-separator-anchor — see #269), * - per-row pipe marks, cell marks, and empty-cell widgets. */ function buildDecorationsUnprofiled(view: EditorView): DecorationSet { const doc = view.state.doc const decos: Array<{ from: number; to: number; deco: Decoration }> = [] const pushDeco = (from: number, to: number, deco: Decoration): void => { decos.push({ from, to, deco }) } const ranges = view.visibleRanges.length > 0 ? view.visibleRanges : [{ from: 0, to: doc.length }] for (const { from: rangeFrom, to: rangeTo } of ranges) { if (rangeFrom >= rangeTo) continue syntaxTree(view.state).iterate({ from: rangeFrom, to: rangeTo, enter(node) { if (node.name !== 'Table') return undefined // Lezer never parses a Table inside frontmatter or code (verified by // the exclusion tests), so no extra context guards are needed. // Collect the structural children. Separate `if`s (not an // else-if chain) because v8 misattributes branch coverage on // else-if chains in decoration builders; the names are mutually // exclusive so the semantics are identical. let header: SyntaxNode | null = null let separator: SyntaxNode | null = null const bodyRows: SyntaxNode[] = [] let child = node.node.firstChild while (child) { if (child.name === 'TableHeader') header = child if (child.name === 'TableDelimiter') separator = child if (child.name === 'TableRow') bodyRows.push(child) child = child.nextSibling } const sepText = doc.sliceString(separator!.from, separator!.to) const cols = countHeaderColumns(header!) const aligns = normalizeAlignment(parseSeparatorAlignment(sepText), cols) const rowAttrs = { style: `--cm-table-cols: ${cols};` } // Header line + per-row decorations. const headerLine = doc.lineAt(header!.from) const headerLineClass = 'cm-table-row cm-table-header-row cm-table-row-first' + (bodyRows.length === 0 ? ' cm-table-row-last' : '') pushDeco( headerLine.from, headerLine.from, Decoration.line({ class: headerLineClass, attributes: rowAttrs }), ) pushRowDecorations( header!, aligns, (from, to, deco) => pushDeco(from, to, deco), (pos, deco) => pushDeco(pos, pos, deco), ) // Separator: line decoration + Decoration.mark over the content. const separatorLine = doc.lineAt(separator!.from) pushDeco( separatorLine.from, separatorLine.from, Decoration.line({ class: 'cm-table-row cm-table-separator-row', attributes: rowAttrs, }), ) // Hide the `|---|---|` text via a Decoration.mark so the // underlying source text stays in the DOM as editable text. // CSS (.cm-table-separator-anchor) makes the glyphs visually // invisible. See the TABLE_SEPARATOR_ANCHOR comment above // for why this is preferable to `Decoration.replace`. pushDeco( separator!.from, separator!.to, Decoration.mark({ class: TABLE_SEPARATOR_ANCHOR }), ) // Body rows. for (let i = 0; i < bodyRows.length; i++) { const body = bodyRows[i] const isLast = i === bodyRows.length - 1 const bodyLine = doc.lineAt(body.from) pushDeco( bodyLine.from, bodyLine.from, Decoration.line({ class: 'cm-table-row cm-table-body-row' + (isLast ? ' cm-table-row-last' : ''), attributes: rowAttrs, }), ) pushRowDecorations( body, aligns, (from, to, deco) => pushDeco(from, to, deco), (pos, deco) => pushDeco(pos, pos, deco), ) } return false // don't descend into already-handled children }, }) } decos.sort((a, b) => a.from - b.from || a.to - b.to) const builder = new RangeSetBuilder() for (const d of decos) builder.add(d.from, d.to, d.deco) return builder.finish() } function buildDecorations(view: EditorView): DecorationSet { return measurePerfSample('live-preview.decoration', 'table-grid', () => buildDecorationsUnprofiled(view), { docLength: view.state.doc.length }) } export const tableGrid = ViewPlugin.fromClass( class { decorations: DecorationSet constructor(view: EditorView) { this.decorations = buildDecorations(view) } update(update: ViewUpdate) { // CRITICAL: do NOT rebuild on selectionSet (D2 / D8). The row layout is // cursor-position-independent. Rebuilding on selection is wasted work // and would let cursor-dependent regressions creep in. if ( update.docChanged || update.viewportChanged || syntaxTree(update.startState) !== syntaxTree(update.state) ) { this.decorations = buildDecorations(update.view) } } }, { decorations: (v) => v.decorations }, )