import { mountViewer, type MountViewerOptions } from "./runtime.ts"; import { convertUrl, converterFormats } from "./convert/client.ts"; import { extensionOf, kindOfExtension } from "./kinds.ts"; import { readBounded, safeSourceUrl } from "./source.ts"; import { resolveKey } from "./uploads/client.ts"; import { resolve } from "./resolve.ts"; import { sniff } from "./sniff.ts"; import type { ViewerSource } from "./types.ts"; /** One active preview; switching always disposes its editor, media and pending work. */ export function mountFilmstrip( host: HTMLElement, files: ViewerSource[], options: MountViewerOptions = {}, ): () => void { files = files.slice(0, 500); const original = [...host.childNodes]; const doc = host.ownerDocument; const templates = original.map((node) => node.cloneNode(true)); const controller = new AbortController(); const urls: string[] = []; const stage = doc.createElement("div"); const strip = doc.createElement("div"); strip.className = "wrn-viewer-filmstrip"; strip.setAttribute("role", "group"); strip.setAttribute("aria-label", "Files to preview"); const status = doc.createElement("p"); status.setAttribute("role", "status"); host.replaceChildren(stage, status, strip); let dispose: (() => void) | undefined; let selected = -1; const buttons: HTMLButtonElement[] = []; const show = (index: number, focus = false) => { if (!files.length || index === selected) return; dispose?.(); selected = Math.max(0, Math.min(files.length - 1, index)); stage.replaceChildren(...templates.map((node) => node.cloneNode(true))); dispose = mountViewer(stage, files[selected]!, options); buttons.forEach((button, i) => { button.setAttribute("aria-pressed", String(i === selected)); button.tabIndex = i === selected ? 0 : -1; }); status.textContent = `${selected + 1} of ${files.length}: ${files[selected]!.name}`; if (focus) buttons[selected]?.focus(); }; for (const [index, file] of files.slice(0, 500).entries()) { const button = doc.createElement("button"); button.type = "button"; button.className = "wrn-viewer-file"; button.textContent = file.name || "File"; button.onclick = () => show(index); button.onkeydown = (event) => { const next = event.key === "ArrowRight" ? Math.min(files.length - 1, selected + 1) : event.key === "ArrowLeft" ? Math.max(0, selected - 1) : event.key === "Home" ? 0 : event.key === "End" ? files.length - 1 : undefined; if (next !== undefined) { event.preventDefault(); show(next, true); } }; buttons.push(button); strip.append(button); } if (files.length) show(0); else status.textContent = "No files to preview."; // Sequential thumbnail work bounds concurrent conversion and source requests. void (async () => { const formats = await converterFormats({ baseURI: doc.baseURI }); for (const [index, file] of files.slice(0, 500).entries()) { if (controller.signal.aborted) return; try { const source = file.key && !file.src ? await resolveKey(file, { baseURI: doc.baseURI, signal: controller.signal }) : file; if (!source || controller.signal.aborted) continue; let url: string | undefined; if (kindOfExtension(extensionOf(source.name)) === "image") { const normalized = source.src ? { ...source, src: new URL(source.src, doc.baseURI).href } : source; const plan = await resolve(normalized, { baseURI: doc.baseURI, signal: controller.signal, }); if ( plan.kind !== "image" || plan.renderer.component === "DownloadCard" || controller.signal.aborted ) continue; if (source.blob) { const type = sniff(new Uint8Array(await source.blob.slice(0, 4096).arrayBuffer())).mime; if (controller.signal.aborted) return; url = URL.createObjectURL(new Blob([source.blob], { type })); urls.push(url); } else if (normalized.src) url = safeSourceUrl(normalized.src); } else if (formats[extensionOf(source.name)]) { const endpoint = convertUrl(source, "thumb", doc.baseURI); if (!endpoint) continue; const response = await fetch(endpoint, { signal: controller.signal }); if (!/^image\/(png|jpeg|webp)$/.test(response.headers.get("content-type") ?? "")) { await response.body?.cancel(); continue; } const bytes = await readBounded(response, 2 * 1024 * 1024); if (controller.signal.aborted) return; url = URL.createObjectURL( new Blob([bytes as Uint8Array], { type: response.headers.get("content-type")!, }), ); urls.push(url); } if (url && !controller.signal.aborted) { const img = doc.createElement("img"); img.alt = ""; img.loading = "lazy"; img.src = url; img.onerror = () => img.remove(); buttons[index]!.prepend(img); } } catch { /* The filename remains a useful thumbnail fallback. */ } } })().catch(() => {}); return () => { controller.abort(); dispose?.(); urls.forEach((url) => URL.revokeObjectURL(url)); host.replaceChildren(...original); }; }