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("[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("[data-viewer-title]") ?? element("span", source.name || "File"); title.textContent = source.name || "File"; if (!toolbar.contains(title)) toolbar.append(title); const controls = toolbar.querySelector("[data-viewer-controls]") ?? element("span"); const actions = toolbar.querySelector("[data-viewer-actions]") ?? element("span"); if (!toolbar.contains(actions)) toolbar.append(actions); if (!toolbar.contains(controls)) toolbar.append(controls); const content = host.querySelector("[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( '[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 = { SvgViewer: "VectorViewer", HtmlViewer: "DocViewer", DocxViewer: "DocViewer", ConvertedViewer: "DocViewer", TableViewer: "SheetViewer", JsonViewer: "CodeViewer", XmlViewer: "CodeViewer", ZipViewer: "ArchiveViewer", RichPdfViewer: "PdfViewer", }; const template = [...host.querySelectorAll("[data-viewer-template]")].find( (node) => node.dataset.viewerTemplate === (aliases[renderer.component] ?? renderer.component), ); content.replaceChildren(...(template ? [template.content.cloneNode(true)] : [])); const parent = content.querySelector("[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 void }>(); const scan = () => { for (const [host, state] of mounted) if (!root.contains(host)) { state.dispose(); mounted.delete(host); } for (const host of root.querySelectorAll('[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(); }