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

469 lines
17 KiB
TypeScript

import { mountFilmstrip } from "./filmstrip.ts";
import { richPdfRenderer } from "./renderers/pdf-rich.ts";
import { mountExtractedText } from "./text.ts";
import { resolve } from "./resolve.ts";
import { defaultRegistry, downloadRenderer } from "./registry.ts";
import { elementFactory, setButtonLabel } from "./renderers/dom.ts";
import { viewerActions, type ViewerAction, type ViewerActionContext } from "./actions.ts";
import { canPrint, printViewer } from "./print.ts";
import { sniff } from "./sniff.ts";
import { directUrl, readSource, safeSourceUrl } from "./source.ts";
import { resolveKey } from "./uploads/client.ts";
import { startViewerTriggers } from "./overlay.ts";
import type { Renderer, ViewerKind, ViewerSource } from "./types.ts";
const DEFAULT_CAP = 2 * 1024 * 1024;
function formatBytes(bytes: number): string {
const units = ["B", "KB", "MB", "GB"];
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit++;
}
return `${unit === 0 ? value : value.toFixed(1)} ${units[unit]}`;
}
/** Mount a preview, including local File/Blob sources. Dispose before reusing the host. */
export interface MountViewerOptions {
richPdf?: boolean;
/**
* Opens covering the page, with a Close control that returns it inline. This
* is a layered overlay rather than the browser's Fullscreen API, so it needs
* no user gesture and can be the state a viewer mounts in.
*/
fullScreen?: boolean;
/** `page` drops the card chrome and fills its container, for a preview route. */
layout?: "inline" | "page";
/** Actions for this viewer only, offered after the globally registered ones. */
actions?: ViewerAction[];
/** Drops the built-in Print action when an app has its own. */
print?: boolean;
}
export function mountViewer(
host: HTMLElement,
input: ViewerSource | ViewerSource[],
options: MountViewerOptions = {},
): () => void {
if (Array.isArray(input)) return mountFilmstrip(host, input, options);
const original = [...host.childNodes].map((node) => node.cloneNode(true));
const registry = defaultRegistry.fork();
if (options.richPdf) registry.register(richPdfRenderer());
const controller = new AbortController();
// Reassigned once a storage key has been exchanged for a URL.
let source = input;
const doc = host.ownerDocument;
const element = elementFactory(doc);
let disposed = false;
let cleanup: (() => void) | undefined;
const urls: string[] = [];
const objectUrl = (blob: Blob) => {
const url = URL.createObjectURL(blob);
urls.push(url);
return url;
};
host.classList.add("wrn-viewer");
if (options.layout === "page") host.classList.add("wrn-viewer-as-page");
host.setAttribute("aria-label", source.name || "File preview");
let expanded = options.fullScreen === true;
const setExpanded = (next: boolean) => {
expanded = next;
host.classList.toggle("wrn-viewer-expanded", expanded);
// The control swaps between Close and Expand, so re-offer the toolbar.
offerActions(lastRenderer, lastKind);
};
if (expanded) host.classList.add("wrn-viewer-expanded");
const toolbar = host.querySelector<HTMLElement>("[data-viewer-toolbar]") ?? element("div");
toolbar.classList.add("wrn-viewer-toolbar");
toolbar.setAttribute("role", "toolbar");
toolbar.setAttribute("aria-label", "File preview controls");
const title =
toolbar.querySelector<HTMLElement>("[data-viewer-title]") ??
element("span", source.name || "File");
title.textContent = source.name || "File";
if (!toolbar.contains(title)) toolbar.append(title);
const controls = toolbar.querySelector<HTMLElement>("[data-viewer-controls]") ?? element("span");
const actions = toolbar.querySelector<HTMLElement>("[data-viewer-actions]") ?? element("span");
if (!toolbar.contains(actions)) toolbar.append(actions);
if (!toolbar.contains(controls)) toolbar.append(controls);
const content = host.querySelector<HTMLElement>("[data-viewer-content]") ?? element("div");
content.classList.add("wrn-viewer-content");
const status = element("p", "Loading preview…");
status.setAttribute("role", "status");
content.replaceChildren(status);
if (!host.contains(toolbar)) host.append(toolbar);
if (!host.contains(content)) host.append(content);
const button = (label: string, action: () => void, iconName?: string) => {
const node = element("button");
node.type = "button";
node.className = "wrn-viewer-action";
setButtonLabel(node, label, iconName);
node.addEventListener("click", action);
controls.append(node);
return node;
};
const actionButton = (label: string, run: () => void, iconName?: string) => {
const node = element("button");
node.type = "button";
node.className = "wrn-viewer-action";
setButtonLabel(node, label, iconName);
node.addEventListener("click", run);
actions.append(node);
return node;
};
if (host.requestFullscreen)
actionButton("Fullscreen", () => {
const promise =
doc.fullscreenElement === host ? doc.exitFullscreen() : host.requestFullscreen();
promise.catch(() => {});
});
const downloadUrl = source.blob
? objectUrl(source.blob)
: source.src && safeSourceUrl(source.src);
const downloadLink = (url: string) => {
const link = element("a");
link.href = url;
link.download = source.name;
link.rel = "noopener noreferrer";
link.className = "wrn-viewer-action";
setButtonLabel(link, "Download");
actions.append(link);
};
if (downloadUrl) downloadLink(downloadUrl);
const showDownload = (reason = "Preview unavailable for this file") => {
cleanup?.();
cleanup = undefined;
controls.replaceChildren();
const template = host.querySelector<HTMLTemplateElement>(
'[data-viewer-template="DownloadCard"]',
);
content.replaceChildren(template ? template.content.cloneNode(true) : element("p", reason));
const reasonNode = content.querySelector("[data-viewer-reason]");
if (reasonNode) reasonNode.textContent = reason;
const bytes = source.blob?.size ?? source.size;
const meta = [source.name, bytes === undefined ? "" : formatBytes(bytes)]
.filter(Boolean)
.join(" · ");
const metaNode = content.querySelector("[data-viewer-size]");
if (metaNode) metaNode.textContent = meta;
else if (meta) content.append(element("p", meta));
content.setAttribute("data-viewer-state", "fallback");
// An app action may still apply to a file the viewer cannot render.
offerActions();
};
let lastRenderer: Renderer | undefined;
let lastKind: ViewerKind | undefined;
async function render(renderer: Renderer, blob: Blob | undefined, kind: ViewerKind) {
if (renderer === downloadRenderer) {
showDownload();
return;
}
if (!renderer.mount)
throw new Error(`Renderer "${renderer.component}" has no browser mount adapter`);
controls.replaceChildren();
const aliases: Record<string, string> = {
SvgViewer: "VectorViewer",
HtmlViewer: "DocViewer",
DocxViewer: "DocViewer",
ConvertedViewer: "DocViewer",
TableViewer: "SheetViewer",
JsonViewer: "CodeViewer",
XmlViewer: "CodeViewer",
ZipViewer: "ArchiveViewer",
RichPdfViewer: "PdfViewer",
};
const template = [...host.querySelectorAll<HTMLTemplateElement>("[data-viewer-template]")].find(
(node) => node.dataset.viewerTemplate === (aliases[renderer.component] ?? renderer.component),
);
content.replaceChildren(...(template ? [template.content.cloneNode(true)] : []));
const parent = content.querySelector<HTMLElement>("[data-viewer-mount]") ?? content;
content.setAttribute("data-viewer-state", "ready");
const dispose = await renderer.mount({
parent,
file: blob,
sourceUrl: async () => {
if (!blob) {
const url = directUrl(source);
if (!url) throw new Error("No readable file source");
return url;
}
// Serving the body under its sniffed type keeps a mislabelled response
// from choosing how the element interprets it.
const detected = sniff(new Uint8Array(await blob.slice(0, 4096).arrayBuffer()));
return objectUrl(new Blob([blob], { type: detected.mime ?? "application/octet-stream" }));
},
source,
kind,
button,
objectUrl,
degrade: showDownload,
signal: controller.signal,
});
if (disposed) {
dispose?.();
return;
}
cleanup = dispose || undefined;
lastRenderer = renderer;
lastKind = kind;
offerActions(renderer, kind);
}
/**
* Built-in and app-supplied actions, offered after every render so a `when`
* predicate can look at what actually drew — printing is meaningless on a
* download card, and an app action may only suit one kind.
*/
function offerActions(renderer?: Renderer, kind?: ViewerKind) {
// Re-offered after every render, so anything from the previous one goes
// first; the actions slot is not cleared by the render itself.
for (const node of host.querySelectorAll("[data-viewer-action]")) node.remove();
const context: ViewerActionContext = { host, content, source, renderer, kind };
const builtIn: ViewerAction[] = [
...(options.print === false
? []
: [
{
id: "print",
label: "Print",
icon: "print",
placement: "actions" as const,
when: canPrint,
run: printViewer,
},
]),
// Offered only once the viewer is covering the page, so an inline viewer
// is not cluttered with a control that would do nothing.
...(expanded
? [
{
id: "close",
label: "Close",
icon: "close",
placement: "actions" as const,
run: () => setExpanded(false),
},
]
: []),
];
for (const action of [...builtIn, ...viewerActions(), ...(options.actions ?? [])]) {
if (action.when && !action.when(context)) continue;
const node = element("button");
node.type = "button";
node.className = "wrn-viewer-action";
setButtonLabel(node, action.label, action.icon);
node.dataset.viewerAction = action.id;
node.addEventListener("click", () => void action.run(context));
(action.placement === "controls" ? controls : actions).append(node);
}
}
void (async () => {
if (source.key && !source.src && !source.blob) {
const resolved = await resolveKey(source, {
signal: controller.signal,
baseURI: doc.baseURI,
});
if (disposed) return;
if (!resolved) {
showDownload("This file is not available to preview");
return;
}
source = resolved;
title.textContent = source.name || "File";
host.setAttribute("aria-label", source.name || "File preview");
const url = safeSourceUrl(source.src ?? "");
// The download control could not be built before the link existed.
if (url) downloadLink(url);
}
if (source.src) source = { ...source, src: new URL(source.src, doc.baseURI).href };
const stopText = mountExtractedText(actions, source, controller.signal);
controller.signal.addEventListener("abort", stopText, { once: true });
const plan = await resolve(source, {
signal: controller.signal,
baseURI: doc.baseURI,
registry: options.richPdf ? registry : undefined,
});
if (disposed) return;
if (plan.renderer === downloadRenderer) {
showDownload(plan.reason);
return;
}
try {
// A streaming renderer is pointed at the original URL: the browser fetches
// and decodes it, so nothing is buffered here and playback can start early.
// Trying every leading streaming candidate before touching the body keeps
// the ladder intact — a failed conversion still falls through to the
// in-package reader rather than straight to the download card.
const streamable = directUrl(source) ? [plan.renderer, ...plan.fallback] : [];
for (const renderer of streamable) {
if (!renderer.streaming) break;
if (disposed) return;
try {
await render(renderer, undefined, plan.kind);
return;
} catch {
cleanup?.();
cleanup = undefined;
}
}
// The body only serves renderers that actually read it, so the cap comes
// from those. A streaming renderer's open-ended limit must never become
// the size of an unbounded download.
const readers = [plan.renderer, ...plan.fallback].filter(
(renderer) => renderer !== downloadRenderer && !(renderer.streaming && directUrl(source)),
);
const cap = readers.reduce(
(limit, renderer) => Math.max(limit, renderer.maxInlineBytes ?? DEFAULT_CAP),
0,
);
const blob = await readSource(source, Number.isFinite(cap) && cap > 0 ? cap : DEFAULT_CAP, {
signal: controller.signal,
});
// Resolve the actual body again: a URL may change between HEAD/range/body.
const verified = await resolve(
{ ...source, blob },
{
signal: controller.signal,
baseURI: doc.baseURI,
registry: options.richPdf ? registry : undefined,
},
);
for (const renderer of [verified.renderer, ...verified.fallback]) {
if (disposed) return;
if (renderer !== downloadRenderer && blob.size > (renderer.maxInlineBytes ?? DEFAULT_CAP))
continue;
try {
await render(renderer, blob, verified.kind);
return;
} catch {
cleanup?.();
cleanup = undefined;
}
}
if (!disposed) showDownload();
} catch {
if (!disposed) showDownload("File could not be loaded within the preview limit");
}
})().catch(() => {
if (!disposed) showDownload("File could not be loaded");
});
return () => {
disposed = true;
controller.abort();
cleanup?.();
for (const url of urls) URL.revokeObjectURL(url);
host.replaceChildren(...original);
};
}
function parseFiles(value?: string): ViewerSource[] | undefined {
if (!value) return;
try {
const files: unknown = JSON.parse(value);
if (
Array.isArray(files) &&
files.length &&
files.length <= 500 &&
files.every(
(item) =>
item &&
typeof item === "object" &&
typeof item.name === "string" &&
(typeof item.src === "string" || typeof item.key === "string"),
)
)
return files;
} catch {
/* Invalid serialized file lists fall back to the single source. */
}
}
/** Observe framework navigation and changed source attributes. */
export function startViewerRuntime(root: Document = document): () => void {
const mounted = new Map<HTMLElement, { signature: string; dispose: () => void }>();
const scan = () => {
for (const [host, state] of mounted)
if (!root.contains(host)) {
state.dispose();
mounted.delete(host);
}
for (const host of root.querySelectorAll<HTMLElement>('[data-wrnexus-runtime="viewer"]')) {
const signature = [
host.dataset.files,
host.dataset.richPdf,
host.dataset.fullScreen,
host.dataset.layout,
host.dataset.src,
host.dataset.key,
host.dataset.store,
host.dataset.name,
host.dataset.mime,
host.dataset.size,
].join("\n");
if (mounted.get(host)?.signature === signature) continue;
mounted.get(host)?.dispose();
const size = Number(host.dataset.size);
mounted.set(host, {
signature,
dispose: mountViewer(
host,
parseFiles(host.dataset.files) ?? {
src: host.dataset.src,
key: host.dataset.key,
store: host.dataset.store,
name: host.dataset.name ?? "",
mime: host.dataset.mime,
size: host.dataset.size && Number.isFinite(size) && size >= 0 ? size : undefined,
},
{
richPdf: host.dataset.richPdf === "true",
fullScreen: host.dataset.fullScreen === "true",
layout: host.dataset.layout === "page" ? "page" : "inline",
},
),
});
}
};
const stopTriggers = startViewerTriggers(root);
const Observer = root.defaultView?.MutationObserver ?? MutationObserver;
const observer = new Observer(scan);
scan();
observer.observe(root.documentElement, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: [
"data-full-screen",
"data-layout",
"data-files",
"data-rich-pdf",
"data-src",
"data-key",
"data-store",
"data-name",
"data-mime",
"data-size",
],
});
return () => {
stopTriggers();
observer.disconnect();
for (const item of mounted.values()) item.dispose();
mounted.clear();
};
}
if (typeof document !== "undefined") {
if (document.readyState === "loading")
document.addEventListener("DOMContentLoaded", () => startViewerRuntime(), { once: true });
else startViewerRuntime();
}