420 lines
15 KiB
HTML
420 lines
15 KiB
HTML
<!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: #3a3a3a;
|
||
font-family: system-ui, -apple-system, sans-serif;
|
||
color: #ccc;
|
||
}
|
||
|
||
body { display: flex; flex-direction: column; }
|
||
|
||
#error-banner {
|
||
display: none;
|
||
flex-shrink: 0;
|
||
background: #5c1a1a;
|
||
color: #f99;
|
||
padding: 10px 20px;
|
||
font-size: 13px;
|
||
border-bottom: 1px solid #7a2222;
|
||
}
|
||
|
||
#toolbar {
|
||
flex-shrink: 0;
|
||
background: #252525;
|
||
border-bottom: 1px solid #444;
|
||
padding: 5px 14px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
font-size: 13px;
|
||
}
|
||
|
||
#zoom-controls { display: flex; align-items: center; gap: 6px; }
|
||
|
||
button {
|
||
background: #3d3d3d;
|
||
color: #ccc;
|
||
border: 1px solid #555;
|
||
border-radius: 4px;
|
||
padding: 3px 10px;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
line-height: 1.4;
|
||
min-width: 28px;
|
||
}
|
||
button:hover { background: #4a4a4a; }
|
||
|
||
#zoom-label {
|
||
min-width: 46px;
|
||
text-align: center;
|
||
font-size: 12px;
|
||
color: #bbb;
|
||
}
|
||
|
||
#page-indicator {
|
||
color: #888;
|
||
font-size: 12px;
|
||
}
|
||
|
||
#status {
|
||
margin-left: auto;
|
||
color: #888;
|
||
font-size: 12px;
|
||
}
|
||
|
||
/* Scrollable viewport — contains the zoomed pages wrapper */
|
||
#canvas-container {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
overflow-x: auto;
|
||
}
|
||
|
||
/* 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 (1–2)
|
||
// 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>
|