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,127 @@
|
||||
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);
|
||||
}
|
||||
Reference in new issue
Block a user