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 `` 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?.("[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); }