256 lines
10 KiB
JavaScript
256 lines
10 KiB
JavaScript
/**
|
|
* viewer.js — PDF viewer orchestrator.
|
|
*
|
|
* Rendering happens on the main thread using the pdfDoc loaded here.
|
|
* PDF.js's own sub-worker (pdf.worker.min.js) does the heavy parsing and
|
|
* rasterisation off-thread; only the final bitmap transfer touches the
|
|
* main thread. A separate render-worker is not used because Tauri's WebKit
|
|
* webview does not support nested workers (workers spawned from workers),
|
|
* which PDF.js requires for its own internal worker.
|
|
*
|
|
* Init sequence:
|
|
* 1. Parse ref_id, configure PDF.js worker source
|
|
* 2. Load pdfDoc (for viewports + rendering)
|
|
* 3. Fetch all page viewports at scale=1
|
|
* 4. Create PageManager → N placeholder divs in #pages-wrapper
|
|
* 5. Create ViewportTracker → observe placeholders
|
|
* 6. Compute fit-to-width → create ZoomController
|
|
* 7. Create MessageBridge
|
|
* 8. Register lifecycle handlers
|
|
* 9. Trigger initial reconcile
|
|
*/
|
|
|
|
import { ViewportTracker } from "./viewport-tracker.js";
|
|
import { PageManager } from "./page-manager.js";
|
|
import { ZoomController } from "./zoom-controller.js";
|
|
import { MessageBridge } from "./message-bridge.js";
|
|
|
|
// ── DOM refs ─────────────────────────────────────────────────────────────────
|
|
const container = document.getElementById("canvas-container");
|
|
const pagesWrapper = document.getElementById("pages-wrapper");
|
|
const statusEl = document.getElementById("status");
|
|
const zoomLabel = document.getElementById("zoom-label");
|
|
const pageIndicator = document.getElementById("page-indicator");
|
|
|
|
// ── Global state ─────────────────────────────────────────────────────────────
|
|
const params = new URLSearchParams(location.search);
|
|
const refId = params.get("ref_id") || "";
|
|
const savedZoom = parseFloat(params.get("zoom")); // NaN if absent
|
|
const savedScrollTop = parseFloat(params.get("scroll_top")); // NaN if absent
|
|
const DPR = window.devicePixelRatio || 1;
|
|
|
|
let pdfDoc = null;
|
|
let pageManager = null;
|
|
let viewportTracker = null;
|
|
let zoomController = null;
|
|
let bridge = null;
|
|
|
|
let currentBufferSet = new Set();
|
|
let currentVisibleSet = new Set();
|
|
|
|
// ── Utilities ────────────────────────────────────────────────────────────────
|
|
function setStatus(msg) { statusEl.textContent = msg; }
|
|
|
|
function showError(msg) {
|
|
const b = document.getElementById("error-banner");
|
|
b.textContent = msg;
|
|
b.style.display = "block";
|
|
setStatus("Error");
|
|
}
|
|
|
|
function refreshPageIndicator() {
|
|
if (!pageManager) return;
|
|
const cur = pageManager.getCurrentPage(currentVisibleSet);
|
|
pageIndicator.textContent = cur + " / " + pageManager.numPages;
|
|
}
|
|
|
|
async function fitToPage() {
|
|
if (!pdfDoc) return 1.0;
|
|
const page = await pdfDoc.getPage(1);
|
|
const vp = page.getViewport({ scale: 1.0 });
|
|
const scaleW = (container.clientWidth - 40) / vp.width;
|
|
const scaleH = (container.clientHeight - 40) / vp.height;
|
|
return Math.max(0.1, Math.min(5.0, Math.min(scaleW, scaleH)));
|
|
}
|
|
|
|
function scrollToPage(pageNum) {
|
|
if (!pageManager) return;
|
|
const wrap = pageManager.pageWrappers[pageNum - 1];
|
|
if (!wrap) return;
|
|
container.scrollTop +=
|
|
wrap.getBoundingClientRect().top - container.getBoundingClientRect().top;
|
|
}
|
|
|
|
// ── Main-thread rendering ────────────────────────────────────────────────────
|
|
// dispatchRender is called by PageManager when a page enters the buffer.
|
|
// We fire-and-forget an async render; the gen check inside discards stale work.
|
|
|
|
function dispatchRender(pageNum, scale, _vpWidth, _vpHeight, gen) {
|
|
renderPage(pageNum, scale, gen);
|
|
}
|
|
|
|
async function renderPage(pageNum, scale, gen) {
|
|
if (!pdfDoc) return;
|
|
let page;
|
|
try {
|
|
page = await pdfDoc.getPage(pageNum);
|
|
if (gen !== pageManager?.renderGen) return; // superseded by zoom or cleanup
|
|
|
|
const vp = page.getViewport({ scale });
|
|
const width = Math.round(vp.width);
|
|
const height = Math.round(vp.height);
|
|
|
|
const offscreen = new OffscreenCanvas(width, height);
|
|
await page.render({ canvasContext: offscreen.getContext("2d"), viewport: vp }).promise;
|
|
if (gen !== pageManager?.renderGen) return; // superseded during render
|
|
|
|
const bitmap = offscreen.transferToImageBitmap();
|
|
pageManager?.onRendered(pageNum, gen, bitmap);
|
|
refreshPageIndicator();
|
|
if (pageManager?.allRendered) setStatus("Ready");
|
|
} catch (e) {
|
|
if (e?.name !== "RenderingCancelledException") {
|
|
console.warn("[viewer] render error page", pageNum, e);
|
|
}
|
|
} finally {
|
|
page?.cleanup();
|
|
}
|
|
}
|
|
|
|
function sendViewerState() {
|
|
if (!bridge || !zoomController) return;
|
|
bridge.postViewerState(refId, zoomController.scale, container.scrollTop);
|
|
}
|
|
|
|
// ── Visibility change callback (called by ViewportTracker) ───────────────────
|
|
function onVisibilityChange(bufferSet, visibleSet) {
|
|
currentBufferSet = bufferSet;
|
|
currentVisibleSet = visibleSet;
|
|
pageManager?.reconcile(bufferSet, visibleSet);
|
|
refreshPageIndicator();
|
|
}
|
|
|
|
// ── Main init ────────────────────────────────────────────────────────────────
|
|
async function load() {
|
|
if (!refId) { showError("No ref_id in URL."); return; }
|
|
setStatus("Loading…");
|
|
|
|
const pdfjsLib = window.pdfjsLib;
|
|
if (!pdfjsLib) { showError("PDF.js failed to load."); return; }
|
|
pdfjsLib.GlobalWorkerOptions.workerSrc = "brittle://app/pdfjs/build/pdf.worker.min.js";
|
|
|
|
const pdfUrl = "brittle://app/pdf?ref_id=" + encodeURIComponent(refId);
|
|
|
|
try {
|
|
// 1. Load PDF
|
|
pdfDoc = await pdfjsLib.getDocument({ url: pdfUrl }).promise;
|
|
|
|
// 2. Fetch all page viewports at scale=1
|
|
setStatus("Reading…");
|
|
const viewports = [];
|
|
for (let i = 1; i <= pdfDoc.numPages; i++) {
|
|
const page = await pdfDoc.getPage(i);
|
|
const vp = page.getViewport({ scale: 1.0 });
|
|
viewports.push({ width: vp.width, height: vp.height });
|
|
}
|
|
|
|
// 3. Compute initial scale: use saved zoom if available, else fit full page
|
|
const fittedScale = Math.max(0.1, Math.min(5.0, Math.min(
|
|
(container.clientWidth - 40) / viewports[0].width,
|
|
(container.clientHeight - 40) / viewports[0].height,
|
|
)));
|
|
const initialScale = (savedZoom > 0) ? Math.max(0.1, Math.min(5.0, savedZoom)) : fittedScale;
|
|
|
|
// 4. PageManager — creates placeholder divs
|
|
pageManager = new PageManager(
|
|
pagesWrapper, viewports, initialScale, DPR, dispatchRender,
|
|
);
|
|
|
|
// 5. ViewportTracker — IntersectionObserver fires once elements are in the
|
|
// DOM, triggering the initial reconcile automatically.
|
|
viewportTracker = new ViewportTracker(
|
|
container, pageManager.pageWrappers, onVisibilityChange,
|
|
);
|
|
|
|
// 6. ZoomController — send state after each debounced re-render
|
|
zoomController = new ZoomController(
|
|
container,
|
|
pageManager,
|
|
(newScale, bufferSet, visibleSet) => {
|
|
pageManager.onScaleChange(newScale, bufferSet, visibleSet);
|
|
sendViewerState();
|
|
},
|
|
() => ({ bufferSet: currentBufferSet, visibleSet: currentVisibleSet }),
|
|
zoomLabel,
|
|
initialScale,
|
|
);
|
|
|
|
// 7. MessageBridge
|
|
bridge = new MessageBridge(
|
|
() => scrollToPage(Math.min(pageManager.getCurrentPage(currentVisibleSet) + 1, pdfDoc.numPages)),
|
|
() => scrollToPage(Math.max(pageManager.getCurrentPage(currentVisibleSet) - 1, 1)),
|
|
);
|
|
|
|
// 8. Toolbar buttons
|
|
document.getElementById("btn-zoom-out").addEventListener("click",
|
|
() => zoomController.applyScale(zoomController.scale / 1.25));
|
|
document.getElementById("btn-zoom-in").addEventListener("click",
|
|
() => zoomController.applyScale(zoomController.scale * 1.25));
|
|
document.getElementById("btn-zoom-fit").addEventListener("click",
|
|
async () => zoomController.applyScale(await fitToPage()));
|
|
|
|
// Keyboard shortcuts + keydown forwarding to parent
|
|
document.addEventListener("keydown", ev => {
|
|
if (ev.target.tagName === "INPUT") return;
|
|
if (ev.key === "+" || ev.key === "=") { ev.preventDefault(); zoomController.applyScale(zoomController.scale * 1.25); }
|
|
if (ev.key === "-") { ev.preventDefault(); zoomController.applyScale(zoomController.scale / 1.25); }
|
|
if (ev.key === "0") { ev.preventDefault(); fitToPage().then(s => zoomController.applyScale(s)); }
|
|
bridge.forwardKeydown(ev);
|
|
});
|
|
|
|
// Scroll → update page indicator + debounced state save
|
|
let _scrollSaveTimer = null;
|
|
container.addEventListener("scroll", () => {
|
|
refreshPageIndicator();
|
|
if (_scrollSaveTimer) clearTimeout(_scrollSaveTimer);
|
|
_scrollSaveTimer = setTimeout(sendViewerState, 500);
|
|
}, { passive: true });
|
|
|
|
// Restore saved scroll position (rAF ensures layout is ready)
|
|
if (savedScrollTop > 0) {
|
|
requestAnimationFrame(() => { container.scrollTop = savedScrollTop; });
|
|
}
|
|
|
|
// Lifecycle: tab hidden → free caches; tab visible → re-reconcile
|
|
document.addEventListener("visibilitychange", () => {
|
|
if (document.hidden) {
|
|
pdfDoc?.cleanup();
|
|
} else {
|
|
pageManager?.reconcile(currentBufferSet, currentVisibleSet);
|
|
}
|
|
});
|
|
|
|
// Cleanup on unload
|
|
window.addEventListener("beforeunload", () => {
|
|
viewportTracker?.disconnect();
|
|
bridge?.disconnect();
|
|
pdfDoc?.destroy();
|
|
});
|
|
|
|
// DPR change (e.g., moving window to a monitor with different DPI)
|
|
matchMedia(`(resolution: ${DPR}dppx)`).addEventListener("change", () => {
|
|
if (pageManager && zoomController) {
|
|
pageManager.onScaleChange(zoomController.scale, currentBufferSet, currentVisibleSet);
|
|
}
|
|
});
|
|
|
|
pageIndicator.textContent = "1 / " + pdfDoc.numPages;
|
|
setStatus("Rendering…");
|
|
|
|
} catch (e) {
|
|
showError("Could not load PDF: " + (e.message ?? String(e)));
|
|
}
|
|
}
|
|
|
|
load();
|