Ship file viewer, editor improvements, and private package releases
This commit is contained in:
1 parent
645914955c
commit
a4a31032b9
158 files changed
+10523
-137
No files matched your search
@@ -0,0 +1,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();
|
||||
};
|
||||
}
|
||||
@@ -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."));
|
||||
};
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
};
|
||||
@@ -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)));
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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(", ");
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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}`),
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user