128 lines
4.0 KiB
TypeScript
128 lines
4.0 KiB
TypeScript
import { mountViewer, type MountViewerOptions } from "./runtime.ts";
|
|
import type { ViewerSource } from "./types.ts";
|
|
|
|
export interface OpenViewerOptions extends MountViewerOptions {
|
|
/** Document to open in. Defaults to the ambient one. */
|
|
document?: Document;
|
|
/** Called once the overlay has closed, however it was closed. */
|
|
onClose?: () => void;
|
|
/** Accessible name for the dialog. Defaults to the file's name. */
|
|
label?: string;
|
|
}
|
|
|
|
export interface OpenedViewer {
|
|
close: () => void;
|
|
dialog: HTMLElement;
|
|
}
|
|
|
|
/**
|
|
* Opens a file over the page, for the "click a button, preview the file" case.
|
|
*
|
|
* A native `<dialog>` does the work that is easy to get wrong by hand: it takes
|
|
* the top layer, traps focus, closes on Escape, and restores focus to whatever
|
|
* opened it. Closing also disposes the viewer, so a video stops and object URLs
|
|
* are revoked rather than leaking behind the overlay.
|
|
*/
|
|
export function openViewer(
|
|
source: ViewerSource | ViewerSource[],
|
|
options: OpenViewerOptions = {},
|
|
): OpenedViewer {
|
|
const doc = options.document ?? document;
|
|
const dialog = doc.createElement("dialog");
|
|
dialog.className = "wrn-viewer-dialog";
|
|
const first = Array.isArray(source) ? source[0] : source;
|
|
dialog.setAttribute("aria-label", options.label ?? first?.name ?? "File preview");
|
|
|
|
const host = doc.createElement("section");
|
|
host.className = "wrn-viewer";
|
|
dialog.append(host);
|
|
doc.body.append(dialog);
|
|
|
|
let closed = false;
|
|
// Assigned below; `close` may run before the viewer has finished mounting.
|
|
let dispose: (() => void) | undefined;
|
|
const close = (): void => {
|
|
if (closed) return;
|
|
closed = true;
|
|
dispose?.();
|
|
// `close()` on an open dialog fires `close`, which lands back here; the
|
|
// guard above makes that a no-op rather than a loop.
|
|
if (dialog.open) dialog.close();
|
|
dialog.remove();
|
|
options.onClose?.();
|
|
};
|
|
|
|
dialog.addEventListener("close", close);
|
|
// The dialog element fills the viewport, so a click that lands on it rather
|
|
// than on the viewer inside is a click on the backdrop.
|
|
dialog.addEventListener("click", (event) => {
|
|
if (event.target === dialog) close();
|
|
});
|
|
|
|
const stop = mountViewer(host, source, {
|
|
...options,
|
|
actions: [
|
|
{
|
|
id: "close",
|
|
label: "Close preview",
|
|
icon: "close",
|
|
placement: "actions",
|
|
run: close,
|
|
},
|
|
...(options.actions ?? []),
|
|
],
|
|
});
|
|
|
|
// If closing raced the mount, tear the viewer down rather than orphan it.
|
|
if (closed) stop();
|
|
else dispose = stop;
|
|
|
|
if (typeof dialog.showModal === "function") dialog.showModal();
|
|
else dialog.setAttribute("open", "");
|
|
return { close, dialog };
|
|
}
|
|
|
|
/** Reads a trigger's data attributes into the source(s) it points at. */
|
|
export function sourceFromTrigger(trigger: HTMLElement): ViewerSource | ViewerSource[] | undefined {
|
|
const { src, key, store, name, mime, size, files } = trigger.dataset;
|
|
if (files) {
|
|
try {
|
|
const parsed = JSON.parse(files) as ViewerSource[];
|
|
if (Array.isArray(parsed) && parsed.length) return parsed;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
if (!src && !key) return undefined;
|
|
const bytes = Number(size);
|
|
return {
|
|
src,
|
|
key,
|
|
store,
|
|
name: name ?? "",
|
|
mime,
|
|
size: size && Number.isFinite(bytes) && bytes >= 0 ? bytes : undefined,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Any element carrying `data-viewer-open` opens a preview when clicked, so a
|
|
* plain button in a list of files needs no JavaScript of its own.
|
|
*/
|
|
export function startViewerTriggers(root: Document = document): () => void {
|
|
const onClick = (event: MouseEvent) => {
|
|
const target = event.target as Element | null;
|
|
const trigger = target?.closest?.<HTMLElement>("[data-viewer-open]");
|
|
if (!trigger) return;
|
|
const source = sourceFromTrigger(trigger);
|
|
if (!source) return;
|
|
event.preventDefault();
|
|
openViewer(source, {
|
|
document: root,
|
|
richPdf: trigger.dataset.richPdf === "true",
|
|
});
|
|
};
|
|
root.addEventListener("click", onClick);
|
|
return () => root.removeEventListener("click", onClick);
|
|
}
|