Ship file viewer, editor improvements, and private package releases
This commit is contained in:
1 parent
645914955c
commit
a4a31032b9
158 files changed
+10523
-137
No files matched your search
@@ -0,0 +1,141 @@
|
||||
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);
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user