Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513// src/plugin/cm6-editor.ts/** * CM6EditorAdapter — Obsidian Editor adapter backed by CodeMirror 6. * * All operations delegate to EditorView.state.doc and EditorView.dispatch. * Supports deferred view binding (constructed before React mounts). */import { Editor } from './editor'import type { EditorPosition, EditorRange, EditorSelection, EditorSelectionOrCaret, EditorTransaction, EditorCommandName, EditorRangeOrCaret } from './types'import { EditorView } from '@codemirror/view'import { EditorSelection as CM6Selection, type TransactionSpec } from '@codemirror/state'import { undo as cm6Undo, redo as cm6Redo } from '@codemirror/commands'import { diffTextChanges } from '../sync/text-diff'
/** * Compute the default vertical margin (in CSS px) for line-top scroll * operations. The 24 px baseline keeps the target line clear of the * editor chrome (toolbar, header). On mobile, `visualViewport.offsetTop` * is non-zero when the on-screen keyboard is open, pushing the visible * area down — we add it so the target isn't hidden behind the keyboard. * * First-class runtime helper — used as the default `yMargin` in * `scrollViewToLine`. Callers with exotic needs can also compose with it. */export function computeDefaultScrollMargin(): number { const baselineMargin = 24 const vvOffsetTop = typeof window !== 'undefined' && window.visualViewport ? window.visualViewport.offsetTop : 0 return baselineMargin + vvOffsetTop}
/** * Scroll an EditorView so that `line` (0-indexed) sits at the top of the * visible viewport, with a top margin (`yMargin` px) so the line isn't * flush against the toolbar. Honors `behavior` for animated scrolling. * * `yMargin` defaults to `computeDefaultScrollMargin()` (24px baseline * + `visualViewport.offsetTop` on mobile) so all consumers get correct * mobile behavior automatically. Pass an explicit `yMargin: 0` only * when you genuinely want flush-to-top positioning. * * Implementation note: we do NOT use * `EditorView.scrollIntoView({ y: 'start' })` + CSS * `scroll-behavior: smooth`. That combination has two problems: * 1. CM6's scrollIntoView effect ultimately calls * element.scrollTop = …, which CSS scroll-behavior may or may not * animate (cross-browser flaky), AND * 2. Restoring the previous `scroll-behavior` on the next animation * frame (~16 ms) cancels the in-progress smooth animation * (which spans ~300–500 ms), silently degrading to instant. * * Instead we compute the line's pixel top via `view.lineBlockAt(...)` * and call `scrollDOM.scrollTo({ top, behavior })` directly. The * Element.scrollTo API natively handles 'smooth' vs 'instant' as a * one-shot per-call argument with no global state to restore. * * Reduced motion: honors `prefers-reduced-motion: reduce` — * `'smooth'` is downgraded to `'instant'` for any caller. Centralized * here so all direct callers (adapter, decoration widgets, dom-event * handlers) get the accessibility behavior for free. * * Returns true unconditionally — the view parameter is always non-null. * The boolean return type is kept for API symmetry with * `CM6EditorAdapter.scrollToLine`, which returns false when unbound. */export function scrollViewToLine( view: EditorView, line: number, options?: { yMargin?: number; behavior?: ScrollBehavior },): boolean { const lineCount = view.state.doc.lines const safeLine = Math.max(1, Math.min(line + 1, lineCount)) // CM6 lines 1-indexed const lineObj = view.state.doc.line(safeLine) const block = view.lineBlockAt(lineObj.from)
const yMargin = options?.yMargin ?? computeDefaultScrollMargin() const top = Math.max(0, block.top - yMargin) const requested: ScrollBehavior = options?.behavior ?? 'smooth' const reducedMotion = typeof window !== 'undefined' && typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches const behavior: ScrollBehavior = reducedMotion ? 'instant' : requested
view.scrollDOM.scrollTo({ top, behavior }) return true}
export class CM6EditorAdapter extends Editor { private _view: EditorView | null = null private _bootstrapDoc: string | null
constructor(initialDoc = '') { super() this._bootstrapDoc = initialDoc }
private _getBootstrapSnapshot(): { doc: string; lines: string[] } | null { if (this._bootstrapDoc === null) { return null } return { doc: this._bootstrapDoc, lines: this._bootstrapDoc.split('\n'), } }
/** Bind a CM6 EditorView instance. */ setView(view: EditorView): void { this._view = view this._bootstrapDoc = null }
/** Unbind the CM6 EditorView instance. */ clearView(): void { this._view = null }
/** Check whether a CM6 EditorView is currently bound. */ isBound(): boolean { return this._view !== null }
/** Get the bound EditorView, or null if unbound. Used by MarkdownView handoff. */ getView(): EditorView | null { return this._view }
// ---- Position conversion helpers ----
private _posToOffset(pos: EditorPosition): number { if (!this._view) { const snapshot = this._getBootstrapSnapshot() if (!snapshot) return 0
const line = Math.min(Math.max(0, pos.line), snapshot.lines.length - 1) const safeLine = snapshot.lines[line] ?? '' const ch = Math.min(Math.max(0, pos.ch), safeLine.length)
let offset = ch for (let i = 0; i < line; i++) { offset += (snapshot.lines[i]?.length ?? 0) + 1 } return offset }
const doc = this._view.state.doc const lineCount = doc.lines const line = Math.min(Math.max(0, pos.line), lineCount - 1) const lineObj = doc.line(line + 1) // CM6 lines are 1-indexed const ch = Math.min(Math.max(0, pos.ch), lineObj.length) return lineObj.from + ch }
private _offsetToPos(offset: number): EditorPosition { if (!this._view) { const snapshot = this._getBootstrapSnapshot() if (!snapshot) return { line: 0, ch: 0 }
const clamped = Math.max(0, Math.min(offset, snapshot.doc.length)) let consumed = 0
for (let i = 0; i < snapshot.lines.length; i++) { const lineLength = snapshot.lines[i]?.length ?? 0 const lineEnd = consumed + lineLength if (clamped <= lineEnd || i === snapshot.lines.length - 1) { return { line: i, ch: clamped - consumed } } consumed = lineEnd + 1 }
return { line: 0, ch: 0 } }
const doc = this._view.state.doc const clamped = Math.max(0, Math.min(offset, doc.length)) const lineObj = doc.lineAt(clamped) return { line: lineObj.number - 1, ch: clamped - lineObj.from } // Convert to 0-indexed }
// ---- Read operations ----
getValue(): string { if (!this._view) return this._bootstrapDoc ?? '' return this._view.state.doc.toString() }
getLine(line: number): string { if (!this._view) { const snapshot = this._getBootstrapSnapshot() if (!snapshot || line < 0 || line >= snapshot.lines.length) return '' return snapshot.lines[line] ?? '' }
const doc = this._view.state.doc if (line < 0 || line >= doc.lines) return '' return doc.line(line + 1).text // CM6 lines are 1-indexed }
lineCount(): number { if (!this._view) return this._getBootstrapSnapshot()?.lines.length ?? 1 return this._view.state.doc.lines }
lastLine(): number { return this.lineCount() - 1 }
getSelection(): string { if (!this._view) return '' const { from, to } = this._view.state.selection.main if (from === to) return '' return this._view.state.sliceDoc(from, to) }
getRange(from: EditorPosition, to: EditorPosition): string { const startOffset = this._posToOffset(from) const endOffset = this._posToOffset(to) if (!this._view) { const snapshot = this._getBootstrapSnapshot() if (!snapshot) return '' return snapshot.doc.slice(startOffset, endOffset) } return this._view.state.sliceDoc(startOffset, endOffset) }
getCursor(side?: 'from' | 'to' | 'head' | 'anchor'): EditorPosition { if (!this._view) return { line: 0, ch: 0 } const sel = this._view.state.selection.main let offset: number switch (side) { case 'anchor': offset = sel.anchor break case 'from': offset = sel.from break case 'to': offset = sel.to break case 'head': default: offset = sel.head break } return this._offsetToPos(offset) }
listSelections(): EditorSelection[] { if (!this._view) return [{ anchor: { line: 0, ch: 0 }, head: { line: 0, ch: 0 } }] return this._view.state.selection.ranges.map(range => ({ anchor: this._offsetToPos(range.anchor), head: this._offsetToPos(range.head), })) }
wordAt(pos: EditorPosition): EditorRange | null { if (!this._view) { const snapshot = this._getBootstrapSnapshot() if (!snapshot) return null
const offset = this._posToOffset(pos) if (offset >= snapshot.doc.length) return null
const lineStart = snapshot.doc.lastIndexOf('\n', Math.max(0, offset - 1)) + 1 const nextBreak = snapshot.doc.indexOf('\n', offset) const lineEnd = nextBreak === -1 ? snapshot.doc.length : nextBreak const lineText = snapshot.doc.slice(lineStart, lineEnd) const chIndex = offset - lineStart
if (chIndex >= lineText.length) return null const ch = lineText[chIndex] if (!ch || !/\w/.test(ch)) return null
let start = chIndex while (start > 0 && /\w/.test(lineText[start - 1])) start--
let end = chIndex while (end < lineText.length && /\w/.test(lineText[end])) end++
return { from: this._offsetToPos(lineStart + start), to: this._offsetToPos(lineStart + end), } }
const offset = this._posToOffset(pos) const doc = this._view.state.doc if (offset >= doc.length) return null
// Use line-local scanning to avoid O(n) doc.toString() copies. const lineObj = doc.lineAt(offset) const lineText = lineObj.text const chIndex = offset - lineObj.from
if (chIndex >= lineText.length) return null const ch = lineText[chIndex] if (!ch || !/\w/.test(ch)) return null
let start = chIndex while (start > 0 && /\w/.test(lineText[start - 1])) start--
let end = chIndex while (end < lineText.length && /\w/.test(lineText[end])) end++
return { from: this._offsetToPos(lineObj.from + start), to: this._offsetToPos(lineObj.from + end), } }
posToOffset(pos: EditorPosition): number { return this._posToOffset(pos) }
offsetToPos(offset: number): EditorPosition { return this._offsetToPos(offset) }
getScrollInfo(): { top: number; left: number } { if (!this._view) return { top: 0, left: 0 } const scrollDOM = this._view.scrollDOM return { top: scrollDOM.scrollTop, left: scrollDOM.scrollLeft } }
// ---- Mutation operations ----
refresh(): void { if (!this._view) return this._view.requestMeasure() }
setValue(content: string): void { if (!this._view) return const current = this._view.state.doc.toString() if (current === content) return this._view.dispatch({ changes: diffTextChanges(current, content), }) }
replaceSelection(replacement: string, _origin?: string): void { if (!this._view) return this._view.dispatch(this._view.state.replaceSelection(replacement)) }
replaceRange(replacement: string, from: EditorPosition, to?: EditorPosition, _origin?: string): void { if (!this._view) return const startOffset = this._posToOffset(from) const endOffset = to ? this._posToOffset(to) : startOffset this._view.dispatch({ changes: { from: startOffset, to: endOffset, insert: replacement }, }) }
// ---- Selection operations ----
setSelection(anchor: EditorPosition, head?: EditorPosition): void { if (!this._view) return const anchorOffset = this._posToOffset(anchor) const headOffset = head ? this._posToOffset(head) : anchorOffset this._view.dispatch({ selection: CM6Selection.single(anchorOffset, headOffset), }) }
setSelections(ranges: EditorSelectionOrCaret[], main?: number): void { if (!this._view || ranges.length === 0) return const cm6Ranges = ranges.map(r => { const anchor = this._posToOffset(r.anchor) // If head is omitted, it defaults to anchor (caret) const head = r.head ? this._posToOffset(r.head) : anchor return CM6Selection.range(anchor, head) }) this._view.dispatch({ selection: CM6Selection.create(cm6Ranges, main ?? 0), }) }
// ---- Focus operations ----
focus(): void { this._view?.focus() }
blur(): void { this._view?.contentDOM.blur() }
hasFocus(): boolean { if (!this._view) return false return this._view.hasFocus }
// ---- Scroll operations ----
scrollTo(x?: number | null, y?: number | null): void { if (!this._view) return const scrollDOM = this._view.scrollDOM if (x != null) scrollDOM.scrollLeft = x if (y != null) scrollDOM.scrollTop = y }
scrollIntoView(range: EditorRange, center?: boolean): void { if (!this._view) return const from = this._posToOffset(range.from) this._view.dispatch({ effects: EditorView.scrollIntoView(from, { y: center ? 'center' : 'nearest' }), }) }
/** * Scroll to a 0-indexed line. Delegates to the free `scrollViewToLine` * function — see its doc-comment for the full contract (yMargin default, * reduced-motion handling, implementation rationale). * * Returns false if the view is unbound; true on dispatch. */ scrollToLine( line: number, options?: { yMargin?: number; behavior?: ScrollBehavior }, ): boolean { const view = this._view if (!view) return false return scrollViewToLine(view, line, options) }
// ---- Undo/Redo ----
undo(): void { if (!this._view) return cm6Undo(this._view) }
redo(): void { if (!this._view) return cm6Redo(this._view) }
// ---- Commands ----
exec(_command: EditorCommandName): void { // CM6 doesn't have a named command registry like CodeMirror 5. // Individual commands can be mapped here as needed. }
// ---- Transactions ----
/** * Helper to convert EditorRangeOrCaret to CM6 selection range offsets. * `from` maps to anchor, `to` (if present) maps to head; otherwise head = anchor (caret). */ private _rangeOrCaretToOffsets(range: EditorRangeOrCaret): { anchor: number; head: number } { const anchor = this._posToOffset(range.from) const head = range.to ? this._posToOffset(range.to) : anchor return { anchor, head } }
transaction(tx: EditorTransaction, _origin?: string): void { if (!this._view) return
// replaceSelection is handled separately — it generates its own // TransactionSpec via state.replaceSelection() and cannot be merged // with explicit changes/selection in a single dispatch call. if (tx.replaceSelection !== undefined) { this._view.dispatch(this._view.state.replaceSelection(tx.replaceSelection)) return }
// Build a single TransactionSpec for changes + selection so they are // applied atomically (one undo step, no intermediate visible state). const spec: TransactionSpec = {}
if (tx.changes) { spec.changes = tx.changes.map(c => ({ from: this._posToOffset(c.from), to: c.to ? this._posToOffset(c.to) : this._posToOffset(c.from), insert: c.text, })) }
// Handle selections (multiple) or selection (single) // selections takes precedence if both are provided (matching Obsidian behavior) const selectionRanges = tx.selections ?? (tx.selection ? [tx.selection] : null)
if (selectionRanges && selectionRanges.length > 0) { // Selection positions are in pre-change (old document) coordinates. // When changes are also present, we must map these positions through // the change set to get valid post-change coordinates, because CM6's // dispatch() expects the selection to be in post-change space. const changeSet = spec.changes ? this._view.state.changes(spec.changes) : null
const ranges = selectionRanges.map(range => { let { anchor, head } = this._rangeOrCaretToOffsets(range) if (changeSet) { anchor = changeSet.mapPos(anchor) head = changeSet.mapPos(head) } return CM6Selection.range(anchor, head) })
// CM6Selection.create expects at least one range; primary index defaults to 0 spec.selection = CM6Selection.create(ranges, 0) }
if (spec.changes || spec.selection) { this._view.dispatch(spec) } }}