Files
brittle/src-tauri/assets/viewer/index.html
2026-03-27 18:14:23 +01:00

421 lines
15 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PDF Viewer — Brittle</title>
<style>
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
height: 100%;
overflow: hidden;
background: #1d2021;
font-family: system-ui, -apple-system, sans-serif;
color: #ebdbb2;
}
body { display: flex; flex-direction: column; }
#error-banner {
display: none;
flex-shrink: 0;
background: #fb4934;
color: #1d2021;
padding: 10px 20px;
font-size: 13px;
border-bottom: 1px solid #cc241d;
}
#toolbar {
flex-shrink: 0;
background: #282828;
border-bottom: 1px solid #504945;
padding: 5px 14px;
display: flex;
align-items: center;
gap: 14px;
font-size: 13px;
}
#zoom-controls { display: flex; align-items: center; gap: 6px; }
button {
background: #3c3836;
color: #ebdbb2;
border: 1px solid #504945;
border-radius: 4px;
padding: 3px 10px;
cursor: pointer;
font-size: 13px;
line-height: 1.4;
min-width: 28px;
}
button:hover { background: #504945; }
#zoom-label {
min-width: 46px;
text-align: center;
font-size: 12px;
color: #a89984;
}
#page-indicator {
color: #a89984;
font-size: 12px;
}
#status {
margin-left: auto;
color: #928374;
font-size: 12px;
}
/* Scrollable viewport — contains the zoomed pages wrapper */
#canvas-container {
flex: 1;
overflow-y: auto;
overflow-x: auto;
background: #1d2021;
}
/* Inner column that receives the CSS zoom for instant visual feedback.
CSS zoom (unlike transform) affects layout, so scrollbars stay correct. */
#pages-wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px 0;
min-width: fit-content;
margin: 0 auto;
}
.page-wrapper { flex-shrink: 0; }
.page-wrapper canvas {
display: block;
background: #fff;
box-shadow: 0 3px 14px rgba(0, 0, 0, 0.55);
}
</style>
</head>
<body>
<div id="error-banner"></div>
<div id="toolbar">
<div id="zoom-controls">
<button id="btn-zoom-out" title="Zoom out [ ]"></button>
<span id="zoom-label"></span>
<button id="btn-zoom-in" title="Zoom in [ + ]">+</button>
<button id="btn-zoom-fit" title="Fit width [ 0 ]">Fit</button>
</div>
<span id="page-indicator">— / —</span>
<span id="status">Loading PDF.js…</span>
</div>
<div id="canvas-container"></div>
<script src="brittle://app/pdfjs/build/pdf.min.js"></script>
<script>
"use strict";
const refId = new URLSearchParams(location.search).get("ref_id") || "";
const pdfjsLib = window.pdfjsLib;
if (!pdfjsLib) {
showError("PDF.js failed to load. Make sure the app is running inside Brittle.");
} else {
pdfjsLib.GlobalWorkerOptions.workerSrc =
"brittle://app/pdfjs/build/pdf.worker.min.js";
const container = document.getElementById("canvas-container");
const statusEl = document.getElementById("status");
const zoomLabel = document.getElementById("zoom-label");
const pageIndicator = document.getElementById("page-indicator");
const DPR = window.devicePixelRatio || 1;
const ZOOM_MIN = 0.1;
const ZOOM_MAX = 5.0;
let pdfDoc = null;
let scale = 1.0; // desired display scale
let renderScale = 1.0; // scale at which pagesWrapper canvases are rendered
let renderGen = 0; // incremented on each render to cancel stale passes
let renderTimer = null;
let pagesWrapper = null; // the current #pages-wrapper element
let pendingRenderTask = null; // the active page.render() RenderTask, if any
// ── Utilities ──────────────────────────────────────────────────────────
function setStatus(msg) { statusEl.textContent = msg; }
function clampScale(s) {
return Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, s));
}
function updateZoomLabel() {
zoomLabel.textContent = Math.round(scale * 100) + "%";
}
// ── Zoom ───────────────────────────────────────────────────────────────
// anchorY/anchorX: offsets within the container to keep fixed on screen.
// Default to the centre of the visible area.
function applyScale(
newScale,
anchorY = container.clientHeight / 2,
anchorX = container.clientWidth / 2,
) {
const oldScale = scale;
const oldScrollTop = container.scrollTop;
const oldScrollLeft = container.scrollLeft;
scale = clampScale(newScale);
updateZoomLabel();
const ratio = scale / oldScale;
const cssZoom = scale / renderScale;
// Apply CSS zoom to each page wrapper individually — NOT to the flex
// container. This keeps the container's padding and gap constant, so
// page positions are identical before and after the background re-render
// (no jump when the re-render swaps in naturally-sized canvases).
if (pagesWrapper) {
for (const wrap of pagesWrapper.children) {
wrap.style.zoom = cssZoom;
}
}
// Keep the document point under the cursor at the same screen position.
container.scrollTop = Math.max(0, (oldScrollTop + anchorY) * ratio - anchorY);
container.scrollLeft = Math.max(0, (oldScrollLeft + anchorX) * ratio - anchorX);
scheduleRender();
}
async function fitToWidth() {
if (!pdfDoc) return scale;
const page = await pdfDoc.getPage(1);
const vp = page.getViewport({ scale: 1.0 });
const avail = container.clientWidth - 40;
return clampScale(avail / vp.width);
}
// ── Rendering ──────────────────────────────────────────────────────────
function scheduleRender() {
clearTimeout(renderTimer);
renderTimer = setTimeout(renderAll, 300);
}
async function renderAll() {
if (!pdfDoc) return;
pendingRenderTask?.cancel();
pendingRenderTask = null;
const gen = ++renderGen;
const targetScale = scale;
const savedScrollTop = container.scrollTop;
const savedScrollLeft = container.scrollLeft;
// Build new wrapper with pre-sized canvases derived from the old ones.
// Pre-sizing means the scroll range is correct the instant we swap, so
// savedScrollTop/Left can be restored exactly without a jump.
const newWrapper = document.createElement("div");
newWrapper.id = "pages-wrapper";
const wrappers = [];
for (let i = 1; i <= pdfDoc.numPages; i++) {
const wrap = document.createElement("div");
wrap.className = "page-wrapper";
wrap.dataset.page = String(i);
const canvas = document.createElement("canvas");
if (pagesWrapper) {
const oldCanvas = pagesWrapper.children[i - 1]?.querySelector("canvas");
if (oldCanvas?.style.width) {
const ratio = targetScale / renderScale;
canvas.style.width = Math.round(parseFloat(oldCanvas.style.width) * ratio) + "px";
canvas.style.height = Math.round(parseFloat(oldCanvas.style.height) * ratio) + "px";
}
}
wrap.appendChild(canvas);
newWrapper.appendChild(wrap);
wrappers.push(wrap);
}
// For zoom re-renders: pre-render the currently visible pages (12)
// before the DOM swap. Only a small number of pages, so off-screen is
// fast; the swap then reveals already-drawn content with no white flash.
// Skipped for the initial load (pagesWrapper is null) where progressive
// in-DOM rendering is preferable.
const preRendered = new Set();
if (pagesWrapper) {
const first = getCurrentPage();
const last = Math.min(first + 1, pdfDoc.numPages);
for (let p = first; p <= last; p++) {
if (renderGen !== gen) return;
await renderPage(wrappers[p - 1], p, targetScale);
preRendered.add(p - 1);
}
}
if (renderGen !== gen) return;
// Swap into DOM — visible pages are already rendered on re-render.
// DOM mutations + scroll restore are synchronous, landing in one paint.
container.innerHTML = "";
container.appendChild(newWrapper);
pagesWrapper = newWrapper;
renderScale = targetScale;
container.scrollTop = savedScrollTop;
container.scrollLeft = savedScrollLeft;
// Render remaining pages in-DOM (GPU-accelerated, progressive).
for (let i = 0; i < wrappers.length; i++) {
if (preRendered.has(i)) continue;
if (renderGen !== gen) return;
await renderPage(wrappers[i], i + 1, targetScale);
}
if (renderGen !== gen) return;
setStatus("Ready");
refreshPageIndicator();
}
async function renderPage(wrapper, pageNum, targetScale) {
let task = null;
try {
const page = await pdfDoc.getPage(pageNum);
const vp = page.getViewport({ scale: targetScale * DPR });
const canvas = wrapper.querySelector("canvas");
if (!canvas) return;
canvas.width = vp.width;
canvas.height = vp.height;
canvas.style.width = Math.round(vp.width / DPR) + "px";
canvas.style.height = Math.round(vp.height / DPR) + "px";
task = page.render({ canvasContext: canvas.getContext("2d"), viewport: vp });
pendingRenderTask = task;
await task.promise;
} catch (e) {
if (e?.name !== "RenderingCancelledException") console.warn("render:", e);
} finally {
if (pendingRenderTask === task) pendingRenderTask = null;
}
}
// ── Page indicator and page navigation ─────────────────────────────────
// Returns the 1-based number of the topmost visible page.
function getCurrentPage() {
const top = container.getBoundingClientRect().top;
for (const wrap of container.querySelectorAll(".page-wrapper")) {
if (wrap.getBoundingClientRect().bottom > top + 4) {
return parseInt(wrap.dataset.page, 10);
}
}
return 1;
}
function refreshPageIndicator() {
if (!pdfDoc) return;
pageIndicator.textContent = getCurrentPage() + " / " + pdfDoc.numPages;
}
// Scroll so that the top of page `pageNum` aligns with the container top.
// getBoundingClientRect() is used so CSS zoom is accounted for correctly.
function scrollToPage(pageNum) {
const wrap = container.querySelector(`.page-wrapper[data-page="${pageNum}"]`);
if (!wrap) return;
container.scrollTop +=
wrap.getBoundingClientRect().top - container.getBoundingClientRect().top;
}
container.addEventListener("scroll", refreshPageIndicator, { passive: true });
// Page-navigation commands posted from the outer Leptos app (fired when
// a global keymap action such as pdf.page.next/prev is dispatched while
// this tab is active, regardless of which element has keyboard focus).
window.addEventListener("message", ev => {
if (ev.data === "pdf.page.next" && pdfDoc)
scrollToPage(Math.min(getCurrentPage() + 1, pdfDoc.numPages));
if (ev.data === "pdf.page.prev" && pdfDoc)
scrollToPage(Math.max(getCurrentPage() - 1, 1));
});
// ── Load ───────────────────────────────────────────────────────────────
async function load() {
if (!refId) { showError("No ref_id in URL."); return; }
setStatus("Loading…");
try {
const url = "brittle://app/pdf?ref_id=" + encodeURIComponent(refId);
pdfDoc = await pdfjsLib.getDocument({ url }).promise;
scale = await fitToWidth();
renderScale = scale;
updateZoomLabel();
setStatus("Rendering…");
await renderAll();
} catch (e) {
showError("Could not load PDF: " + e.message);
}
}
// ── Toolbar buttons ────────────────────────────────────────────────────
document.getElementById("btn-zoom-out").addEventListener("click",
() => applyScale(scale / 1.25));
document.getElementById("btn-zoom-in").addEventListener("click",
() => applyScale(scale * 1.25));
document.getElementById("btn-zoom-fit").addEventListener("click",
async () => applyScale(await fitToWidth()));
// Ctrl+Scroll — anchor at the cursor position.
container.addEventListener("wheel", ev => {
if (!ev.ctrlKey) return;
ev.preventDefault();
const rect = container.getBoundingClientRect();
const anchorY = ev.clientY - rect.top;
const anchorX = ev.clientX - rect.left;
applyScale(scale * (ev.deltaY < 0 ? 1.1 : 1 / 1.1), anchorY, anchorX);
}, { passive: false });
// Keyboard shortcuts (active when the iframe has focus).
// Also forwards every keydown to the parent window so global keybindings
// (tab switching, etc.) keep working when the PDF view has focus.
document.addEventListener("keydown", ev => {
if (ev.target.tagName === "INPUT") return;
if (ev.key === "+" || ev.key === "=") { ev.preventDefault(); applyScale(scale * 1.25); }
if (ev.key === "-") { ev.preventDefault(); applyScale(scale / 1.25); }
if (ev.key === "0") { ev.preventDefault(); fitToWidth().then(applyScale); }
// Forward to the parent Leptos document for the global keymap.
if (window.parent !== window) {
window.parent.postMessage({
type: "brittle:keydown",
key: ev.key,
ctrlKey: ev.ctrlKey,
shiftKey: ev.shiftKey,
altKey: ev.altKey,
metaKey: ev.metaKey,
}, "*");
}
});
load();
}
function showError(msg) {
const b = document.getElementById("error-banner");
b.textContent = msg;
b.style.display = "block";
document.getElementById("status").textContent = "Error";
}
</script>
</body>
</html>