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 = []
}
}