Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
15 kB · 370 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371// 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: <N>` (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<Decoration>() 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 },)