142 lines
5.5 KiB
TypeScript
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);
|
|
};
|
|
}
|