Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
13 kB · 339 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340// src/editor/extensions/image-embed-widget.ts/** * CM6 extension that renders ![[image.png]] embeds as <img>. * * Behavior matrix (per embed, gated on cursor proximity): * * Cursor outside the embed (far, including elsewhere on its line): * - Emit Decoration.replace({ widget }) covering [from, to). * - Raw text is HIDDEN; image renders inline at the embed position. * * Cursor within or at either edge of the embed (near): * - Emit NO replace decoration — raw `![[image.png]]` text remains * visible and editable. * - Emit Decoration.widget({ widget, block: true, side: 1 }) at * line.to so the image renders as a block on the next visual line. * * The same ImageEmbedWidget class is used in both modes; the constructor * arg `mode: 'inline' | 'block'` selects between a <span> (inline) and a * <div> (block) wrapper. `mode` participates in eq() so a cursor crossing * the embed boundary triggers a clean widget rebuild rather than reusing * a wrapper of the wrong kind. * * IMPLEMENTATION NOTE — StateField vs. ViewPlugin: * CM6 disallows block decorations from ViewPlugins ("Block decorations * may not be specified via plugins"). We therefore expose decorations * from a StateField, derived from EditorState. The field recomputes on * docChanged or selection change, scanning the full document each time. * The full-doc rescan (instead of the visible-range scan a ViewPlugin * could do) is the explicit trade-off for being able to emit * `Decoration.widget({ block: true })` at all — block decorations are * not available from a ViewPlugin. * * Perf: a per-line `line.text.includes('![[')` pre-filter skips both * the regex and the syntax-tree walk for any line that cannot match. * On a typical doc the vast majority of lines fall into that fast * path, keeping selection-change rebuilds responsive on long docs. * * Live updates: when the user edits an embed, the regex re-scans, the * widget eq() re-compares (target, alias, resolver, mode). Edits that * leave those four invariants unchanged produce zero DOM churn. * * Async lifecycle (unchanged from prior version): the widget owns an * `aborted` flag set by WidgetType.destroy(). The resolver promise only * mutates DOM if the widget is not aborted. */import { Decoration, type DecorationSet, EditorView, WidgetType,} from '@codemirror/view'import { RangeSetBuilder, StateField, type EditorState, type Extension,} from '@codemirror/state'import { ImageResolver } from '../../services/image-resolver'import { getFrontmatterRange, shouldSkipMarkdownEnhancement, isOffsetRangeInTableCell, isCursorNearSameLine,} from './markdown-context'import { EMBED_RE, isSizingSpec, collectGalleryImages, hasImageEmbedSyntax } from './image-embed-utils'import { measurePerfSample } from '../../lib/perf-profile'
export interface ImageResolverLike { resolveImageUrl(reference: string): Promise<string | null>}
export interface ImageEmbedConfig { resolver?: ImageResolverLike}
type WidgetMode = 'inline' | 'block'
class ImageEmbedWidget extends WidgetType { private aborted = false
constructor( readonly target: string, readonly alias: string | undefined, readonly resolver: ImageResolverLike, readonly mode: WidgetMode, /** True when the embed is the sole content of its line (no other text). */ readonly standalone: boolean = false, ) { super() }
toDOM(): HTMLElement { // Block-mode embeds render as <div> so they participate in normal // block layout under CM6's block-widget container. Inline-mode // embeds render as <span> so they slot inside the line text. const wrap = document.createElement(this.mode === 'block' ? 'div' : 'span') wrap.className = 'cm-image-embed cm-image-embed-loading' wrap.setAttribute('data-target', this.target) // data-mode lets CSS distinguish inline vs block mode without // depending on the wrapper's tag name (CM6 wraps block widgets in // its own container, so element-type selectors are fragile). wrap.setAttribute('data-mode', this.mode) // data-standalone lets CSS render full-size images when the embed is // the only content on a line (vs. thumbnail when mixed with text). if (this.standalone) wrap.setAttribute('data-standalone', '')
// Prefer alias as alt text when present (Obsidian convention), but // skip aliases that are pure sizing specs (e.g., `100`, `100x200`) // — Obsidian uses these for image sizing, not as alt text. In that // case, fall back to the filename (without anchor) for alt. const filename = this.target.split('#')[0] const alt = this.alias && !isSizingSpec(this.alias) ? this.alias : filename
const placeholder = document.createElement('span') placeholder.className = 'cm-image-embed-loading-text' placeholder.textContent = `Loading ${alt}…` wrap.appendChild(placeholder)
const showError = () => { if (this.aborted) return wrap.classList.remove('cm-image-embed-loading', 'cm-image-embed-loaded') wrap.classList.add('cm-image-embed-error') wrap.textContent = `Could not load ${alt}` }
this.resolver.resolveImageUrl(this.target).then( (url) => { if (this.aborted) return if (url) { wrap.classList.remove('cm-image-embed-loading') wrap.classList.add('cm-image-embed-loaded') wrap.textContent = '' const img = document.createElement('img') img.className = 'cm-image-embed-img' // Note: loading="lazy" is intentionally NOT set — these images // are served from blob URLs that are already in memory, so // deferring decode/layout buys nothing and just adds an extra // render frame before the image appears. img.src = url img.alt = alt // If the browser fails to actually fetch/decode the image // (corrupted file, unsupported format, blob URL revoked between // resolve and load), swap the wrapper into the error state. // Visual-only failure — never throws, never mutates the doc. img.addEventListener('error', () => showError()) wrap.appendChild(img) } else { showError() } }, () => showError(), )
return wrap }
destroy(): void { // Set the abort flag so any in-flight resolver promise that resolves // AFTER the widget has been torn down (cursor moved, doc edited away, // editor unmounted) becomes a no-op when its `.then()` callback runs. // Without this, a late resolver could mutate DOM that no longer // belongs to a live widget — at best wasted work, at worst a memory // leak from an orphan <img> retaining a blob URL. this.aborted = true }
eq(other: ImageEmbedWidget): boolean { return this.target === other.target && this.alias === other.alias && this.resolver === other.resolver && this.mode === other.mode && this.standalone === other.standalone }
ignoreEvent(): boolean { return false }}
function buildDecorationsUnprofiled( state: EditorState, config: ImageEmbedConfig,): DecorationSet { const builder = new RangeSetBuilder<Decoration>() const resolver = config.resolver if (!resolver) return builder.finish()
const doc = state.doc // Empty documents have no lines to scan; bail early. if (doc.length === 0) return builder.finish() if (!hasImageEmbedSyntax(doc)) return builder.finish() const frontmatter = getFrontmatterRange(state) const lastLine = doc.lines
for (let i = 1; i <= lastLine; i++) { const line = doc.line(i) // Fast pre-filter: lines that do not contain the literal `![[` // substring cannot possibly match EMBED_RE, so skip the regex AND // the syntax-tree walk in shouldSkipMarkdownEnhancement. This is // the load-bearing perf strategy for the full-doc rescan; without // it, every cursor movement on a 10k-line doc would walk the // syntax tree once per line. if (!line.text.includes('![[')) continue
// Skip lines that form galleries (handled by image-gallery-widget). // collectGalleryImages does a single scan — reuse its result. if (collectGalleryImages(state, i) !== null) continue
// Single scan: collect all embeds and compute line classification. // After this loop we know: // - Which embeds are valid images (for decoration emission) // - Total embed count + remainder (for standalone detection) const re = new RegExp(EMBED_RE.source, EMBED_RE.flags) const embeds: Array<{ target: string alias: string | undefined from: number to: number isImage: boolean }> = [] let totalEmbedChars = 0 let match: RegExpExecArray | null
while ((match = re.exec(line.text)) !== null) { const target = match[1] const alias = match[2] const isImage = ImageResolver.isImageTarget(target) const from = line.from + match.index const to = from + match[0].length
totalEmbedChars += match[0].length
if (!isImage) { embeds.push({ target, alias, from, to, isImage }) continue }
if (shouldSkipMarkdownEnhancement(state, from, to, frontmatter)) continue
// Fallback A (extended) — see plan D15. Suppress image embeds // inside table cells: a Decoration.replace would split the // cm-table-cell wrapper, and a block widget at line.to would push // the entire table out of layout. Cells stay as raw `![[…]]` text. if (isOffsetRangeInTableCell(state, from, to)) continue
embeds.push({ target, alias, from, to, isImage }) }
// Standalone: exactly one embed total, it's an image, and the // non-embed remainder is only whitespace. Lines with 2+ image // embeds are galleries (caught above). Lines with non-image embeds // (e.g., ![[note.md]]) or mixed text are not standalone. const imageEmbeds = embeds.filter(e => e.isImage) const nonEmbedLength = line.text.length - totalEmbedChars const standalone = embeds.length === 1 && embeds[0].isImage && (nonEmbedLength === 0 || line.text.replace(new RegExp(EMBED_RE.source, EMBED_RE.flags), '').trim().length === 0)
// Emit decorations from the pre-collected image embeds. // FAR embeds: inline replace decorations in document order. // NEAR embeds: collected, then emitted as block widgets at line.to. // A cursor elsewhere on the same line does not reveal their markup. const nearEmbeds: Array<{ target: string; alias: string | undefined }> = []
for (let index = 0; index < imageEmbeds.length; index++) { const embed = imageEmbeds[index] // At a shared boundary, the caret belongs to the embed on its right. // Otherwise both embeds would reveal markup at the same cursor position. const nextStartsHere = imageEmbeds[index + 1]?.from === embed.to const near = isCursorNearSameLine(state, embed.from, embed.to, 0) && !(nextStartsHere && state.selection.main.head === embed.to) if (near) { nearEmbeds.push({ target: embed.target, alias: embed.alias }) } else { builder.add( embed.from, embed.to, Decoration.replace({ widget: new ImageEmbedWidget(embed.target, embed.alias, resolver, 'inline', standalone), }), ) } }
// Emit block widgets only for embeds touching the cursor. // Each near embed gets its own block widget below the line; // CM6 stacks them in insertion order at line.to. if (nearEmbeds.length > 0) { for (const { target, alias } of nearEmbeds) { builder.add( line.to, line.to, Decoration.widget({ widget: new ImageEmbedWidget(target, alias, resolver, 'block', standalone), block: true, side: 1, }), ) } } }
return builder.finish()}
function buildDecorations( state: EditorState, config: ImageEmbedConfig,): DecorationSet { return measurePerfSample('live-preview.decoration', 'image-embed', () => buildDecorationsUnprofiled(state, config), { docLength: state.doc.length })}
export function imageEmbedWidget(config: ImageEmbedConfig): Extension { // Block decorations cannot be exposed via a ViewPlugin (CM6 throws // "Block decorations may not be specified via plugins"). We use a // StateField instead. The field recomputes whenever the doc or the // selection changes — which is exactly the set of triggers we need. // // NOTE: The pointer-select guard that previously lived here has been // removed (Option E refactor). The lightbox now uses a mousedown // handler with event.preventDefault(), which fires BEFORE CM6's // pointer-select and prevents the widget rebuild entirely. This // makes the StateField simple: just rebuild on docChanged/selection. const field = StateField.define<DecorationSet>({ create(state) { return buildDecorations(state, config) }, update(value, tr) { // Rebuild on doc change or selection change (for proximity-reveal). if (!tr.docChanged && !tr.selection) { return value } return buildDecorations(tr.state, config) }, provide: (f) => EditorView.decorations.from(f), }) return field}