469 lines
17 KiB
TypeScript
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();
|
|
}
|