import 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 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() /** In-flight resolution promises to deduplicate concurrent requests */ private pending = new Map>() /** 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 { // 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 { 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 = [] } }