Files
WRNexusJS/packages/viewer/src/filmstrip.ts
T

142 lines
5.5 KiB
TypeScript

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<ArrayBuffer>], {
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);
};
}