Ship file viewer, editor improvements, and private package releases

This commit is contained in:
Clintchiz committed 2026-09-09 11:20:11 +05:30
1 parent 645914955c
commit a4a31032b9
158 files changed
+10523 -137

No files matched your search

+38
View File
@@ -0,0 +1,38 @@
import { gunzip, gzipName, isTar, parseTar, type ArchiveEntry } from "../parse/archive.ts";
import { elementFactory, requireFile } from "./dom.ts";
import { entriesSummary, formatSize, renderEntries } from "./table-view.ts";
import type { ViewerMount } from "../types.ts";
const DECOMPRESS_CAP = 64 * 1024 * 1024;
export const mountArchive: ViewerMount = async ({ parent, file, source, signal }) => {
const element = elementFactory(parent.ownerDocument);
const input = requireFile(file);
let bytes: Uint8Array<ArrayBufferLike> = new Uint8Array(await input.arrayBuffer());
if (signal.aborted) return;
let note = "";
if (bytes[0] === 0x1f && bytes[1] === 0x8b) {
const inner = gzipName(bytes);
const expanded = await gunzip(input, DECOMPRESS_CAP);
if (signal.aborted) return;
if (!expanded) throw new Error("Compressed listing unavailable");
note = `gzip, ${formatSize(bytes.length)} compressed to ${formatSize(expanded.length)}`;
bytes = expanded;
if (!isTar(bytes)) {
// A plain gzipped file, not an archive: report what it holds.
parent.append(element("p", note));
parent.append(element("p", inner ?? "Single compressed file"));
return;
}
}
if (!isTar(bytes)) throw new Error("Unsupported archive format");
const entries: ArchiveEntry[] = parseTar(bytes);
if (!entries.length) throw new Error("Archive holds no readable entries");
parent.append(
element("p", entriesSummary(entries, note)),
renderEntries(parent.ownerDocument, entries, `Contents of ${source.name}`),
);
};
@@ -0,0 +1,188 @@
import { setButtonLabel } from "./dom.ts";
import { readSource } from "../source.ts";
import type { ViewerSource } from "../types.ts";
import { waveformPeaks } from "./waveform.ts";
const clock = (seconds: number) => {
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
const whole = Math.floor(seconds);
return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, "0")}`;
};
/**
* One transport for audio instead of two.
*
* The native controls and a separate seek slider were both on screen, each able to
* move the same playhead. This is a single row — play, elapsed, a seekable
* track, duration, mute — with the waveform drawn *into* the track so the
* envelope and the progress are the same control rather than two stacked bars.
*/
export function mountAudioPlayer(
parent: HTMLElement,
audio: HTMLMediaElement,
source: ViewerSource,
signal: AbortSignal,
): () => void {
const doc = parent.ownerDocument;
const view = doc.defaultView;
const make = <K extends keyof HTMLElementTagNameMap>(tag: K, className?: string) => {
const node = doc.createElement(tag);
if (className) node.className = className;
return node;
};
const shell = make("div", "wrn-viewer-player");
const transport = make("div", "wrn-viewer-player-transport");
const play = make("button", "wrn-viewer-action wrn-viewer-play");
play.type = "button";
setButtonLabel(play, "Play", "play");
const elapsed = make("span", "wrn-viewer-time");
elapsed.textContent = "0:00";
const duration = make("span", "wrn-viewer-time");
duration.textContent = "0:00";
const mute = make("button", "wrn-viewer-action");
mute.type = "button";
setButtonLabel(mute, "Mute", "volume");
const track = make("div", "wrn-viewer-track");
const canvas = make("canvas", "wrn-viewer-waveform");
canvas.setAttribute("aria-hidden", "true");
const progress = make("div", "wrn-viewer-progress");
const seek = make("input", "wrn-viewer-seek");
seek.type = "range";
seek.min = "0";
seek.max = "1000";
seek.value = "0";
seek.setAttribute("aria-label", `Seek ${source.name || "audio"}`);
track.append(canvas, progress, seek);
transport.append(play, elapsed, track, duration, mute);
const note = make("p", "wrn-viewer-note");
note.setAttribute("role", "status");
shell.append(transport, note);
parent.append(shell);
const BINS = 200;
let envelope: number[] = [];
/**
* Drawn at device resolution and redrawn on resize: a fixed bitmap stretched
* to the track is what made the bars look soft and blobby.
*/
const drawPeaks = (peaks = envelope) => {
envelope = peaks;
const box = canvas.getBoundingClientRect();
const ratio = Math.min(view?.devicePixelRatio ?? 1, 2);
const width = Math.max(1, Math.round(box.width * ratio));
const height = Math.max(1, Math.round(box.height * ratio));
if (!peaks.length || width < 2) return;
canvas.width = width;
canvas.height = height;
const draw = canvas.getContext("2d");
if (!draw) return;
const styles = view!.getComputedStyle(shell);
draw.fillStyle = styles.getPropertyValue("--wrn-color-primary").trim() || styles.color;
// Normalised against the loudest bin, so a quiet recording still reads as a
// waveform rather than a flat line down the middle.
const loudest = Math.max(...peaks, 0.0001);
const step = width / peaks.length;
const bar = Math.max(1 * ratio, Math.min(3 * ratio, step * 0.55));
const radius = bar / 2;
peaks.forEach((peak, i) => {
const size = Math.max(bar, (peak / loudest) * height * 0.9);
const x = i * step + (step - bar) / 2;
const y = (height - size) / 2;
draw.beginPath();
// Rounded caps read as a waveform; square blocks read as a bar chart.
if (draw.roundRect) draw.roundRect(x, y, bar, size, radius);
else draw.rect(x, y, bar, size);
draw.fill();
});
};
const setProgress = () => {
const ratio =
Number.isFinite(audio.duration) && audio.duration > 0
? audio.currentTime / audio.duration
: 0;
progress.style.width = `${ratio * 100}%`;
seek.value = String(Math.round(ratio * 1000));
elapsed.textContent = clock(audio.currentTime);
};
const setDuration = () => {
duration.textContent = clock(audio.duration);
};
const setPlayState = () => {
setButtonLabel(play, audio.paused ? "Play" : "Pause", audio.paused ? "play" : "pause");
};
play.addEventListener("click", () => {
if (audio.paused) void audio.play().catch(() => {});
else audio.pause();
});
mute.addEventListener("click", () => {
audio.muted = !audio.muted;
setButtonLabel(mute, audio.muted ? "Unmute" : "Mute", audio.muted ? "muted" : "volume");
});
seek.addEventListener("input", () => {
if (!Number.isFinite(audio.duration)) return;
audio.currentTime = (Number(seek.value) / 1000) * audio.duration;
setProgress();
});
audio.addEventListener("timeupdate", setProgress);
audio.addEventListener("loadedmetadata", setDuration);
audio.addEventListener("durationchange", setDuration);
audio.addEventListener("play", setPlayState);
audio.addEventListener("pause", setPlayState);
audio.addEventListener("ended", setPlayState);
setDuration();
setProgress();
setPlayState();
// The envelope is drawn once metadata is in and decoding stays bounded; a
// file too long or too large simply keeps a flat track.
let disposed = false;
let context: AudioContext | undefined;
void (async () => {
try {
if (!view?.AudioContext) return;
await new Promise<void>((done) => {
if (Number.isFinite(audio.duration)) done();
else audio.addEventListener("loadedmetadata", () => done(), { once: true });
});
if (disposed || !Number.isFinite(audio.duration) || audio.duration > 600) return;
const file = await readSource(source, 8 * 1024 * 1024, { signal });
if (disposed) return;
context = new view.AudioContext();
const decoded = await context.decodeAudioData(await file.arrayBuffer());
if (disposed) return;
const peaks = waveformPeaks(
Array.from({ length: decoded.numberOfChannels }, (_, i) => decoded.getChannelData(i)),
BINS,
);
drawPeaks(peaks);
canvas.classList.add("is-ready");
} catch {
/* A flat track is a fine outcome; playback is unaffected. */
} finally {
await context?.close().catch(() => {});
context = undefined;
}
})();
const observer =
view && "ResizeObserver" in view ? new view.ResizeObserver(() => drawPeaks()) : undefined;
observer?.observe(track);
return () => {
disposed = true;
observer?.disconnect();
audio.removeEventListener("timeupdate", setProgress);
audio.removeEventListener("loadedmetadata", setDuration);
audio.removeEventListener("durationchange", setDuration);
audio.removeEventListener("play", setPlayState);
audio.removeEventListener("pause", setPlayState);
audio.removeEventListener("ended", setPlayState);
void context?.close().catch(() => {});
shell.remove();
};
}
+48
View File
@@ -0,0 +1,48 @@
import { elementFactory, requireFile } from "./dom.ts";
import type { ViewerMount } from "../types.ts";
/** Served separately by `viewerPlugin`; fetched only when a text preview opens. */
const editorAsset = "/__wrnexus/assets/viewer-code.js";
export const mountCode: ViewerMount = async ({ parent, file, source, button, signal }) => {
const doc = parent.ownerDocument;
const element = elementFactory(doc);
const text = await requireFile(file).text();
if (signal.aborted) return;
// Plain text first: never a parse, never markup, and correct with no editor.
const plain = element("pre", text);
parent.append(plain);
try {
const { mountBuilderSourceEditor } = (await import(
new URL(editorAsset, doc.baseURI).href
)) as typeof import("@wrnexus/block-editor/source-editor");
if (signal.aborted) return;
const host = element("div");
host.className = "wrn-viewer-code";
parent.replaceChildren(host);
const editor = await mountBuilderSourceEditor(
host,
/\.(xml|html|htm|svg)$/i.test(source.name) ? "xml" : "text",
text,
{ label: source.name || "File source", theme: "tokens" },
);
if (signal.aborted) {
editor?.destroy();
return;
}
if (!editor) {
parent.replaceChildren(plain);
return;
}
let wrapped = false;
const wrap = button("Wrap lines", () => {
wrapped = !wrapped;
editor.wrap(wrapped);
wrap.setAttribute("aria-pressed", String(wrapped));
});
wrap.setAttribute("aria-pressed", "false");
return () => editor.destroy();
} catch {
if (!signal.aborted) parent.replaceChildren(plain);
}
};
@@ -0,0 +1,56 @@
import { convertUrl, requireConverter } from "../convert/client.ts";
import { extensionOf } from "../kinds.ts";
import { readBounded } from "../source.ts";
import { elementFactory } from "./dom.ts";
import type { ViewerMount } from "../types.ts";
const MAX_TEXT_BYTES = 2 * 1024 * 1024;
/**
* Renders what the server produced rather than the original bytes. The output
* is same-origin, so the element points straight at the conversion endpoint and
* the browser streams it under the app's own policy.
*/
export const mountConverted: ViewerMount = async ({ parent, source, signal, degrade }) => {
const element = elementFactory(parent.ownerDocument);
const url = convertUrl(source, "preview", parent.ownerDocument.baseURI);
const mime = await requireConverter([extensionOf(source.name)], {
baseURI: parent.ownerDocument.baseURI,
});
if (!url) throw new Error("Conversion needs a URL source");
if (signal.aborted) return;
const note = (text: string) => {
const line = element("p", text);
line.className = "wrn-viewer-note";
return line;
};
if (mime.startsWith("text/")) {
// Listings are small and are shown inline. The cap is applied to the stream,
// not to the decoded string: slicing afterwards has already read it all.
const response = await fetch(url, { signal });
if (!response.ok) throw new Error("Conversion failed");
const text = new TextDecoder().decode(await readBounded(response, MAX_TEXT_BYTES, true));
if (signal.aborted) return;
parent.append(note(`Listed by the server from ${source.name}.`), element("pre", text));
return;
}
if (mime.startsWith("image/")) {
const image = element("img");
image.alt = `Converted preview of ${source.name}`;
image.src = url;
image.addEventListener("error", () => degrade("This file could not be converted"), {
once: true,
});
parent.append(image, note(`Rendered by the server from ${source.name}.`));
return;
}
const frame = element("iframe");
frame.src = url;
frame.className = "wrn-viewer-frame wrn-viewer-pdf";
frame.setAttribute("title", `Converted preview of ${source.name}`);
parent.append(frame, note("Converted by the server. Use Download for the original file."));
};
+36
View File
@@ -0,0 +1,36 @@
import { loadOptional } from "../optional/load.ts";
import type { docxToHtml } from "../optional/docx.ts";
import { requireFile } from "./dom.ts";
import { sandboxFrame } from "./frame.ts";
import type { ViewerMount } from "../types.ts";
/** Mammoth may emit inline images as data URLs; nothing else may load. */
const POLICY = "default-src 'none'; style-src 'unsafe-inline'; img-src data:";
const CSS =
"body{padding:1.5rem;font:16px/1.6 system-ui,sans-serif}" +
"img{max-width:100%;height:auto}table{border-collapse:collapse}" +
"td,th{border:1px solid currentColor;padding:.25rem .5rem}";
export const mountDocx: ViewerMount = async ({ parent, file, source, signal }) => {
const { docxToHtml: convert } = await loadOptional<{ docxToHtml: typeof docxToHtml }>(
"viewer-docx",
parent.ownerDocument.baseURI,
);
const bytes = new Uint8Array(await requireFile(file).arrayBuffer());
if (signal.aborted) return;
const html = await convert(bytes);
if (signal.aborted) return;
if (!html.trim()) throw new Error("Document produced no readable content");
// Converted markup is derived from an untrusted file, so it is framed under a
// policy that permits no script and no network, exactly like the SVG preview.
parent.append(
sandboxFrame(parent, {
title: source.name || "Document preview",
policy: POLICY,
body: html,
css: CSS,
}),
);
};
+35
View File
@@ -0,0 +1,35 @@
import { icon, iconFor } from "./icons.ts";
/** Element factory bound to a host document; never parses strings into nodes. */
export function elementFactory(doc: Document) {
return <K extends keyof HTMLElementTagNameMap>(
tag: K,
text?: string,
): HTMLElementTagNameMap[K] => {
const node = doc.createElement(tag);
if (text !== undefined) node.textContent = text;
return node;
};
}
/** Renderers that parse bytes are never `streaming`, so the body is present. */
export function requireFile(file: Blob | undefined): Blob {
if (!file) throw new Error("This renderer needs the file body");
return file;
}
/**
* Toolbar buttons carry an icon plus a label that is read out but not shown, so
* assigning `textContent` would delete the icon. Toggling a label goes through
* here instead, which also swaps the icon to match.
*/
export function setButtonLabel(node: HTMLElement, label: string, iconName?: string): void {
const doc = node.ownerDocument;
node.setAttribute("aria-label", label);
node.setAttribute("data-tooltip", label);
node.replaceChildren(icon(doc, iconName ?? iconFor(label)));
const text = doc.createElement("span");
text.className = "wrn-viewer-sr";
text.textContent = label;
node.append(text);
}
+55
View File
@@ -0,0 +1,55 @@
import { elementFactory } from "./dom.ts";
export interface SandboxFrameOptions {
title: string;
/** Content-Security-Policy for the framed document. */
policy: string;
/** Markup placed inside the frame. Must already be sanitised. */
body: string;
/** Extra CSS for the framed document. */
css?: string;
}
const SAFE_COLOR = /^[#a-z0-9(),.%\s/-]+$/i;
/**
* A frame with every sandbox restriction on, carrying its own CSP.
*
* The framed document is a separate document, so theme tokens and
* `currentColor` do not reach it and Chrome paints its own opaque base
* background rather than letting the element's background show. Both the
* resolved text and surface colours are therefore copied in by value.
*/
export function sandboxFrame(host: HTMLElement, options: SandboxFrameOptions): HTMLIFrameElement {
const doc = host.ownerDocument;
const element = elementFactory(doc);
const view = doc.defaultView;
const computed = (node: Element) => view?.getComputedStyle?.(node);
const safe = (value: string, fallback: string) => (SAFE_COLOR.test(value) ? value : fallback);
const color = safe(computed(host)?.color ?? "", "#000");
let background = "#fff";
for (let node: Element | null = host; node; node = node.parentElement) {
const value = computed(node)?.backgroundColor ?? "";
if (value && !/^(transparent|rgba\(0, *0, *0, *0\))$/.test(value)) {
background = safe(value, "#fff");
break;
}
}
const frame = element("iframe");
frame.setAttribute("sandbox", "");
frame.setAttribute("title", options.title);
frame.className = "wrn-viewer-frame";
frame.setAttribute(
"srcdoc",
[
'<meta charset="utf-8">',
`<meta http-equiv="Content-Security-Policy" content="${options.policy}">`,
`<style>html,body{margin:0;height:100%;color:${color};background:${background}}` +
`${options.css ?? ""}</style>`,
options.body,
].join(""),
);
return frame;
}
+28
View File
@@ -0,0 +1,28 @@
import { sanitizeHtml } from "../parse/html.ts";
import { elementFactory, requireFile, setButtonLabel } from "./dom.ts";
import { sandboxFrame } from "./frame.ts";
import type { ViewerMount } from "../types.ts";
export const mountHtml: ViewerMount = async ({ parent, file, source, button, signal }) => {
const text = await requireFile(file).text();
if (signal.aborted) return;
const view = parent.ownerDocument.defaultView;
if (!view) throw new Error("HTML preview requires a document");
const frame = sandboxFrame(parent, {
title: source.name || "HTML preview",
policy:
"default-src 'none'; base-uri 'none'; form-action 'none'; style-src 'unsafe-inline'; img-src data:",
body: sanitizeHtml(text, view),
css: "body{padding:1rem;box-sizing:border-box;font:16px/1.5 system-ui}img{max-width:100%}",
});
const raw = elementFactory(parent.ownerDocument)("pre", text);
let sourceView = false;
const toggle = button("Source", () => {
sourceView = !sourceView;
setButtonLabel(toggle, sourceView ? "Preview" : "Source");
toggle.setAttribute("aria-pressed", String(sourceView));
parent.replaceChildren(sourceView ? raw : frame);
});
toggle.setAttribute("aria-pressed", "false");
parent.append(frame);
};
+133
View File
@@ -0,0 +1,133 @@
/**
* Toolbar icons, drawn as SVG nodes rather than markup strings: the framework
* enables Trusted Types in production, where `innerHTML` is refused outright.
*
* One line-drawn family at 24×24 on `currentColor`, so every icon inherits the
* app's theme with no palette of its own.
*/
type Shape = [tag: string, attributes: Record<string, string>];
const path = (d: string): Shape => ["path", { d }];
export const ICONS: Record<string, Shape[]> = {
"zoom-in": [
["circle", { cx: "11", cy: "11", r: "7" }],
path("M21 21l-4.3-4.3"),
path("M8 11h6"),
path("M11 8v6"),
],
"zoom-out": [
["circle", { cx: "11", cy: "11", r: "7" }],
path("M21 21l-4.3-4.3"),
path("M8 11h6"),
],
rotate: [path("M21 12a9 9 0 1 1-3-6.7"), path("M21 4v5h-5")],
reset: [path("M3 12a9 9 0 1 0 3-6.7"), path("M3 4v5h5")],
fullscreen: [path("M8 3H3v5"), path("M21 8V3h-5"), path("M16 21h5v-5"), path("M3 16v5h5")],
download: [path("M12 3v12"), path("M7 12l5 5 5-5"), path("M4 21h16")],
print: [
path("M7 8V3h10v5"),
path("M7 18H5a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-2"),
["rect", { x: "7", y: "15", width: "10", height: "6", rx: "1" }],
],
previous: [path("M15 5l-7 7 7 7")],
next: [path("M9 5l7 7-7 7")],
top: [path("M12 20V6"), path("M6 11l6-6 6 6")],
source: [path("M9 6l-6 6 6 6"), path("M15 6l6 6-6 6")],
preview: [
path("M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7Z"),
["circle", { cx: "12", cy: "12", r: "3" }],
],
table: [
["rect", { x: "3", y: "4", width: "18", height: "16", rx: "2" }],
path("M3 10h18"),
path("M9 10v10"),
],
tree: [path("M5 4v12a2 2 0 0 0 2 2h12"), path("M9 8h11"), path("M13 18h7")],
raw: [path("M4 5h16"), path("M4 10h16"), path("M4 15h10"), path("M4 20h7")],
wrap: [
path("M4 6h16"),
path("M4 12h13a3 3 0 0 1 0 6h-4"),
path("M16 15l-3 3 3 3"),
path("M4 18h4"),
],
waveform: [path("M3 12h2"), path("M8 7v10"), path("M12 4v16"), path("M16 8v8"), path("M20 11h1")],
sheets: [
["rect", { x: "3", y: "3", width: "13", height: "13", rx: "2" }],
path("M8 21h11a2 2 0 0 0 2-2V8"),
],
text: [path("M5 4h14"), path("M12 4v16"), path("M8 20h8")],
play: [path("M7 4.5v15l12-7.5Z")],
close: [path("M6 6l12 12"), path("M18 6L6 18")],
expand: [path("M9 3H3v6"), path("M21 15v6h-6"), path("M3 3l7 7"), path("M21 21l-7-7")],
pause: [path("M9 4v16"), path("M15 4v16")],
volume: [
path("M11 5 6.5 9H3v6h3.5L11 19Z"),
path("M16 9.5a3.5 3.5 0 0 1 0 5"),
path("M18.5 7a7 7 0 0 1 0 10"),
],
muted: [path("M11 5 6.5 9H3v6h3.5L11 19Z"), path("M17 10l4 4"), path("M21 10l-4 4")],
file: [path("M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8Z"), path("M14 3v5h5")],
slides: [
["rect", { x: "3", y: "4", width: "18", height: "12", rx: "2" }],
path("M12 16v4"),
path("M8 20h8"),
],
};
/** Toolbar labels are stable strings, so the icon can be derived from them. */
const BY_LABEL: Record<string, string> = {
"zoom in": "zoom-in",
"zoom out": "zoom-out",
rotate: "rotate",
reset: "reset",
fullscreen: "fullscreen",
download: "download",
print: "print",
previous: "previous",
next: "next",
top: "top",
source: "source",
preview: "preview",
table: "table",
raw: "raw",
tree: "tree",
text: "text",
"wrap lines": "wrap",
"show waveform": "waveform",
"hide waveform": "waveform",
sheets: "sheets",
play: "play",
pause: "pause",
close: "close",
expand: "expand",
collapse: "close",
mute: "volume",
unmute: "muted",
"extract text": "text",
"hide text": "text",
};
export function iconFor(label: string): string {
return BY_LABEL[label.trim().toLowerCase()] ?? "file";
}
export function icon(doc: Document, name: string): SVGSVGElement {
const NS = "http://www.w3.org/2000/svg";
const svg = doc.createElementNS(NS, "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("fill", "none");
svg.setAttribute("stroke", "currentColor");
svg.setAttribute("stroke-width", "1.75");
svg.setAttribute("stroke-linecap", "round");
svg.setAttribute("stroke-linejoin", "round");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("focusable", "false");
svg.classList.add("wrn-viewer-icon");
for (const [tag, attributes] of ICONS[name] ?? ICONS.file!) {
const shape = doc.createElementNS(NS, tag);
for (const [key, value] of Object.entries(attributes)) shape.setAttribute(key, value);
svg.append(shape);
}
return svg;
}
+74
View File
@@ -0,0 +1,74 @@
import { elementFactory } from "./dom.ts";
import type { ViewerMount } from "../types.ts";
export const mountImage: ViewerMount = async ({
parent,
source,
sourceUrl,
button,
degrade,
signal,
}) => {
const element = elementFactory(parent.ownerDocument);
const url = await sourceUrl();
if (signal.aborted) return;
const stage = element("div");
stage.className = "wrn-viewer-stage";
const image = element("img");
image.alt = source.name;
image.src = url;
image.draggable = false;
image.addEventListener("error", () => degrade("Image could not be displayed"), { once: true });
// A readout, because four zoom buttons with no feedback leave you guessing.
const readout = element("span", "100%");
readout.className = "wrn-viewer-zoom";
stage.append(image, readout);
parent.append(stage);
let zoom = 1;
let rotation = 0;
const update = () => {
image.style.width = zoom === 1 ? "" : `${zoom * 100}%`;
image.style.maxWidth = zoom === 1 ? "" : "none";
image.style.transform = rotation ? `rotate(${rotation}deg)` : "";
readout.textContent = `${Math.round(zoom * 100)}%${rotation ? ` · ${rotation}°` : ""}`;
};
button("Zoom in", () => {
zoom = Math.min(4, zoom + 0.25);
update();
});
button("Zoom out", () => {
zoom = Math.max(0.25, zoom - 0.25);
update();
});
button("Rotate", () => {
rotation = (rotation + 90) % 360;
update();
});
button("Reset", () => {
zoom = 1;
rotation = 0;
update();
});
// Native scrolling supports keyboard/touch panning; pointer drag supports mouse.
parent.tabIndex = 0;
let drag: { x: number; y: number; left: number; top: number } | undefined;
parent.onpointerdown = (event) => {
drag = { x: event.clientX, y: event.clientY, left: parent.scrollLeft, top: parent.scrollTop };
parent.setPointerCapture(event.pointerId);
};
parent.onpointermove = (event) => {
if (drag) {
parent.scrollLeft = drag.left - event.clientX + drag.x;
parent.scrollTop = drag.top - event.clientY + drag.y;
}
};
parent.onpointerup = parent.onpointercancel = () => {
drag = undefined;
};
return () => {
parent.onpointerdown = parent.onpointermove = parent.onpointerup = null;
parent.onpointercancel = null;
};
};
+56
View File
@@ -0,0 +1,56 @@
import { elementFactory, requireFile, setButtonLabel } from "./dom.ts";
import { branch, leaf } from "./tree.ts";
import type { ViewerMount } from "../types.ts";
const preview = (value: unknown): string =>
Array.isArray(value)
? `[] ${value.length} item${value.length === 1 ? "" : "s"}`
: `{} ${Object.keys(value as object).length} key${
Object.keys(value as object).length === 1 ? "" : "s"
}`;
function render(doc: Document, key: string, value: unknown, depth: number): HTMLElement {
if (value === null || typeof value !== "object")
return leaf(
doc,
key,
typeof value === "string" ? JSON.stringify(value) : String(value),
`wrn-viewer-value wrn-viewer-${value === null ? "null" : typeof value}`,
);
const entries: [string, unknown][] = Array.isArray(value)
? value.map((item, index) => [String(index), item])
: Object.entries(value as object);
return branch(
doc,
`${key ? `${key} ` : ""}${preview(value)}`,
"wrn-viewer-key",
(into) => {
for (const [childKey, child] of entries) into.append(render(doc, childKey, child, depth + 1));
},
depth < 1,
);
}
export const mountJson: ViewerMount = async ({ parent, file, button, signal }) => {
const doc = parent.ownerDocument;
const element = elementFactory(doc);
const text = await requireFile(file).text();
if (signal.aborted) return;
// A parse failure throws, and the ladder falls through to the source view.
const data = JSON.parse(text) as unknown;
const tree = element("div");
tree.className = "wrn-viewer-tree";
tree.append(render(doc, "", data, 0));
const raw = element("pre", text);
let showingRaw = false;
const toggle = button("Raw", () => {
showingRaw = !showingRaw;
setButtonLabel(toggle, showingRaw ? "Tree" : "Raw");
toggle.setAttribute("aria-pressed", String(showingRaw));
parent.replaceChildren(showingRaw ? raw : tree);
});
toggle.setAttribute("aria-pressed", "false");
parent.append(tree);
};
+41
View File
@@ -0,0 +1,41 @@
import { elementFactory } from "./dom.ts";
import type { ViewerMount } from "../types.ts";
import { mountAudioPlayer } from "./audio-player.ts";
export const mountMedia: ViewerMount = async ({
parent,
source,
sourceUrl,
kind,
degrade,
signal,
}) => {
const element = elementFactory(parent.ownerDocument);
const url = await sourceUrl();
if (signal.aborted) return;
const media = element(kind === "audio" ? "audio" : "video");
// Audio gets the package's own transport, so the native controls would be a
// second set of the same buttons driving the same playhead.
media.controls = kind !== "audio";
media.preload = "metadata";
media.src = url;
media.setAttribute("aria-label", source.name);
media.addEventListener("error", () => degrade("This browser cannot play this file"), {
once: true,
});
if (kind === "video") {
// Video shares the image's stage: a bounded, framed surface rather than a
// bare element stretched into the panel.
const stage = element("div");
stage.className = "wrn-viewer-stage";
stage.append(media);
parent.append(stage);
} else parent.append(media);
const stopPlayer = kind === "audio" ? mountAudioPlayer(parent, media, source, signal) : undefined;
return () => {
stopPlayer?.();
media.pause();
media.removeAttribute("src");
media.load();
};
};
+76
View File
@@ -0,0 +1,76 @@
import { loadOptional } from "../optional/load.ts";
import type { OfficeDocument, readOdf, readPptx } from "../optional/office.ts";
import { elementFactory, requireFile } from "./dom.ts";
import { renderWorkbook } from "./workbook.ts";
import { renderSlides, type SlideDeckView } from "./slides.ts";
import type { ViewerMount, ViewerMountContext } from "../types.ts";
interface OfficeModule {
readPptx: typeof readPptx;
readOdf: typeof readOdf;
}
function parser(parent: HTMLElement): { DOMParser: new () => DOMParser } {
const view = parent.ownerDocument.defaultView;
if (!view?.DOMParser) throw new Error("No XML parser available");
return view as unknown as { DOMParser: new () => DOMParser };
}
/** Shared presentation of a parsed office document. */
async function show(context: ViewerMountContext, document: OfficeDocument) {
const { parent, signal } = context;
const element = elementFactory(parent.ownerDocument);
if (signal.aborted) return;
if (document.type === "slides") {
// The deck carries its own pager, so toolbar arrows would be a second set
// of the same two controls — and they push the toolbar onto two rows.
const deck: SlideDeckView = renderSlides(parent.ownerDocument, document.slides);
parent.append(deck.node);
return () => deck.dispose();
}
if (document.type === "sheets") {
return renderWorkbook(parent, document.sheets);
}
const article = element("article");
article.className = "wrn-viewer-document";
for (const block of document.blocks) {
if (block.level > 0) {
const heading = element(
(["h1", "h2", "h3", "h4", "h5", "h6"] as const)[Math.min(block.level, 6) - 1]!,
block.text,
);
article.append(heading);
} else if (block.list) {
const previous = article.lastElementChild;
const list = previous?.tagName === "UL" ? previous : element("ul");
list.append(element("li", block.text));
if (list !== previous) article.append(list);
} else article.append(element("p", block.text));
}
parent.append(article);
}
export const mountPresentation: ViewerMount = async (context) => {
const { parent, file, signal } = context;
const { readPptx: read } = await loadOptional<OfficeModule>(
"viewer-office",
parent.ownerDocument.baseURI,
);
const bytes = new Uint8Array(await requireFile(file).arrayBuffer());
if (signal.aborted) return;
return show(context, await read(bytes, parser(parent)));
};
export const mountOpenDocument: ViewerMount = async (context) => {
const { parent, file, signal } = context;
const { readOdf: read } = await loadOptional<OfficeModule>(
"viewer-office",
parent.ownerDocument.baseURI,
);
const bytes = new Uint8Array(await requireFile(file).arrayBuffer());
if (signal.aborted) return;
return show(context, await read(bytes, parser(parent)));
};
+62
View File
@@ -0,0 +1,62 @@
import { loadOptional } from "../optional/load.ts";
import type { renderPdf } from "../optional/pdf.ts";
import { elementFactory, requireFile } from "./dom.ts";
import type { Renderer, ViewerMount } from "../types.ts";
const LIB_ASSET = "/__wrnexus/assets/viewer-pdfjs-lib.js";
const WORKER_ASSET = "/__wrnexus/assets/viewer-pdfjs-worker.js";
const mountRichPdf: ViewerMount = async ({ parent, file, button, signal }) => {
const element = elementFactory(parent.ownerDocument);
const { renderPdf: render } = await loadOptional<{ renderPdf: typeof renderPdf }>(
"viewer-pdfjs",
parent.ownerDocument.baseURI,
);
const bytes = new Uint8Array(await requireFile(file).arrayBuffer());
if (signal.aborted) return;
const pages = element("div");
pages.className = "wrn-viewer-pages";
parent.append(pages);
const base = parent.ownerDocument.baseURI;
const result = await render(pages, bytes, {
signal,
libSrc: new URL(LIB_ASSET, base).href,
workerSrc: new URL(WORKER_ASSET, base).href,
});
if (signal.aborted) {
result.destroy();
return;
}
const status = element(
"p",
result.rendered < result.pages
? `Showing ${result.rendered} of ${result.pages} pages. Use Download for the rest.`
: `${result.pages} page${result.pages === 1 ? "" : "s"}`,
);
status.className = "wrn-viewer-note";
parent.append(status);
button("Top", () => pages.firstElementChild?.scrollIntoView({ block: "start" }));
return result.destroy;
};
/**
* Opt-in pdf.js rendering. The native frame is the default because it costs
* nothing; this trades roughly a megabyte of script for page count, controlled
* sizing and consistent output across browsers.
*
* ```ts
* registerRenderer(richPdfRenderer());
* ```
*/
export function richPdfRenderer(options: { maxInlineBytes?: number } = {}): Renderer {
return {
kind: "pdf",
exts: [".pdf"],
tier: 2,
component: "RichPdfViewer",
maxInlineBytes: options.maxInlineBytes ?? 32 * 1024 * 1024,
load: (options) => loadOptional("viewer-pdfjs", options?.baseURI),
mount: mountRichPdf,
};
}
+25
View File
@@ -0,0 +1,25 @@
import { directUrl } from "../source.ts";
import { elementFactory } from "./dom.ts";
import type { ViewerMount } from "../types.ts";
/**
* A frame, not an `<object>`: `object-src` is commonly `'none'` while frames
* fall back to `default-src`, so a same-origin PDF loads under the app's own
* policy instead of being blocked.
*/
export const mountPdf: ViewerMount = async ({ parent, source, sourceUrl, signal }) => {
const element = elementFactory(parent.ownerDocument);
// The original URL wins over an object URL whenever there is one. Reaching
// here as a fallback — after a richer renderer failed — means a body was
// already read, and framing that `blob:` is refused by the common
// `default-src 'self'` policy, leaving an empty frame.
const url = directUrl(source) ?? (await sourceUrl());
if (signal.aborted) return;
const frame = element("iframe");
frame.src = url;
frame.className = "wrn-viewer-frame wrn-viewer-pdf";
frame.setAttribute("title", source.name || "PDF preview");
const note = element("p", "If the document does not appear, use Download to open it.");
note.className = "wrn-viewer-note";
parent.append(frame, note);
};
+28
View File
@@ -0,0 +1,28 @@
import { rtfToText } from "../parse/rtf.ts";
import { elementFactory, requireFile, setButtonLabel } from "./dom.ts";
import type { ViewerMount } from "../types.ts";
/** RTF needs no dependency at all: it is text once the control words are gone. */
export const mountRtf: ViewerMount = async ({ parent, file, button, signal }) => {
const element = elementFactory(parent.ownerDocument);
const source = await requireFile(file).text();
if (signal.aborted) return;
const text = rtfToText(source);
if (!text) throw new Error("Document holds no readable text");
const article = element("article");
article.className = "wrn-viewer-document";
for (const paragraph of text.split(/\n{2,}/))
for (const line of paragraph.split("\n")) article.append(element("p", line));
const raw = element("pre", source);
let showingRaw = false;
const toggle = button("Source", () => {
showingRaw = !showingRaw;
setButtonLabel(toggle, showingRaw ? "Text" : "Source");
toggle.setAttribute("aria-pressed", String(showingRaw));
parent.replaceChildren(showingRaw ? raw : article);
});
toggle.setAttribute("aria-pressed", "false");
parent.append(article);
};
+17
View File
@@ -0,0 +1,17 @@
import { loadOptional } from "../optional/load.ts";
import type { readWorkbook, SheetData } from "../optional/sheet.ts";
import { requireFile } from "./dom.ts";
import { renderWorkbook } from "./workbook.ts";
import type { ViewerMount } from "../types.ts";
export const mountSheet: ViewerMount = async ({ parent, file, signal }) => {
const { readWorkbook: parse } = await loadOptional<{ readWorkbook: typeof readWorkbook }>(
"viewer-sheet",
parent.ownerDocument.baseURI,
);
const bytes = new Uint8Array(await requireFile(file).arrayBuffer());
if (signal.aborted) return;
const sheets: SheetData[] = parse(bytes);
if (!sheets.length) throw new Error("Workbook holds no sheets");
return renderWorkbook(parent, sheets);
};
+141
View File
@@ -0,0 +1,141 @@
import { elementFactory } from "./dom.ts";
import { icon } from "./icons.ts";
import type { Slide } from "../optional/office.ts";
/**
* Slide-by-slide reader shared by `.pptx` and `.odp`. Content and order, not a
* faithful layout: the converter's PDF stays preferred wherever it is reachable.
*/
export interface SlideDeckView {
node: HTMLElement;
/** Toolbar label describing the deck, e.g. "3 of 12". */
status: () => string;
go: (index: number) => void;
next: (step: number) => void;
dispose: () => void;
}
/** Beyond this many slides a row of dots stops being readable. */
const MAX_DOTS = 20;
export function renderSlides(
doc: Document,
slides: Slide[],
/** Called after every move, however it was made. */
onChange?: (index: number) => void,
): SlideDeckView {
const element = elementFactory(doc);
const wrapper = element("div");
wrapper.className = "wrn-viewer-slides";
// A deck is one control: focus it once and the arrow keys drive it.
wrapper.tabIndex = 0;
wrapper.setAttribute("role", "group");
wrapper.setAttribute("aria-roledescription", "slide deck");
wrapper.setAttribute("aria-label", `${slides.length} slides. Use the arrow keys to move.`);
const stage = element("article");
stage.className = "wrn-viewer-slide";
stage.setAttribute("aria-live", "polite");
const pager = element("div");
pager.className = "wrn-viewer-pager";
const back = element("button");
back.type = "button";
back.className = "wrn-viewer-pager-step";
back.setAttribute("aria-label", "Previous slide");
back.append(icon(doc, "previous"));
const forward = element("button");
forward.type = "button";
forward.className = "wrn-viewer-pager-step";
forward.setAttribute("aria-label", "Next slide");
forward.append(icon(doc, "next"));
const dots = element("div");
dots.className = "wrn-viewer-dots";
const count = element("span");
count.className = "wrn-viewer-pager-count";
let current = 0;
const useDots = slides.length <= MAX_DOTS;
const marks = useDots
? slides.map((slide, index) => {
const dot = element("button");
dot.type = "button";
dot.className = "wrn-viewer-dot";
dot.title = slide.title || `Slide ${index + 1}`;
dot.setAttribute(
"aria-label",
`Slide ${index + 1}${slide.title ? `: ${slide.title}` : ""}`,
);
dot.addEventListener("click", () => go(index));
dots.append(dot);
return dot;
})
: [];
pager.append(back, useDots ? dots : count, forward);
if (useDots) pager.append(count);
function go(index: number): void {
current = Math.max(0, Math.min(slides.length - 1, index));
const slide = slides[current]!;
marks.forEach((dot, position) => {
dot.setAttribute("aria-current", String(position === current));
dot.tabIndex = position === current ? 0 : -1;
});
count.textContent = `${current + 1} / ${slides.length}`;
back.disabled = current === 0;
forward.disabled = current === slides.length - 1;
stage.replaceChildren();
stage.append(element("h2", slide.title || `Slide ${current + 1}`));
// The title shape is already the heading; render the rest as its body.
slide.blocks.forEach((lines, position) => {
const body = position === 0 ? lines.slice(1) : lines;
if (!body.length) return;
const list = element("ul");
for (const line of body) list.append(element("li", line));
stage.append(list);
});
if (slide.notes) {
const notes = element("details");
notes.append(element("summary", "Presenter notes"), element("p", slide.notes));
notes.className = "wrn-viewer-slide-notes";
stage.append(notes);
}
marks[current]?.scrollIntoView?.({ block: "nearest", inline: "nearest" });
// Every route into the deck ends here, so a caller that subscribes cannot
// miss a move — keyboard navigation included.
onChange?.(current);
}
back.addEventListener("click", () => go(current - 1));
forward.addEventListener("click", () => go(current + 1));
const onKey = (event: KeyboardEvent) => {
const keys: Record<string, number> = {
ArrowRight: current + 1,
PageDown: current + 1,
ArrowDown: current + 1,
ArrowLeft: current - 1,
PageUp: current - 1,
ArrowUp: current - 1,
Home: 0,
End: slides.length - 1,
};
const target = keys[event.key];
if (target === undefined) return;
event.preventDefault();
go(target);
};
wrapper.addEventListener("keydown", onKey);
wrapper.append(stage, pager);
go(0);
return {
node: wrapper,
status: () => `${current + 1} of ${slides.length}`,
go,
next: (step) => go(current + step),
dispose: () => wrapper.removeEventListener("keydown", onKey),
};
}
+42
View File
@@ -0,0 +1,42 @@
import { sanitizeSvg } from "../parse/svg.ts";
import { elementFactory, requireFile, setButtonLabel } from "./dom.ts";
import { sandboxFrame } from "./frame.ts";
import type { ViewerMount } from "../types.ts";
/** No network, no scripts, no plugins: only inline styles may apply. */
const POLICY = "default-src 'none'; style-src 'unsafe-inline'";
export const mountSvg: ViewerMount = async ({ parent, file, source, button, signal }) => {
const doc = parent.ownerDocument;
const view = doc.defaultView;
const element = elementFactory(doc);
const text = await requireFile(file).text();
if (signal.aborted) return;
if (!view?.DOMParser || !view.XMLSerializer) throw new Error("No XML parser available");
const safe = sanitizeSvg(text, view);
if (!safe) throw new Error("Not a valid SVG document");
// Sanitised markup still renders inside a sandboxed frame: two layers, so a
// gap in either one is not on its own enough to run anything.
const frame = sandboxFrame(parent, {
title: source.name || "SVG preview",
policy: POLICY,
body: safe,
// Centred and contained: a small icon should not be blown up to the full
// height of the frame just because the frame is tall.
css:
"html,body{display:flex;align-items:center;justify-content:center;padding:1rem;box-sizing:border-box}" +
"svg{display:block;max-width:100%;max-height:100%}",
});
const raw = element("pre", text);
let showingRaw = false;
const toggle = button("Source", () => {
showingRaw = !showingRaw;
setButtonLabel(toggle, showingRaw ? "Preview" : "Source");
toggle.setAttribute("aria-pressed", String(showingRaw));
parent.replaceChildren(showingRaw ? raw : frame);
});
toggle.setAttribute("aria-pressed", "false");
parent.append(frame);
};
+181
View File
@@ -0,0 +1,181 @@
import { elementFactory } from "./dom.ts";
/** Must match `.wrn-viewer-sheet td` in viewer.css for windowing to line up. */
const ROW_HEIGHT = 28;
const OVERSCAN = 20;
export interface TableData {
header: string[];
rows: string[][];
truncated?: boolean;
}
export interface TableView {
/** Scroll container content: the windowed grid. */
node: HTMLElement;
caption: string;
/** Re-measure and redraw, e.g. after the view is shown again. */
refresh: () => void;
dispose: () => void;
}
/** Spreadsheet column labels: A, B … Z, AA, AB … */
export function columnLabel(index: number): string {
let label = "";
for (let n = index; n >= 0; n = Math.floor(n / 26) - 1)
label = String.fromCharCode(65 + (n % 26)) + label;
return label;
}
/**
* A spreadsheet grid, not a data table: lettered columns across the top,
* numbered rows down the side, and the file's first row kept as row 1 rather
* than promoted into a header — because in a spreadsheet it *is* row 1.
*
* Only the visible rows plus overscan exist in the DOM; two spacer rows stand
* in for everything above and below.
*/
export function renderTable(scroller: HTMLElement, data: TableData): TableView {
const element = elementFactory(scroller.ownerDocument);
// Row 1 of a sheet is data, so the parsed header rejoins the rows here.
const rows = data.header.length ? [data.header, ...data.rows] : data.rows;
const columns = rows.reduce((max, row) => Math.max(max, row.length), 0);
const caption = `${rows.length}${data.truncated ? "+" : ""} rows × ${columns} columns`;
// A column of numbers reads right-aligned; one of words does not. Row 1 is
// skipped because a label above a numeric column is still a label.
const numeric = Array.from({ length: columns }, (_, column) => {
const values = rows
.slice(1, 51)
.map((row) => (row[column] ?? "").trim())
.filter(Boolean);
return values.length > 0 && values.every((value) => /^-?[\d.,]+%?$/.test(value));
});
const wrapper = element("div");
wrapper.className = "wrn-viewer-sheet";
const grid = element("table");
const head = element("thead");
const headRow = element("tr");
const corner = element("th");
corner.setAttribute("data-viewer-index", "");
corner.setAttribute("aria-label", "Row numbers");
headRow.append(corner);
for (let i = 0; i < columns; i++) {
const cell = element("th", columnLabel(i));
cell.scope = "col";
headRow.append(cell);
}
head.append(headRow);
const body = element("tbody");
grid.append(head, body);
wrapper.append(grid);
const spacer = (height: number) => {
const row = element("tr");
row.setAttribute("data-viewer-spacer", "");
const cell = element("td");
cell.colSpan = columns + 1;
cell.style.height = `${height}px`;
cell.style.padding = "0";
row.append(cell);
return row;
};
let windowStart = -1;
const draw = () => {
const visible = Math.ceil((scroller.clientHeight || 400) / ROW_HEIGHT) + OVERSCAN * 2;
const start = Math.max(0, Math.floor(scroller.scrollTop / ROW_HEIGHT) - OVERSCAN);
if (start === windowStart) return;
windowStart = start;
const slice = rows.slice(start, start + visible);
const nodes: HTMLElement[] = [spacer(start * ROW_HEIGHT)];
slice.forEach((row, index) => {
const line = element("tr");
const number = element("th", String(start + index + 1));
number.setAttribute("data-viewer-index", "");
number.scope = "row";
line.append(number);
for (let i = 0; i < columns; i++) {
const cell = element("td", row[i] ?? "");
if (numeric[i]) cell.setAttribute("data-viewer-numeric", "");
line.append(cell);
}
// Row 1 usually carries the column names, so it is given some weight.
if (start + index === 0 && data.header.length) line.setAttribute("data-viewer-labels", "");
nodes.push(line);
});
nodes.push(spacer(Math.max(0, rows.length - start - slice.length) * ROW_HEIGHT));
body.replaceChildren(...nodes);
};
draw();
scroller.addEventListener("scroll", draw, { passive: true });
return {
node: wrapper,
caption,
refresh: () => {
windowStart = -1;
draw();
},
dispose: () => scroller.removeEventListener("scroll", draw),
};
}
export function formatSize(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]}`;
}
export interface ListedEntry {
name: string;
size: number;
directory: boolean;
}
/** Archive contents table, shared by the tar/gzip and zip previews. */
export function renderEntries(doc: Document, entries: ListedEntry[], label: string): HTMLElement {
const element = elementFactory(doc);
const wrapper = element("div");
wrapper.className = "wrn-viewer-table";
wrapper.setAttribute("aria-label", label);
const table = element("table");
const head = element("thead");
const headRow = element("tr");
const sizeHead = element("th", "Size");
sizeHead.setAttribute("data-viewer-numeric", "");
headRow.append(element("th", "Name"), sizeHead);
head.append(headRow);
const body = element("tbody");
for (const entry of entries) {
const row = element("tr");
const name = element("td", entry.name);
name.setAttribute("data-viewer-grow", "");
const size = element("td", entry.directory ? "" : formatSize(entry.size));
size.setAttribute("data-viewer-numeric", "");
row.append(name, size);
body.append(row);
}
table.append(head, body);
wrapper.append(table);
return wrapper;
}
/** "3 files, 1.2 MB uncompressed" — the line above an archive listing. */
export function entriesSummary(entries: ListedEntry[], note = ""): string {
const files = entries.filter((entry) => !entry.directory);
const total = files.reduce((sum, entry) => sum + entry.size, 0);
return [
`${files.length} file${files.length === 1 ? "" : "s"}`,
`${formatSize(total)} uncompressed`,
note,
]
.filter(Boolean)
.join(", ");
}
+32
View File
@@ -0,0 +1,32 @@
import { parseDelimited } from "../parse/delimited.ts";
import { elementFactory, requireFile, setButtonLabel } from "./dom.ts";
import { renderTable } from "./table-view.ts";
import type { ViewerMount } from "../types.ts";
export const mountTable: ViewerMount = async ({ parent, file, source, button, signal }) => {
const element = elementFactory(parent.ownerDocument);
const text = await requireFile(file).text();
if (signal.aborted) return;
const table = parseDelimited(text, {
delimiter: /\.tsv$/i.test(source.name) ? "\t" : undefined,
});
// A single column means no delimiter was found: show it as text instead.
if (table.header.length < 2) throw new Error("Not a delimited table");
const view = renderTable(parent, table);
const caption = element("p", view.caption);
const raw = element("pre", text);
let showingRaw = false;
const toggle = button("Raw", () => {
showingRaw = !showingRaw;
setButtonLabel(toggle, showingRaw ? "Table" : "Raw");
toggle.setAttribute("aria-pressed", String(showingRaw));
parent.replaceChildren(caption, showingRaw ? raw : view.node);
if (!showingRaw) view.refresh();
});
toggle.setAttribute("aria-pressed", "false");
parent.append(caption, view.node);
return view.dispose;
};
+45
View File
@@ -0,0 +1,45 @@
import { elementFactory } from "./dom.ts";
/**
* Shared collapsible tree used by the JSON and XML previews. Children are built
* on first expand, so a deep document costs nothing until it is opened.
*/
export function branch(
doc: Document,
label: string,
summaryClass: string,
fill: (into: HTMLElement) => void,
open = false,
): HTMLElement {
const element = elementFactory(doc);
const details = element("details");
details.open = open;
const summary = element("summary", label);
summary.className = summaryClass;
const children = element("div");
children.className = "wrn-viewer-branch";
details.append(summary, children);
let filled = false;
const build = () => {
if (filled || !details.open) return;
filled = true;
fill(children);
};
details.addEventListener("toggle", build);
build();
return details;
}
export function leaf(doc: Document, label: string, value: string, valueClass: string): HTMLElement {
const element = elementFactory(doc);
const line = element("div");
line.className = "wrn-viewer-leaf";
if (label) {
line.append(element("span", label));
line.append(doc.createTextNode(": "));
}
const rendered = element("span", value);
rendered.className = valueClass;
line.append(rendered);
return line;
}
+108
View File
@@ -0,0 +1,108 @@
import { setButtonLabel } from "./dom.ts";
import { readSource } from "../source.ts";
import type { ViewerSource } from "../types.ts";
/** Peak envelope in bounded bins, including every channel. */
export function waveformPeaks(channels: Float32Array[], bins = 160): number[] {
const length = Math.max(0, ...channels.map((channel) => channel.length));
return Array.from({ length: bins }, (_, i) => {
let peak = 0;
for (const channel of channels)
for (
let n = Math.floor((i * length) / bins);
n < Math.min(channel.length, Math.ceil(((i + 1) * length) / bins));
n++
)
peak = Math.max(peak, Math.abs(channel[n] ?? 0));
return Math.min(1, peak);
});
}
/** User-triggered decoding preserves streaming playback until a waveform is requested. */
export function mountWaveform(
parent: HTMLElement,
audio: HTMLMediaElement,
source: ViewerSource,
signal: AbortSignal,
): () => void {
const doc = parent.ownerDocument;
const view = doc.defaultView;
const button = doc.createElement("button");
button.type = "button";
button.className = "wrn-viewer-action";
setButtonLabel(button, "Show waveform");
const note = doc.createElement("p");
note.setAttribute("role", "status");
const canvas = doc.createElement("canvas");
canvas.width = 640;
canvas.height = 96;
canvas.className = "wrn-viewer-waveform";
canvas.setAttribute("role", "img");
canvas.setAttribute("aria-label", "Audio waveform");
const seek = doc.createElement("input");
seek.type = "range";
seek.min = "0";
seek.max = "1000";
seek.value = "0";
seek.setAttribute("aria-label", "Seek audio");
let context: AudioContext | undefined;
let disposed = false;
const update = () => {
if (Number.isFinite(audio.duration) && audio.duration > 0)
seek.value = String(Math.round((audio.currentTime / audio.duration) * 1000));
};
seek.oninput = () => {
if (Number.isFinite(audio.duration))
audio.currentTime = (Number(seek.value) / 1000) * audio.duration;
};
audio.addEventListener("timeupdate", update);
button.onclick = async () => {
button.disabled = true;
note.textContent = "Building waveform…";
parent.append(note);
try {
if (!view?.AudioContext) throw new Error("No decoder");
// 8 MiB encoded and 10 minutes of audio bound this optional analysis.
if (!Number.isFinite(audio.duration) || audio.duration > 600)
throw new Error("Duration unavailable or too long");
const file = await readSource(source, 8 * 1024 * 1024, { signal });
if (disposed) return;
context = new view.AudioContext();
const decoded = await context.decodeAudioData(await file.arrayBuffer());
if (disposed) return;
const peaks = waveformPeaks(
Array.from({ length: decoded.numberOfChannels }, (_, i) => decoded.getChannelData(i)),
);
const draw = canvas.getContext("2d");
if (!draw) throw new Error("No canvas");
draw.fillStyle =
view.getComputedStyle(parent).getPropertyValue("--wrn-color-primary") ||
view.getComputedStyle(parent).color;
peaks.forEach((peak, i) => {
const h = Math.max(1, peak * 90);
draw.fillRect(i * 4, (96 - h) / 2, 3, h);
});
note.textContent = "";
button.remove();
parent.append(canvas, seek);
} catch {
if (!disposed) {
note.textContent = "Waveform unavailable for this file. Audio controls remain available.";
button.disabled = false;
}
} finally {
await context?.close().catch(() => {});
context = undefined;
}
};
parent.append(button);
return () => {
disposed = true;
audio.removeEventListener("timeupdate", update);
void context?.close().catch(() => {});
button.remove();
note.remove();
canvas.remove();
seek.remove();
};
}
+75
View File
@@ -0,0 +1,75 @@
import { elementFactory } from "./dom.ts";
import { renderTable, type TableData, type TableView } from "./table-view.ts";
export interface WorkbookSheet extends TableData {
name: string;
}
/**
* A workbook: the grid scrolls, and the sheet bar stays beneath it with the
* active tab joined to the sheet — where a spreadsheet keeps it.
*
* Shared by `.xlsx` and `.ods` so the same file looks the same whichever
* reader opened it.
*/
export function renderWorkbook(parent: HTMLElement, sheets: WorkbookSheet[]): () => void {
const doc = parent.ownerDocument;
const element = elementFactory(doc);
const shell = element("div");
shell.className = "wrn-viewer-workbook";
const surface = element("div");
surface.className = "wrn-viewer-workbook-surface";
const bar = element("div");
bar.className = "wrn-viewer-sheetbar";
bar.setAttribute("role", "tablist");
bar.setAttribute("aria-label", "Sheets");
const status = element("span");
status.className = "wrn-viewer-sheetbar-status";
let view: TableView | undefined;
let current = 0;
const show = (index: number) => {
current = Math.max(0, Math.min(sheets.length - 1, index));
const sheet = sheets[current]!;
view?.dispose();
view = renderTable(surface, sheet);
surface.replaceChildren(view.node);
status.textContent = view.caption;
tabs.forEach((tab, position) => {
const selected = position === current;
tab.setAttribute("aria-selected", String(selected));
tab.tabIndex = selected ? 0 : -1;
});
view.refresh();
};
const tabs = sheets.map((sheet, index) => {
const tab = element("button", sheet.name);
tab.type = "button";
tab.setAttribute("role", "tab");
tab.className = "wrn-viewer-sheettab";
tab.title = sheet.name;
tab.addEventListener("click", () => show(index));
return tab;
});
// Arrow keys walk the bar, as a tablist should.
bar.addEventListener("keydown", (event) => {
const step = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0;
if (!step) return;
event.preventDefault();
const next = (current + step + sheets.length) % sheets.length;
show(next);
tabs[next]?.focus();
});
bar.append(...tabs, status);
// One sheet needs no tabs, but its size is still worth stating.
if (sheets.length === 1) bar.classList.add("is-single");
shell.append(surface, bar);
parent.append(shell);
show(0);
return () => view?.dispose();
}
+62
View File
@@ -0,0 +1,62 @@
import { elementFactory, requireFile, setButtonLabel } from "./dom.ts";
import { branch, leaf } from "./tree.ts";
import type { ViewerMount } from "../types.ts";
function label(node: Element): string {
const attributes = [...node.attributes]
.map((attribute) => `${attribute.name}="${attribute.value}"`)
.join(" ");
return `<${node.tagName}${attributes ? ` ${attributes}` : ""}>`;
}
function render(doc: Document, node: Element, depth: number): HTMLElement {
const children = [...node.childNodes].filter(
(child) => child.nodeType === 1 || (child.textContent ?? "").trim(),
);
const elements = children.filter((child) => child.nodeType === 1) as Element[];
if (!elements.length)
return leaf(doc, label(node), (node.textContent ?? "").trim(), "wrn-viewer-value");
return branch(
doc,
label(node),
"wrn-viewer-key",
(into) => {
for (const child of children)
into.append(
child.nodeType === 1
? render(doc, child as Element, depth + 1)
: leaf(doc, "", (child.textContent ?? "").trim(), "wrn-viewer-value"),
);
},
depth < 2,
);
}
export const mountXml: ViewerMount = async ({ parent, file, button, signal }) => {
const doc = parent.ownerDocument;
const view = doc.defaultView;
const element = elementFactory(doc);
const text = await requireFile(file).text();
if (signal.aborted) return;
if (!view?.DOMParser) throw new Error("No XML parser available");
const parsed = new view.DOMParser().parseFromString(text, "application/xml");
const root = parsed.documentElement;
// Malformed XML throws, and the ladder falls through to the source view.
if (!root || parsed.getElementsByTagName("parsererror").length)
throw new Error("Malformed XML document");
const tree = element("div");
tree.className = "wrn-viewer-tree";
tree.append(render(doc, root, 0));
const raw = element("pre", text);
let showingRaw = false;
const toggle = button("Raw", () => {
showingRaw = !showingRaw;
setButtonLabel(toggle, showingRaw ? "Tree" : "Raw");
toggle.setAttribute("aria-pressed", String(showingRaw));
parent.replaceChildren(showingRaw ? raw : tree);
});
toggle.setAttribute("aria-pressed", "false");
parent.append(tree);
};
+22
View File
@@ -0,0 +1,22 @@
import { loadOptional } from "../optional/load.ts";
import type { listZip } from "../optional/zip.ts";
import { elementFactory, requireFile } from "./dom.ts";
import { entriesSummary, renderEntries } from "./table-view.ts";
import type { ViewerMount } from "../types.ts";
export const mountZip: ViewerMount = async ({ parent, file, source, signal }) => {
const element = elementFactory(parent.ownerDocument);
const { listZip: list } = await loadOptional<{ listZip: typeof listZip }>(
"viewer-zip",
parent.ownerDocument.baseURI,
);
const bytes = new Uint8Array(await requireFile(file).arrayBuffer());
if (signal.aborted) return;
const entries = await list(bytes);
if (signal.aborted) return;
if (!entries.length) throw new Error("Archive holds no readable entries");
parent.append(
element("p", entriesSummary(entries)),
renderEntries(parent.ownerDocument, entries, `Contents of ${source.name}`),
);
};