Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
4.8 kB · 128 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129// src/editor/extensions/image-embed-utils.ts/** * Shared utilities for image embed parsing, used by both * image-embed-widget.ts (single images) and image-gallery-widget.ts (galleries). */import type { EditorState, Text } from '@codemirror/state'import { ImageResolver } from '../../services/image-resolver'import { getFrontmatterRange, shouldSkipMarkdownEnhancement, isOffsetRangeInTableCell,} from './markdown-context'
/** * Regex for Obsidian-style image embeds: `![[target|optional-alias]]` * Captures: * 1 = target (filename, no `]` or `|`) * 2 = optional alias (everything between `|` and `]]`) * * IMPORTANT: This regex has the `g` flag, so reset `lastIndex` before each use * OR create a fresh instance via `new RegExp(EMBED_RE.source, EMBED_RE.flags)`. */export const EMBED_RE = /!\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/g
const embedSyntaxPresence = new WeakMap<Text, boolean>()
/** Shared document-level prefilter for the image and gallery state fields. */export function hasImageEmbedSyntax(doc: Text): boolean { const cached = embedSyntaxPresence.get(doc) if (cached !== undefined) return cached const present = doc.toString().includes('![[') embedSyntaxPresence.set(doc, present) return present}
/** * Detect Obsidian image-sizing specs in the alias slot: * - `100` → width only * - `100x200` → width x height * Anything else (e.g., `caption`, `My alt text`) is treated as a real * alias and rendered into `<img alt="...">` or `<figcaption>`. */export function isSizingSpec(alias: string): boolean { return /^\d+(?:x\d+)?$/.test(alias)}
export interface GalleryImage { target: string alias: string | undefined from: number to: number}
/** * Collect all valid image embeds on a line and determine whether the line * qualifies as a gallery (2+ image embeds with ONLY whitespace between them). * * Returns the collected images if the line is a gallery, or `null` if not. * This avoids the double-scan problem: callers that need both the boolean * check and the image list get them in one pass. * * A line qualifies as a gallery when: * 1. Every `![[...]]` embed on the line is an image target * 2. There's no non-embed, non-whitespace text on the line * 3. At least 2 image embeds pass the exclusion guards * * Examples: * `![[a.png]] ![[b.png]]` → gallery (2 images) * `Here is ![[a.png]] ![[b.png]]` → null (non-embed text) * `![[note.md]] ![[a.png]]` → null (non-image embed) * `![[a.png]]` → null (only 1 image) */export function collectGalleryImages(state: EditorState, lineNumber: number): GalleryImage[] | null { const doc = state.doc if (lineNumber < 1 || lineNumber > doc.lines) return null const line = doc.line(lineNumber) if (!line.text.includes('![[')) return null
// Structural check: scan all embeds and verify that every embed on the // line is an image target. Also track the character ranges consumed by // embeds so we can verify nothing else remains. const re = new RegExp(EMBED_RE.source, EMBED_RE.flags) const frontmatter = getFrontmatterRange(state) const images: GalleryImage[] = [] let consumed = 0 // total characters consumed by embed syntax let match: RegExpExecArray | null
while ((match = re.exec(line.text)) !== null) { const target = match[1] consumed += match[0].length
// If ANY embed on the line is not an image, the line has non-image // content and is not a gallery (e.g., `![[note.md]] ![[a.png]]`). if (!ImageResolver.isImageTarget(target)) return null
const from = line.from + match.index const to = from + match[0].length
if (shouldSkipMarkdownEnhancement(state, from, to, frontmatter)) continue if (isOffsetRangeInTableCell(state, from, to)) continue
images.push({ target, alias: match[2], from, to }) }
// Check that the non-embed remainder is only whitespace. // (line.text.length - consumed) gives the characters NOT inside embeds. const remainderLength = line.text.length - consumed if (remainderLength > 0) { // Build remainder by stripping matched embed ranges const remainder = line.text.replace(new RegExp(EMBED_RE.source, EMBED_RE.flags), '').trim() if (remainder.length > 0) return null }
return images.length >= 2 ? images : null}
/** * Quick boolean check for whether a line is a gallery line. * * Used by image-embed-widget.ts to skip gallery lines. For callers that * also need the image list, use `collectGalleryImages()` directly. * * This function is in the shared utils module to prevent a circular * dependency between image-embed-widget and image-gallery-widget. */export function isGalleryLine(state: EditorState, lineNumber: number): boolean { return collectGalleryImages(state, lineNumber) !== null}