Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
11 kB · 248 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249// src/editor/extensions/code-marker-decoration.ts/** * CM6 ViewPlugin that hides the punctuation around inline and fenced code * when the cursor is not near them, matching the live-preview WYSIWYG * convention used by `inline-format-decoration.ts` (which hides `**`, `*`, * `~~`) and `callout-decoration.ts` (which hides `> [!type]` chrome). * * Two distinct hide rules: * * INLINE CODE (`` `code` ``): * The adjacent `CodeMark` siblings of an `InlineCode` node are hidden when the * cursor is more than `INLINE_PROXIMITY_CHARS` characters from the span. * Same proximity model as `inlineFormatDecoration`. * * FENCED CODE (` ```lang … ``` `): * The opening fence row's `CodeMark` and the closing fence row's * `CodeMark` are hidden when the cursor is NOT on any line inside the * block (header, body, or footer). The language label (`CodeInfo`) is * intentionally NOT hidden — the user always wants to know what * language a block is in. * * Why this is a separate extension (not folded into `inlineFormatDecoration`): * - The inline rule and the fenced rule have different proximity * definitions (character window vs line membership). * - The fenced rule needs `FencedCode` as the proximity scope, not the * `CodeMark` itself — the cursor must reveal both fences regardless * of which line it's on. * - Keeping these in their own file keeps the inline-format file * scoped to bold/italic/strike and easier to reason about. * * Wiring: this extension is loaded only inside the live-preview compartment * (via `live-preview.ts`). In source mode it is intentionally absent so the * raw markdown stays visible. This matches the project's general "source = * raw, live-preview = WYSIWYG-ish" rule and keeps the fence-line decoration * (cm-codeblock chrome via code-fence-decoration.ts) running in both modes * while only the punctuation collapse is mode-scoped. */import { syntaxTree } from '@codemirror/language'import { Decoration, type DecorationSet, EditorView, ViewPlugin, type ViewUpdate,} from '@codemirror/view'import { RangeSetBuilder, type Extension } from '@codemirror/state'import { getFrontmatterRange, isRangeInFrontmatter, isInsideTableCell } from './markdown-context'import { INLINE_FORMATTING_HIDDEN } from './hidden-text-classes'import { measurePerfSample } from '../../lib/perf-profile'
// Mirrors `PROXIMITY_CHARS` in `inline-format-decoration.ts`.const INLINE_PROXIMITY_CHARS = 0
/** * Reuses the inline-formatting-hidden class (collapsed inline span) so * the same CSS rule that collapses `**` and `*` markers also collapses * backticks. Keeps the visual treatment uniform across all inline * marker categories. */const hiddenMark = Decoration.mark({ class: INLINE_FORMATTING_HIDDEN })
/** * True iff the cursor is within `slack` chars of [from, to]. * * NOTE — pure distance check, no same-line gate. CommonMark §6.1 explicitly * allows inline code to contain line endings (`` `foo<newline>bar` `` is one * code span), and lezer-markdown parses it as a single InlineCode node * straddling multiple lines. A same-line gate (as used in * inline-format-decoration.ts for bold/italic/strike) would refuse to reveal * the backticks when the cursor is on a different line of the same code * span — a multi-line-code regression that's worse than the line-leak risk * (cursor at start of line N+1 unhiding backticks at end of line N) the * same-line gate is meant to prevent. The line-leak risk is small for * inline code (it's only ±2 chars and only when there's an inline code * span literally adjacent to the line break). */function isCursorInRange(view: EditorView, from: number, to: number, slack = 0): boolean { const cursor = view.state.selection.main.head return cursor >= from - slack && cursor <= to + slack}
/** * True if the cursor's resolved line falls inside the block's line range. * Used for fenced-code proximity: cursor anywhere inside the block (incl. the * opening or closing fence line) keeps both fence markers visible. * * `blockTo` is clamped to `doc.length` to handle unterminated fenced blocks * (no closing ``` ): in that case `node.to` may equal `doc.length` and a * trailing newline would put `lineAt(blockTo - 1)` on the line before the * empty trailing line, causing a flicker as the user types at end-of-doc. */function isCursorOnBlockLine(view: EditorView, blockFrom: number, blockTo: number): boolean { const doc = view.state.doc const cursor = view.state.selection.main.head const cursorLine = doc.lineAt(cursor).number const blockStartLine = doc.lineAt(blockFrom).number const clampedTo = Math.min(blockTo, doc.length) const blockEndLine = doc.lineAt(clampedTo).number return cursorLine >= blockStartLine && cursorLine <= blockEndLine}
function buildDecorationsUnprofiled(view: EditorView): DecorationSet { const decos: Array<{ from: number; to: number; deco: Decoration }> = [] // Dedupe across overlapping visible ranges. A FencedCode node that // straddles two visible ranges would otherwise be entered twice, pushing // duplicate hide marks for the open/close ` ``` ` rows. RangeSetBuilder // does NOT dedupe identical ranges automatically. Key includes the class // name so a future second-decoration-type addition can't silently clobber // a first-type decoration on the same range. Mirrors the pattern in // inline-format-decoration.ts. const emitted = new Set<string>() function pushDeco(from: number, to: number, deco = hiddenMark): void { // `Decoration.spec` is typed `any` upstream (@codemirror/view // index.d.ts:301), so direct property access is correct. // Every emitted mark carries a class; the class feeds the dedupe key. const cls: string = deco.spec.class const key = `${from}:${to}:${cls}` if (emitted.has(key)) return emitted.add(key) decos.push({ from, to, deco }) }
const frontmatter = getFrontmatterRange(view.state) // Limit iteration to visible ranges for performance on large docs. const ranges = view.visibleRanges.length > 0 ? view.visibleRanges : [{ from: 0, to: view.state.doc.length }]
for (const { from: rangeFrom, to: rangeTo } of ranges) { if (rangeFrom >= rangeTo) continue syntaxTree(view.state).iterate({ from: rangeFrom, to: rangeTo, enter(node) { // Skip frontmatter — its content can match these patterns inside // YAML strings and we don't want to decorate it. if (isRangeInFrontmatter(node.from, node.to, frontmatter)) { return undefined } if (isInsideTableCell(node.node)) { return false // Fallback A — see plan D15. Don't decorate inside cells. }
if (node.name === 'InlineCode') { // Hide BOTH adjacent backtick CodeMark nodes when the cursor is far. // Proximity scope is the inline-code span itself so the cursor // anywhere inside the code text reveals both fences. if (isCursorInRange(view, node.from, node.to, INLINE_PROXIMITY_CHARS)) { return false // Cursor near — keep markers visible; don't descend. } // Reams' compatibility parser exposes the content as `InlineCode`, // with its opening and closing delimiters as direct siblings. const previous = node.node.prevSibling const next = node.node.nextSibling // InlineCode siblings are always the CodeMark backticks (probed), // so the null and name checks are redundant. pushDeco(previous!.from, previous!.to) pushDeco(next!.from, next!.to) return false }
if (node.name === 'FencedCode') { // Hide the opening and closing CodeMark rows when cursor is NOT // on any line in the block. CodeInfo (language label) is preserved // — the user wants to keep seeing what language a block is in. if (isCursorOnBlockLine(view, node.from, node.to)) { return false // Cursor in block — keep markers visible } // FencedCode's first/last children are the opening and closing // CodeMark rows respectively (per the lezer-markdown grammar). // Bounded children walk: O(1) regardless of block length, vs the // previous siblings-loop which scaled with the number of inner // nodes (CodeText, embedded language nodes, etc.). For an // unterminated fence with no closing ` ``` `, lezer may set // `firstChild === lastChild`; the dedupe Set in `pushDeco` // collapses the duplicate push, so no manual guard is needed. const first = node.node.firstChild const last = node.node.lastChild // FencedCode opens with a CodeMark; for an unterminated fence // last === first, and the dedupe set collapses the second push. pushDeco(first!.from, first!.to) pushDeco(last!.from, last!.to) return false }
return undefined }, }) }
// Sort by position so RangeSetBuilder's monotonic-from invariant holds. decos.sort((a, b) => a.from - b.from) 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', 'code-marker', () => buildDecorationsUnprofiled(view), { docLength: view.state.doc.length })}
/** * The underlying ViewPlugin. Exported so tests can resolve the plugin * instance via `view.plugin(codeMarkerDecorationPlugin)`. Production * wiring should use the combined `codeMarkerDecoration` extension * below, which also installs the atomicRanges facet provider. */export const codeMarkerDecorationPlugin = ViewPlugin.fromClass( class { decorations: DecorationSet constructor(view: EditorView) { this.decorations = buildDecorations(view) } update(update: ViewUpdate) { // Rebuild on cursor move (selectionSet) — proximity depends on cursor. // Also rebuild on syntax-tree changes so newly-parsed blocks pick up // the hide treatment promptly (matches code-fence-decoration's pattern). if ( update.docChanged || update.selectionSet || update.viewportChanged || syntaxTree(update.startState) !== syntaxTree(update.state) ) { this.decorations = buildDecorations(update.view) } } }, { decorations: (v) => v.decorations },)
/** * The plugin plus the atomicRanges facet provider. Every decoration * emitted by this plugin is a hidden-marker decoration (backticks for * inline code, fence markers for fenced code) — there is no separate * "visible" decoration class — so the entire `decorations` RangeSet * is registered as atomic. See the doc-comment on * `inlineFormatDecoration` for the rationale and the * `skipAtomicRanges` boundary caveat. */export const codeMarkerDecoration: Extension = [ codeMarkerDecorationPlugin, // The plugin is mounted by the same extension, so the provider always finds it. EditorView.atomicRanges.of((view) => view.plugin(codeMarkerDecorationPlugin)!.decorations, ),]