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

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);
}