Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
16 kB · 406 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407import { StateField, type EditorState } from '@codemirror/state'import { syntaxTree } from '@codemirror/language'import type { SyntaxNode } from '@lezer/common'import { getFrontmatterRange as getLezerFrontmatterRange } from '../../markdown/frontmatter'
export interface FrontmatterRange { startOffset: number endOffset: number}
const CODE_CONTEXT_NAMES = new Set([ 'CodeBlock', 'FencedCode', 'InlineCode', 'CodeText', 'CodeMark', 'CodeInfo',])
const BLOCK_CODE_CONTEXT_NAMES = new Set([ 'CodeBlock', 'FencedCode',])
function parseFrontmatterRange(state: EditorState): FrontmatterRange | null { const frontmatter = getLezerFrontmatterRange(state.doc.toString()) if (!frontmatter) return null return { startOffset: frontmatter.from, endOffset: frontmatter.to, }}
export const markdownFrontmatterField = StateField.define<FrontmatterRange | null>({ create: (state) => parseFrontmatterRange(state), update: (value, transaction) => (transaction.docChanged ? parseFrontmatterRange(transaction.state) : value),})
export function getFrontmatterRange(state: EditorState): FrontmatterRange | null { return state.field(markdownFrontmatterField, false) ?? parseFrontmatterRange(state)}
export function isRangeInFrontmatter( from: number, to: number, frontmatter: FrontmatterRange | null,): boolean { if (!frontmatter) return false if (from === to) return from >= frontmatter.startOffset && from < frontmatter.endOffset return from < frontmatter.endOffset && to > frontmatter.startOffset}
/** * True iff the cursor is on the SAME LINE as the span [from, to] AND within * `slack` chars of its boundaries. Same-line gating prevents proximity-reveal * from leaking onto neighboring lines (e.g. cursor at start of line N+1 * unhiding markup at the end of line N). * * Use this for proximity-reveal of inline markdown markers that cannot * legitimately cross newlines (bold/italic/strike per CommonMark, plus * wikilinks, checkboxes, footnote refs). Inline code is excluded because * CommonMark §6.1 allows code spans to contain newlines; use a pure * distance check there instead. */export function isCursorNearSameLine( state: EditorState, from: number, _to: number, slack: number,): boolean { const cursor = state.selection.main.head if (cursor < from - slack || cursor > _to + slack) return false const doc = state.doc const cursorLine = doc.lineAt(cursor).number const fromLine = doc.lineAt(from).number return cursorLine === fromLine // Note: bold/italic/strike/wikilink/checkbox/footnote-ref cannot cross // newlines per the @lezer/markdown grammar, so fromLine === toLine for // every legitimate caller. Comparing only against fromLine saves one // O(log doc) lineAt call. The `_to` parameter is retained in the // signature so the helper still asserts the slack-based distance check // against both ends of the span.}
/** * Resolve the syntax node at `offset` and walk up the tree, returning true if * any ancestor's name is in `contextNames`. The bias direction passed to * `resolveInner` matters at boundaries; we look "into" the document by using a * positive bias when not at end-of-doc, and a negative bias at end-of-doc so * the final position is treated as belonging to the trailing node rather than * resolving past it. */function isOffsetInNamedContext(state: EditorState, offset: number, contextNames: Set<string>): boolean { const docLen = state.doc.length // Clamp into [0, docLen]. At the very end (offset === docLen) we resolve // with a negative bias so we get the node ending at end-of-doc rather than // a position past it. Previously we silently shifted to `docLen - 1`, which // could flip the answer for queries right at end-of-doc. const safeOffset = Math.max(0, Math.min(offset, docLen)) const side = safeOffset >= docLen ? -1 : 1 let node: SyntaxNode | null = syntaxTree(state).resolveInner(safeOffset, side) while (node) { if (contextNames.has(node.name)) return true node = node.parent } return false}
function isRangeInNamedContext( state: EditorState, from: number, to: number, contextNames: Set<string>,): boolean { // Conservative overlap test for a half-open [from, to) range: return true // if either endpoint resolves inside a named context or if iteration finds // a matching node anywhere inside the range. if (state.doc.length === 0) return false
// Empty range (from === to): treat as an offset query at `from`. The // syntax-tree iterate over an empty range is a no-op, so without this the // function would return false for queries on, e.g., a blank line inside a // fenced code block — losing the surrounding context. if (from === to) { if (isOffsetInNamedContext(state, from, contextNames)) return true
// Lezer's indented CodeBlock starts after the four-space prefix. A // cursor at the line end therefore resolves outside the node, even // though Enter is still on that code line. Probe the first non-space // character so editing behavior follows the line's block context. if (contextNames === BLOCK_CODE_CONTEXT_NAMES) { const line = state.doc.lineAt(from) const contentOffset = line.from + line.text.search(/\S|$/) if (contentOffset !== from && isOffsetInNamedContext(state, contentOffset, contextNames)) return true } return false }
if (isOffsetInNamedContext(state, from, contextNames)) return true if (isOffsetInNamedContext(state, to - 1, contextNames)) return true
let found = false syntaxTree(state).iterate({ from, to, enter(node) { if (contextNames.has(node.name)) { found = true return false } return undefined }, })
if (found) return true return false}
export function isRangeInCodeContext(state: EditorState, from: number, to: number): boolean { return isRangeInNamedContext(state, from, to, CODE_CONTEXT_NAMES)}
export function isRangeInBlockCodeContext(state: EditorState, from: number, to: number): boolean { return isRangeInNamedContext(state, from, to, BLOCK_CODE_CONTEXT_NAMES)}
export function shouldSkipMarkdownEnhancement( state: EditorState, from: number, to: number, frontmatter: FrontmatterRange | null,): boolean { return isRangeInFrontmatter(from, to, frontmatter) || isRangeInCodeContext(state, from, to)}
export function shouldSkipBlockMarkdownEnhancement( state: EditorState, from: number, to: number, frontmatter: FrontmatterRange | null,): boolean { return isRangeInFrontmatter(from, to, frontmatter) || isRangeInBlockCodeContext(state, from, to)}
/** * True if `node` has any ancestor named `TableCell`. Used by * `inlineFormatDecoration` and `codeMarkerDecoration` (Fallback A from the * table-grid plan) to suppress decorations inside table cells. Cell wrappers * for the flex-laid table row would otherwise be split by overlapping inline * marks, breaking the one-cell-equals-one-flex-item invariant. v1 * limitation: bold/italic/strike/code markers render as literal text inside * cells. See `docs/plans/2026-04-28-table-grid-layout.md` D15. */export function isInsideTableCell(node: SyntaxNode | null): boolean { let cur: SyntaxNode | null = node while (cur) { if (cur.name === 'TableCell') return true cur = cur.parent } return false}
/** * Offset-based variant of `isInsideTableCell` for regex-based extensions * (e.g., `wikilinkExtension`, `imageEmbedWidget`) that scan text without * walking the syntax tree. Resolves the syntax node at the half-open * range [from, to) and returns true if any ancestor is a TableCell. * * Same Fallback A motivation as `isInsideTableCell`: any Decoration.mark * or Decoration.replace overlapping the cell wrapper splits it into N * sibling spans, breaking the one-cell-equals-one-flex-item invariant. * Regex-based decorators must therefore consult this helper before * emitting any decoration that overlaps a TableCell range. */export function isOffsetRangeInTableCell( state: EditorState, from: number, to: number,): boolean { const docLen = state.doc.length if (docLen === 0) return false const safe = Math.max(0, Math.min(from, docLen)) const side = safe >= docLen ? -1 : 1 let node: SyntaxNode | null = syntaxTree(state).resolveInner(safe, side) while (node) { if (node.name === 'TableCell') { // Confirm the [from, to) range is actually contained in this cell. // A wikilink that straddles a TableCell boundary (rare but possible // with malformed input) shouldn't be treated as fully inside. if (from >= node.from && to <= node.to) return true return false } node = node.parent } return false}
/** * Structured position of a cursor inside a Table. Used by table * keybindings and toolbar action visibility predicates. * * - `table`: the SyntaxNode of the containing Table. * - `rowKind`: 'header' | 'separator' | 'body'. * - `rowIndex`: 0 for header / separator; 0-based index among body rows. * - `columnIndex`: 0-based column. -1 if the cursor falls strictly inside * a multi-character TableDelimiter (malformed input). * - `cellOffset`: offset within the inter-pipe cell content. Convention: * `cellOffset = 0` is immediately AFTER the left pipe of the cell * (i.e., at `prevDelim.to`). -1 if `columnIndex === -1`. */export interface TablePosition { table: SyntaxNode rowKind: 'header' | 'separator' | 'body' rowIndex: number columnIndex: number cellOffset: number}
/** * Resolve the cursor offset to a structured position inside a Table node. * Returns null if the offset is not inside a Table. See {@link TablePosition} * for the cell-offset convention. The boundary rule for length-1 pipes is: * either side of a pipe boundary snaps to the NEXT cell with cellOffset = 0 * (the user's intuition is "I'm at the start of cell N"). */export function getTablePositionAt( state: EditorState, offset: number,): TablePosition | null { const docLen = state.doc.length if (docLen === 0) return null const safe = Math.max(0, Math.min(offset, docLen)) const side = safe >= docLen ? -1 : 1 let node: SyntaxNode | null = syntaxTree(state).resolveInner(safe, side)
// Walk ancestors looking for Table and the immediate row container. let table: SyntaxNode | null = null let row: SyntaxNode | null = null let cur: SyntaxNode | null = node while (cur) { if (cur.name === 'Table' && !table) table = cur if ((cur.name === 'TableHeader' || cur.name === 'TableRow') && !row) row = cur cur = cur.parent } if (!table) return null
// Determine rowKind. If we're not inside a TableHeader or TableRow then we // may be inside the wide separator TableDelimiter (a direct child of Table). let rowKind: 'header' | 'separator' | 'body' let rowIndex = 0 if (row && row.name === 'TableHeader') { rowKind = 'header' } else if (row && row.name === 'TableRow') { rowKind = 'body' let sib = row.prevSibling while (sib) { if (sib.name === 'TableRow') rowIndex++ sib = sib.prevSibling } } else { // Possibly on the separator row's wide TableDelimiter. let sep: SyntaxNode | null = null let c = table.firstChild while (c) { if (c.name === 'TableDelimiter' && (c.to - c.from) > 1) { sep = c break } c = c.nextSibling } if (sep && safe >= sep.from && safe <= sep.to) { rowKind = 'separator' } else { return null } }
// Determine columnIndex + cellOffset. let columnIndex = -1 let cellOffset = -1
if (rowKind === 'separator') { // Use line-text scan over '|' chars. The wide TableDelimiter spans the // full separator line. const sepLine = state.doc.lineAt(safe) const text = state.doc.sliceString(sepLine.from, sepLine.to) const pipes: number[] = [] for (let i = 0; i < text.length; i++) { if (text[i] === '|') pipes.push(sepLine.from + i) } for (let i = 0; i < pipes.length - 1; i++) { if (safe >= pipes[i] && safe <= pipes[i + 1]) { columnIndex = i cellOffset = Math.max(0, safe - pipes[i] - 1) // safe === pipes[i] (left boundary): snap to cell i, cellOffset = 0. if (safe === pipes[i]) cellOffset = 0 // safe === pipes[i + 1] (right boundary): belongs to cell i with // cellOffset = pipes[i+1] - pipes[i] - 1 (gap length). The // arithmetic above already gives this; keep the previous-cell // bias for the right boundary so the user's "end of cell N" // resolves correctly. EXCEPT: when there's a NEXT cell beyond, // we prefer NEXT cell with cellOffset 0 to match the convention // tested in get-table-position.test.ts. Implemented below. if (safe === pipes[i + 1] && i + 1 < pipes.length - 1) { columnIndex = i + 1 cellOffset = 0 } break } } } else { // header or body row: walk children to find TableDelimiter indices. const children: SyntaxNode[] = [] let c = (row as SyntaxNode).firstChild while (c) { children.push(c) c = c.nextSibling } const delimIndices: number[] = [] for (let i = 0; i < children.length; i++) { if (children[i].name === 'TableDelimiter') delimIndices.push(i) }
// Header/body row delimiters are length-1 pipes, so no position can be // strictly inside one; every row carries a leading and trailing pipe. { // Convention (revised after dev-server findings): // - safe === delims[0].from (left edge of LEADING pipe): cursor is at // the very start of the row's text; snap to col 0 cellOffset 0. // This is the case after a click on the left edge of an empty cell // wrapper, where CM6 places cursor at row.line.from. Without this // snap, columnIndex would stay -1 and the in-table column-targeted // actions would be hidden — caught visually during the polish // dev-server checkpoint. // - safe in inter-pipe gap [prev.to, next.from]: cell at index `p` // with cellOffset `safe - prev.to`. Both the left boundary // (safe === prev.to, immediately after THIS cell's left pipe) and // the right boundary (safe === next.from, immediately before THIS // cell's right pipe) belong to the SAME cell `p`. Previously the // right boundary snapped to NEXT cell, which produced an off-by-one // for column-targeted toolbar commands (Toggle alignment of col N // would actually adjust col N+1 when cursor was at end of col N's // content). // - safe === delims[last].to (right edge of TRAILING pipe): cursor // is past the table on the same line; this is unusual and we leave // columnIndex = -1 so column-targeted actions hide. const firstDelim = children[delimIndices[0]] if (safe === firstDelim.from) { columnIndex = 0 cellOffset = 0 } else { for (let p = 0; p < delimIndices.length - 1; p++) { const prev = children[delimIndices[p]] const next = children[delimIndices[p + 1]] if (safe >= prev.to && safe <= next.from) { columnIndex = p cellOffset = Math.max(0, safe - prev.to) break } } } } }
return { table, rowKind, rowIndex, columnIndex, cellOffset }}