// 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() /** 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 `...` or `
`. */ 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 }