Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190import type { Vault } from '../plugin/vault'import type { TFile, TAbstractFile } from '../plugin/files'import type { EventRef } from '../plugin/events'import { mimeTypeFromExtension } from '../types/pushwork'
/** * Set of image file extensions (lowercase). * Matches Obsidian's supported image formats. */// NOTE: SVG files are rendered via <img> which does not execute embedded// scripts (safe from XSS). However, SVGs can reference external resources.// For a local-first app this is acceptable. If Reams adds network sync// with untrusted content, SVG sanitization should be revisited.const IMAGE_EXTENSIONS = new Set([ 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'bmp', 'ico',])
/** * ImageResolver maps file references to displayable blob URLs. * * Given a wikilink embed target like "my-image.png", it: * 1. Looks up the file in the Vault by path * 2. Reads the binary content * 3. Creates a blob URL via URL.createObjectURL() * 4. Caches the result to avoid re-reading on subsequent renders * * Cache is invalidated when vault files are modified or deleted. * Cache size is bounded (FIFO eviction) — oldest entries by insertion * time are evicted when the limit is reached to prevent unbounded * memory growth on mobile. * Blob URLs are revoked when dispose() is called (app unmount). */export class ImageResolver { /** Default maximum cached blob URLs before evicting oldest entries. */ static readonly DEFAULT_MAX_CACHE_SIZE = 100
private vault: Vault private readonly maxCacheSize: number /** Cache: vault path → blob URL. Insertion-ordered (Map spec). */ private cache = new Map<string, string>() /** In-flight resolution promises to deduplicate concurrent requests */ private pending = new Map<string, Promise<string | null>>() /** Vault event subscriptions for cache invalidation */ private eventRefs: EventRef[] = []
constructor(vault: Vault, maxCacheSize = ImageResolver.DEFAULT_MAX_CACHE_SIZE) { this.vault = vault this.maxCacheSize = maxCacheSize
// Invalidate cached blob URL when a file is modified (content changed). this.eventRefs.push( vault.on('modify', (file: TFile) => { this.invalidate(file.path) }) )
// Invalidate cached blob URL when a file is deleted. this.eventRefs.push( vault.on('delete', (file: TAbstractFile) => { this.invalidate(file.path) }) )
// Invalidate cached blob URL when a file is renamed (old path is stale). this.eventRefs.push( vault.on('rename', (_file: TAbstractFile, oldPath: string) => { this.invalidate(oldPath) }) ) }
/** * Invalidate the cached blob URL for a specific path. * Revokes the blob URL to free memory. */ private invalidate(path: string): void { const cached = this.cache.get(path) if (cached) { URL.revokeObjectURL(cached) this.cache.delete(path) } }
/** * Check if a file extension is a recognized image type. */ static isImageExtension(ext: string): boolean { return IMAGE_EXTENSIONS.has(ext.toLowerCase()) }
/** * Get a cached blob URL for a path if it exists. * Returns null if the path is not cached. * This is a synchronous lookup for use by adapter.getResourcePath(). */ getCachedUrl(path: string): string | null { return this.cache.get(path) ?? null }
/** * Check if an embed target references an image file. * Strips anchor fragments and checks extension case-insensitively. */ static isImageTarget(target: string): boolean { const path = target.split('#')[0] const dotIndex = path.lastIndexOf('.') if (dotIndex <= 0) return false const ext = path.slice(dotIndex + 1).toLowerCase() return IMAGE_EXTENSIONS.has(ext) }
/** * Resolve a file reference to a displayable blob URL. * * Returns cached URL if available. Otherwise reads binary data * from Vault and creates a new blob URL. * * Returns null if the file doesn't exist or isn't binary. */ async resolveImageUrl(reference: string): Promise<string | null> { // Strip anchor fragment const path = reference.split('#')[0]
// Check cache const cached = this.cache.get(path) if (cached) return cached
// Deduplicate concurrent requests for the same path const inflight = this.pending.get(path) if (inflight) return inflight
const promise = this._resolve(path) this.pending.set(path, promise)
try { return await promise } finally { this.pending.delete(path) } }
private async _resolve(path: string): Promise<string | null> { const file = this.vault.getFileByPath(path) if (!file) return null
let data: ArrayBuffer try { data = await this.vault.readBinary(file) } catch { // File has no binary content or read error - return null return null }
const mimeType = mimeTypeFromExtension(file.extension) const blob = new Blob([data], { type: mimeType }) const blobUrl = URL.createObjectURL(blob)
this.cache.set(path, blobUrl)
// Evict oldest entries if cache exceeds size limit. // Map iteration order is insertion order per spec. while (this.cache.size > this.maxCacheSize) { const oldest = this.cache.keys().next().value! const oldUrl = this.cache.get(oldest)! URL.revokeObjectURL(oldUrl) this.cache.delete(oldest) }
return blobUrl }
/** * Revoke all cached blob URLs, clear the cache, and unsubscribe * from vault events. Call on app unmount to prevent memory leaks. */ dispose(): void { for (const url of this.cache.values()) { URL.revokeObjectURL(url) } this.cache.clear() this.pending.clear()
// Unsubscribe from vault events for (const ref of this.eventRefs) { this.vault.offref(ref) } this.eventRefs = [] }}