New architecture

This commit is contained in:
2026-04-03 18:45:08 +02:00
parent 4613b8e5dd
commit 0d0e9fe043
24 changed files with 2747 additions and 908 deletions

View File

@@ -122,7 +122,6 @@
<div id="pages-wrapper"></div>
</div>
<script src="brittle://app/pdfjs/build/pdf.min.js"></script>
<script type="module" src="brittle://app/viewer/viewer.js"></script>
<script src="brittle://app/viewer/viewer.bundle.js"></script>
</body>
</html>

View File

@@ -1,48 +0,0 @@
/**
* MessageBridge — postMessage protocol between the PDF viewer iframe
* and the parent Leptos application.
*
* Inbound (parent → iframe): "pdf.page.next" | "pdf.page.prev"
* Outbound (iframe → parent): { type: "brittle:keydown", key, ctrlKey, ... }
*/
export class MessageBridge {
/**
* @param {Function} onPageNext - () => void
* @param {Function} onPagePrev - () => void
*/
constructor(onPageNext, onPagePrev) {
this._handler = ev => {
if (ev.data === "pdf.page.next") onPageNext();
if (ev.data === "pdf.page.prev") onPagePrev();
};
window.addEventListener("message", this._handler);
}
/** Send the current viewer state (zoom + scroll) to the parent window. */
postViewerState(refId, zoom, scrollTop) {
if (window.parent === window) return;
window.parent.postMessage({
type: "brittle:viewer-state",
refId,
zoom,
scrollTop,
}, "*");
}
/** Forward a keydown event to the parent window for global keybindings. */
forwardKeydown(ev) {
if (window.parent === window) return;
window.parent.postMessage({
type: "brittle:keydown",
key: ev.key,
ctrlKey: ev.ctrlKey,
shiftKey: ev.shiftKey,
altKey: ev.altKey,
metaKey: ev.metaKey,
}, "*");
}
disconnect() {
window.removeEventListener("message", this._handler);
}
}

View File

@@ -1,256 +0,0 @@
/**
* PageManager — page lifecycle: PLACEHOLDER → RENDERING → RENDERED → PLACEHOLDER
*
* Each page is a sized div (placeholder). When a page enters the buffer,
* a canvas is created and a render is dispatched to the worker. When a page
* leaves the buffer, its canvas is removed and the page is reset to placeholder.
*/
const MAX_CANVAS_PIXELS = 16_777_216; // 4096 × 4096
/**
* Clamp render scale so the canvas pixel count stays within the budget.
* CSS dimensions stay correct — pages appear at the right size, just at
* reduced effective DPI when zoomed very high. Same strategy as Chrome's viewer.
*/
function clampedRenderScale(vpWidth, vpHeight, desiredScale, dpr) {
const w = vpWidth * desiredScale * dpr;
const h = vpHeight * desiredScale * dpr;
const pixels = w * h;
if (pixels <= MAX_CANVAS_PIXELS) return desiredScale * dpr;
return desiredScale * dpr * Math.sqrt(MAX_CANVAS_PIXELS / pixels);
}
const State = Object.freeze({ PLACEHOLDER: 0, RENDERING: 1, RENDERED: 2 });
export class PageManager {
/**
* @param {HTMLElement} wrapper - #pages-wrapper container (already in DOM)
* @param {Array<{width:number,height:number}>} viewports - scale=1 viewports (0-indexed)
* @param {number} initialScale - initial display scale
* @param {number} dpr - devicePixelRatio
* @param {Function} dispatchRender - (pageNum, scale, vpWidth, vpHeight, gen) => void
*/
constructor(wrapper, viewports, initialScale, dpr, dispatchRender) {
this._wrapper = wrapper;
this._viewports = viewports;
this._scale = initialScale;
this._dpr = dpr;
this._dispatchRender = dispatchRender;
this._renderGen = 0;
this._inFlight = 0; // renders dispatched but not yet completed/cancelled
this._zooming = false; // true during Phase 1 CSS zoom (before debounced re-render)
this._states = new Array(viewports.length).fill(State.PLACEHOLDER);
this._canvases = new Array(viewports.length).fill(null);
this._wrappers = [];
this._buildPlaceholders();
}
get pageWrappers() { return this._wrappers; }
get numPages() { return this._viewports.length; }
get renderGen() { return this._renderGen; }
/** Called by ZoomController to suppress canvas teardown during CSS zoom. */
setZooming(z) { this._zooming = z; }
_buildPlaceholders() {
for (let i = 0; i < this._viewports.length; i++) {
const vp = this._viewports[i];
const wrap = document.createElement("div");
wrap.className = "page-wrapper";
wrap.dataset.page = String(i + 1);
wrap.style.width = vp.width * this._scale + "px";
wrap.style.height = vp.height * this._scale + "px";
this._wrapper.appendChild(wrap);
this._wrappers.push(wrap);
}
}
/**
* Called on each IntersectionObserver tick.
* Renders pages entering the buffer; cleans up pages leaving it.
* Visible pages are prioritized in the render queue.
*
* @param {Set<number>} bufferSet - 1-based page numbers in the buffer zone
* @param {Set<number>} visibleSet - 1-based page numbers currently on screen
*/
reconcile(bufferSet, visibleSet) {
const gen = this._renderGen;
// Prioritize visible pages, then the rest of the buffer
const toRender = [
...[...visibleSet].sort((a, b) => a - b),
...[...bufferSet].filter(p => !visibleSet.has(p)).sort((a, b) => a - b),
];
for (const pageNum of toRender) {
const i = pageNum - 1;
if (i < 0 || i >= this._viewports.length) continue;
if (this._states[i] === State.PLACEHOLDER) {
this._startRender(i, gen);
}
}
// Clean up pages no longer in the buffer.
// Skip during active CSS zoom (Phase 1): IO may report stale intersection
// data while the layout is still settling, and prematurely removing a
// canvas causes a visible dark flash. onScaleChange (Phase 2) handles the
// authoritative cleanup once the debounce fires.
for (let i = 0; i < this._viewports.length; i++) {
const pageNum = i + 1;
if (!this._zooming && !bufferSet.has(pageNum) && this._states[i] === State.RENDERED) {
this._cleanup(i);
}
// Also cancel stale RENDERING pages outside the buffer.
// Don't remove the canvas — it's off-screen and harmless, and tearing it
// down immediately causes a dark flash when IntersectionObserver fires
// between Phase 1 (CSS zoom) and Phase 2 (debounced re-render).
if (!bufferSet.has(pageNum) && this._states[i] === State.RENDERING) {
this._inFlight--;
this._states[i] = State.PLACEHOLDER;
}
}
}
get allRendered() { return this._inFlight === 0; }
_startRender(i, gen) {
const vp = this._viewports[i];
const scale = clampedRenderScale(vp.width, vp.height, this._scale, this._dpr);
this._states[i] = State.RENDERING;
this._inFlight++;
this._dispatchRender(i + 1, scale, vp.width, vp.height, gen);
}
_cleanup(i) {
if (this._states[i] === State.RENDERING) this._inFlight--;
this._states[i] = State.PLACEHOLDER;
const canvas = this._canvases[i];
if (canvas) {
canvas.remove();
this._canvases[i] = null;
}
}
/**
* Called when the worker returns a rendered bitmap.
*
* @param {number} pageNum
* @param {number} gen - render generation the bitmap was rendered for
* @param {ImageBitmap} bitmap
*/
onRendered(pageNum, gen, bitmap) {
if (gen !== this._renderGen) {
bitmap.close();
return;
}
const i = pageNum - 1;
if (i < 0 || i >= this._viewports.length || this._states[i] !== State.RENDERING) {
bitmap.close();
return;
}
this._inFlight--;
const vp = this._viewports[i];
const wrap = this._wrappers[i];
const cssW = vp.width * this._scale;
const cssH = vp.height * this._scale;
// Remove the old canvas (may be present — kept visible during re-render to
// avoid a blank flash). Both mutations land in the same paint frame.
const old = wrap.querySelector("canvas");
if (old) old.remove();
const canvas = document.createElement("canvas");
canvas.width = bitmap.width;
canvas.height = bitmap.height;
canvas.style.width = cssW + "px";
canvas.style.height = cssH + "px";
// Zero-copy display — fall back to drawImage if bitmaprenderer unavailable
const ctx = canvas.getContext("bitmaprenderer");
if (ctx) {
ctx.transferFromImageBitmap(bitmap);
} else {
canvas.getContext("2d").drawImage(bitmap, 0, 0);
bitmap.close();
}
// Set explicit wrapper size. Do NOT touch wrap.style.zoom here —
// ZoomController may have applied a CSS zoom since the last onScaleChange
// (the user kept zooming while this render was in-flight). Resetting zoom
// to "1" would briefly show the page at the wrong visual scale until the
// next onScaleChange corrects it, causing the "zoomed far in/out" flash.
wrap.style.width = cssW + "px";
wrap.style.height = cssH + "px";
wrap.appendChild(canvas);
this._canvases[i] = canvas;
this._states[i] = State.RENDERED;
}
/**
* Called after the zoom debounce fires.
* Increments renderGen (cancels stale work), resizes all placeholders,
* then re-renders the buffer pages at the new resolution.
*
* @param {number} newScale
* @param {Set<number>} bufferSet
* @param {Set<number>} visibleSet
*/
onScaleChange(newScale, bufferSet, visibleSet) {
this._scale = newScale;
this._renderGen++;
for (let i = 0; i < this._viewports.length; i++) {
const pageNum = i + 1;
const vp = this._viewports[i];
const wrap = this._wrappers[i];
const cssW = vp.width * newScale;
const cssH = vp.height * newScale;
wrap.style.width = cssW + "px";
wrap.style.height = cssH + "px";
wrap.style.zoom = "1";
if (bufferSet.has(pageNum)) {
// Keep the old canvas visible (stretched to the new CSS size) so there
// is no blank flash while the new render is in flight. onRendered()
// will replace it atomically in the same synchronous JS turn.
const canvas = this._canvases[i];
if (canvas) {
canvas.style.width = cssW + "px";
canvas.style.height = cssH + "px";
}
if (this._states[i] === State.RENDERING) {
this._inFlight--;
}
this._states[i] = State.PLACEHOLDER;
} else {
// Off-screen: clean up immediately — not visible, so no flash.
const canvas = this._canvases[i];
if (canvas) { canvas.remove(); this._canvases[i] = null; }
this._states[i] = State.PLACEHOLDER;
}
}
this.reconcile(bufferSet, visibleSet);
}
/**
* Returns the 1-based number of the topmost visible page.
*
* @param {Set<number>} visibleSet
*/
getCurrentPage(visibleSet) {
if (visibleSet.size > 0) return Math.min(...visibleSet);
// Fall back to scroll position
const top = this._wrapper.parentElement?.getBoundingClientRect().top ?? 0;
for (const wrap of this._wrappers) {
if (wrap.getBoundingClientRect().bottom > top + 4) {
return parseInt(wrap.dataset.page, 10);
}
}
return 1;
}
}

File diff suppressed because one or more lines are too long

View File

@@ -1,87 +0,0 @@
/**
* render-worker.js — Web Worker for off-main-thread PDF rendering.
*
* Has its own PDF.js instance. Renders pages via OffscreenCanvas and transfers
* ImageBitmap objects back to the main thread with zero-copy transfer.
*
* Message protocol:
*
* Main → Worker:
* { type: "init", pdfData: ArrayBuffer } (transferred, not copied)
* { type: "render", pageNum, scale, gen }
* { type: "cancel", gen } — renderGen check handles this implicitly
* { type: "cleanup" } — pdfDoc.cleanup() (free caches)
* { type: "destroy" } — pdfDoc.destroy(); self.close()
*
* Worker → Main:
* { type: "ready", numPages }
* { type: "rendered", pageNum, gen, bitmap } (bitmap as transferable)
* { type: "error", message }
*/
importScripts("brittle://app/pdfjs/build/pdf.min.js");
const pdfjsLib = globalThis.pdfjsLib;
// Do NOT set workerSrc to a brittle:// URL here. When PDF.js tries to spawn its
// own sub-worker with new Worker("brittle://…") and that fails, it falls back to
// a "fake worker" that injects a <script> tag — which throws because `document`
// does not exist inside a Web Worker.
//
// Instead, we fetch pdf.worker.min.js in handleInit() and hand PDF.js a blob URL
// it can actually use with new Worker(blobUrl). Blob URLs created inside a worker
// are same-origin and can be used for nested workers.
let pdfDoc = null;
self.onmessage = async function (ev) {
const msg = ev.data;
switch (msg.type) {
case "init": await handleInit(msg); break;
case "render": await handleRender(msg); break;
case "cleanup":
if (pdfDoc) await pdfDoc.cleanup();
break;
case "destroy":
if (pdfDoc) { await pdfDoc.destroy(); pdfDoc = null; }
self.close();
break;
}
};
async function handleInit({ pdfData }) {
try {
// Fetch pdf.worker.min.js and create a blob URL so PDF.js can spawn its
// own sub-worker without relying on brittle:// for new Worker().
const resp = await fetch("brittle://app/pdfjs/build/pdf.worker.min.js");
const text = await resp.text();
const blob = new Blob([text], { type: "application/javascript" });
pdfjsLib.GlobalWorkerOptions.workerSrc = URL.createObjectURL(blob);
pdfDoc = await pdfjsLib.getDocument({ data: new Uint8Array(pdfData) }).promise;
self.postMessage({ type: "ready", numPages: pdfDoc.numPages });
} catch (e) {
self.postMessage({ type: "error", message: e.message });
}
}
async function handleRender({ pageNum, scale, gen }) {
if (!pdfDoc) return;
let page = null;
try {
page = await pdfDoc.getPage(pageNum);
const vp = page.getViewport({ scale });
const width = Math.round(vp.width);
const height = Math.round(vp.height);
const canvas = new OffscreenCanvas(width, height);
const ctx = canvas.getContext("2d");
await page.render({ canvasContext: ctx, viewport: vp }).promise;
const bitmap = canvas.transferToImageBitmap();
self.postMessage({ type: "rendered", pageNum, gen, bitmap }, [bitmap]);
} catch (e) {
if (e?.name !== "RenderingCancelledException") {
console.warn("[render-worker] render error page", pageNum, e);
}
} finally {
if (page) page.cleanup();
}
}

View File

@@ -0,0 +1,567 @@
"use strict";
(() => {
// src/types.ts
var PageState = {
PLACEHOLDER: 0,
RENDERING: 1,
RENDERED: 2
};
// src/page-manager.ts
var MAX_CANVAS_PIXELS = 16777216;
function clampedRenderScale(vpWidth, vpHeight, desiredScale, dpr) {
const w = vpWidth * desiredScale * dpr;
const h = vpHeight * desiredScale * dpr;
const pixels = w * h;
if (pixels <= MAX_CANVAS_PIXELS) return desiredScale * dpr;
return desiredScale * dpr * Math.sqrt(MAX_CANVAS_PIXELS / pixels);
}
var PageManager = class {
constructor(wrapper, dims, initialScale, dpr, dispatchRender2) {
this._wrappers = [];
this._renderGen = 0;
this._inFlight = 0;
this._zooming = false;
this._wrapper = wrapper;
this._dims = dims;
this._scale = initialScale;
this._dpr = dpr;
this._dispatchRender = dispatchRender2;
this._states = new Array(dims.length).fill(PageState.PLACEHOLDER);
this._canvases = new Array(dims.length).fill(null);
this._buildPlaceholders();
}
get pageWrappers() {
return this._wrappers;
}
get numPages() {
return this._dims.length;
}
get renderGen() {
return this._renderGen;
}
/** True when no renders are currently in flight (all visible pages are sharp). */
get allRendered() {
return this._inFlight === 0;
}
/** Called by ZoomController to suppress canvas teardown during Phase 1 CSS zoom. */
setZooming(z) {
this._zooming = z;
}
_buildPlaceholders() {
for (let i = 0; i < this._dims.length; i++) {
const dim = this._dims[i];
const wrap = document.createElement("div");
wrap.className = "page-wrapper";
wrap.dataset["page"] = String(i + 1);
wrap.style.width = dim.width * this._scale + "px";
wrap.style.height = dim.height * this._scale + "px";
this._wrapper.appendChild(wrap);
this._wrappers.push(wrap);
}
}
/**
* Called on each IntersectionObserver tick.
* Dispatches renders for pages entering the buffer (visible pages first).
* Cleans up pages that have left the buffer (unless zooming).
*/
reconcile(bufferSet, visibleSet) {
const gen = this._renderGen;
const toRender = [
...[...visibleSet].sort((a, b) => a - b),
...[...bufferSet].filter((p) => !visibleSet.has(p)).sort((a, b) => a - b)
];
for (const pageNum of toRender) {
const i = pageNum - 1;
if (i < 0 || i >= this._dims.length) continue;
if (this._states[i] === PageState.PLACEHOLDER) {
this._startRender(i, gen);
}
}
for (let i = 0; i < this._dims.length; i++) {
const pageNum = i + 1;
if (!this._zooming && !bufferSet.has(pageNum) && this._states[i] === PageState.RENDERED) {
this._cleanup(i);
}
if (!bufferSet.has(pageNum) && this._states[i] === PageState.RENDERING) {
this._inFlight--;
this._states[i] = PageState.PLACEHOLDER;
}
}
}
_startRender(i, gen) {
const dim = this._dims[i];
const scale = clampedRenderScale(dim.width, dim.height, this._scale, this._dpr);
this._states[i] = PageState.RENDERING;
this._inFlight++;
this._dispatchRender(i + 1, scale, gen);
}
_cleanup(i) {
if (this._states[i] === PageState.RENDERING) this._inFlight--;
this._states[i] = PageState.PLACEHOLDER;
const canvas = this._canvases[i];
if (canvas) {
canvas.remove();
this._canvases[i] = null;
}
}
/**
* Called when the render worker returns a finished bitmap.
*
* Double-buffer swap: the old canvas stays in the DOM until the new one is
* ready, then both mutations happen in the same synchronous JS turn so the
* browser produces exactly one paint frame for the transition.
*/
onRendered(pageNum, gen, bitmap) {
if (gen !== this._renderGen) {
bitmap.close();
return;
}
const i = pageNum - 1;
if (i < 0 || i >= this._dims.length || this._states[i] !== PageState.RENDERING) {
bitmap.close();
return;
}
this._inFlight--;
const dim = this._dims[i];
const wrap = this._wrappers[i];
const cssW = dim.width * this._scale;
const cssH = dim.height * this._scale;
const canvas = document.createElement("canvas");
canvas.width = bitmap.width;
canvas.height = bitmap.height;
canvas.style.width = cssW + "px";
canvas.style.height = cssH + "px";
canvas.style.display = "block";
const bitmapCtx = canvas.getContext("bitmaprenderer");
if (bitmapCtx) {
bitmapCtx.transferFromImageBitmap(bitmap);
} else {
canvas.getContext("2d").drawImage(bitmap, 0, 0);
bitmap.close();
}
wrap.style.width = cssW + "px";
wrap.style.height = cssH + "px";
const old = this._canvases[i];
if (old) old.remove();
wrap.appendChild(canvas);
this._canvases[i] = canvas;
this._states[i] = PageState.RENDERED;
}
/**
* Called after the zoom debounce fires (Phase 2).
* Increments renderGen to cancel all stale in-flight renders, resizes
* placeholders, then re-dispatches renders for the buffer zone.
*/
onScaleChange(newScale, bufferSet, visibleSet) {
this._scale = newScale;
this._renderGen++;
for (let i = 0; i < this._dims.length; i++) {
const pageNum = i + 1;
const dim = this._dims[i];
const wrap = this._wrappers[i];
const cssW = dim.width * newScale;
const cssH = dim.height * newScale;
wrap.style.width = cssW + "px";
wrap.style.height = cssH + "px";
wrap.style.setProperty("zoom", "1");
if (bufferSet.has(pageNum)) {
const canvas = this._canvases[i];
if (canvas) {
canvas.style.width = cssW + "px";
canvas.style.height = cssH + "px";
}
if (this._states[i] === PageState.RENDERING) this._inFlight--;
this._states[i] = PageState.PLACEHOLDER;
} else {
const canvas = this._canvases[i];
if (canvas) {
canvas.remove();
this._canvases[i] = null;
}
this._states[i] = PageState.PLACEHOLDER;
}
}
this.reconcile(bufferSet, visibleSet);
}
/** Returns the 1-based page number of the topmost visible page. */
getCurrentPage(visibleSet) {
if (visibleSet.size > 0) return Math.min(...visibleSet);
const top = this._wrapper.parentElement?.getBoundingClientRect().top ?? 0;
for (const wrap of this._wrappers) {
if (wrap.getBoundingClientRect().bottom > top + 4) {
return parseInt(wrap.dataset["page"], 10);
}
}
return 1;
}
};
// src/viewport-tracker.ts
var ViewportTracker = class {
constructor(root, pageWrappers, onChange) {
this._visibleSet = /* @__PURE__ */ new Set();
this._bufferSet = /* @__PURE__ */ new Set();
this._visibleObserver = null;
this._bufferObserver = null;
this._rafPending = null;
this._onChange = onChange;
this._observe(root, pageWrappers);
}
_observe(root, pageWrappers) {
const scheduleNotify = () => {
if (this._rafPending !== null) return;
this._rafPending = requestAnimationFrame(() => {
this._rafPending = null;
for (const p of this._visibleSet) this._bufferSet.add(p);
this._onChange(new Set(this._bufferSet), new Set(this._visibleSet));
});
};
this._visibleObserver = new IntersectionObserver((entries) => {
for (const e of entries) {
const page = parseInt(e.target.dataset["page"], 10);
if (e.isIntersecting) this._visibleSet.add(page);
else this._visibleSet.delete(page);
}
scheduleNotify();
}, { root, rootMargin: "0px", threshold: 0 });
this._bufferObserver = new IntersectionObserver((entries) => {
for (const e of entries) {
const page = parseInt(e.target.dataset["page"], 10);
if (e.isIntersecting) this._bufferSet.add(page);
else this._bufferSet.delete(page);
}
scheduleNotify();
}, { root, rootMargin: "200% 0px", threshold: 0 });
for (const wrap of pageWrappers) {
this._visibleObserver.observe(wrap);
this._bufferObserver.observe(wrap);
}
}
/** Observe a newly-added page wrapper (unused currently, kept for extensibility). */
observe(wrap) {
this._visibleObserver?.observe(wrap);
this._bufferObserver?.observe(wrap);
}
disconnect() {
this._visibleObserver?.disconnect();
this._bufferObserver?.disconnect();
this._visibleSet.clear();
this._bufferSet.clear();
if (this._rafPending !== null) {
cancelAnimationFrame(this._rafPending);
this._rafPending = null;
}
}
};
// src/zoom-controller.ts
var CONTENT_PADDING_TOP = 20;
var PAGE_GAP = 12;
var _ZoomController = class _ZoomController {
constructor(container2, pageManager2, onReRender, getBuffer, zoomLabel2, initialScale) {
this._debounce = null;
this._rafPending = null;
this._container = container2;
this._pm = pageManager2;
this._onReRender = onReRender;
this._getBuffer = getBuffer;
this._zoomLabel = zoomLabel2;
this._scale = initialScale;
this._renderScale = initialScale;
this._updateLabel();
this._bindScrollZoom();
}
get scale() {
return this._scale;
}
clamp(s) {
return Math.max(_ZoomController.ZOOM_MIN, Math.min(_ZoomController.ZOOM_MAX, s));
}
_updateLabel() {
this._zoomLabel.textContent = Math.round(this._scale * 100) + "%";
}
/**
* Apply a new zoom level.
*
* @param newScale - Target zoom scale.
* @param anchorY - Pixel offset within container to hold fixed (default: center).
* @param anchorX - Pixel offset within container to hold fixed (default: center).
*/
applyScale(newScale, anchorY, anchorX) {
const container2 = this._container;
const oldScale = this._scale;
this._scale = this.clamp(newScale);
this._updateLabel();
this._pm.setZooming(true);
if (anchorY === void 0) anchorY = container2.clientHeight / 2;
if (anchorX === void 0) anchorX = container2.clientWidth / 2;
const anchorContentY = container2.scrollTop + anchorY;
let fixedAbove = CONTENT_PADDING_TOP;
let cumY = CONTENT_PADDING_TOP;
for (const wrap of this._pm.pageWrappers) {
const zoom = parseFloat(wrap.style.getPropertyValue("zoom") || "1") || 1;
const h = parseFloat(wrap.style.height) * zoom;
if (cumY + h > anchorContentY) break;
cumY += h + PAGE_GAP;
fixedAbove += PAGE_GAP;
}
const cssZoom = this._scale / this._renderScale;
for (const wrap of this._pm.pageWrappers) {
wrap.style.setProperty("zoom", String(cssZoom));
}
const ratio = this._scale / oldScale;
const scalable = container2.scrollTop + anchorY - fixedAbove;
container2.scrollTop = Math.max(0, fixedAbove + scalable * ratio - anchorY);
container2.scrollLeft = Math.max(0, (container2.scrollLeft + anchorX) * ratio - anchorX);
this._scheduleReRender();
}
_scheduleReRender() {
if (this._rafPending !== null) cancelAnimationFrame(this._rafPending);
this._rafPending = requestAnimationFrame(() => {
this._rafPending = null;
if (this._debounce !== null) clearTimeout(this._debounce);
this._debounce = setTimeout(() => this._triggerReRender(), 250);
});
}
_triggerReRender() {
this._pm.setZooming(false);
this._renderScale = this._scale;
const { bufferSet, visibleSet } = this._getBuffer();
this._onReRender(this._scale, bufferSet, visibleSet);
}
_bindScrollZoom() {
this._container.addEventListener("wheel", (ev) => {
if (!ev.ctrlKey) return;
ev.preventDefault();
const rect = this._container.getBoundingClientRect();
const anchorY = ev.clientY - rect.top;
const anchorX = ev.clientX - rect.left;
this.applyScale(
this._scale * (ev.deltaY < 0 ? 1.1 : 1 / 1.1),
anchorY,
anchorX
);
}, { passive: false });
}
};
_ZoomController.ZOOM_MIN = 0.1;
_ZoomController.ZOOM_MAX = 5;
var ZoomController = _ZoomController;
// src/viewer.ts
if (typeof requestIdleCallback === "undefined") {
self["requestIdleCallback"] = (cb) => setTimeout(() => cb({ timeRemaining: () => 50, didTimeout: false }), 1);
}
var container = document.getElementById("canvas-container");
var pagesWrapper = document.getElementById("pages-wrapper");
var statusEl = document.getElementById("status");
var zoomLabel = document.getElementById("zoom-label");
var pageIndicator = document.getElementById("page-indicator");
var params = new URLSearchParams(location.search);
var refId = params.get("ref_id") ?? "";
var savedZoom = parseFloat(params.get("zoom") ?? "");
var savedScrollTop = parseFloat(params.get("scroll_top") ?? "");
var DPR = window.devicePixelRatio || 1;
var pageManager = null;
var viewportTracker = null;
var zoomController = null;
var renderWorker = null;
var currentBufferSet = /* @__PURE__ */ new Set();
var currentVisibleSet = /* @__PURE__ */ new Set();
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}`;
}
function scrollToPage(pageNum) {
if (!pageManager) return;
const wrap = pageManager.pageWrappers[pageNum - 1];
if (!wrap) return;
container.scrollTop += wrap.getBoundingClientRect().top - container.getBoundingClientRect().top;
}
function sendViewerState() {
if (!zoomController || window.parent === window) return;
const msg = {
type: "brittle:viewer-state",
refId,
zoom: zoomController.scale,
scrollTop: container.scrollTop
};
window.parent.postMessage(msg, "*");
}
function dispatchRender(pageNum, scale, gen) {
const msg = { type: "render", pageNum, scale, gen };
renderWorker?.postMessage(msg);
}
function onVisibilityChange(bufferSet, visibleSet) {
currentBufferSet = bufferSet;
currentVisibleSet = visibleSet;
pageManager?.reconcile(bufferSet, visibleSet);
refreshPageIndicator();
}
function fitScale(dims) {
const dim = dims[0] ?? { width: 595, height: 842 };
return Math.max(0.1, Math.min(5, Math.min(
(container.clientWidth - 40) / dim.width,
(container.clientHeight - 40) / dim.height
)));
}
async function load() {
if (!refId) {
showError("No ref_id in URL.");
return;
}
setStatus("Loading\u2026");
try {
const [workerBlob, pdfBuf] = await Promise.all([
fetch("brittle://app/viewer/render-worker.bundle.js").then((r) => r.blob()),
fetch(`brittle://app/pdf?ref_id=${encodeURIComponent(refId)}`).then((r) => r.arrayBuffer())
]);
renderWorker = new Worker(URL.createObjectURL(workerBlob));
const { numPages, dims } = await new Promise((resolve, reject) => {
renderWorker.onmessage = (ev) => {
const msg = ev.data;
if (msg.type === "ready") resolve({ numPages: msg.numPages, dims: msg.dims });
if (msg.type === "error") reject(new Error(msg.message));
};
renderWorker.onerror = (e) => reject(new Error(e.message));
const initMsg = { type: "init", pdfData: pdfBuf };
renderWorker.postMessage(initMsg, [pdfBuf]);
});
const fitted = fitScale(dims);
const initialScale = savedZoom > 0 ? Math.max(0.1, Math.min(5, savedZoom)) : fitted;
pageManager = new PageManager(
pagesWrapper,
dims,
initialScale,
DPR,
dispatchRender
);
renderWorker.onmessage = (ev) => {
const msg = ev.data;
if (msg.type === "rendered") {
pageManager?.onRendered(msg.pageNum, msg.gen, msg.bitmap);
refreshPageIndicator();
if (pageManager?.allRendered) setStatus("Ready");
} else if (msg.type === "error") {
console.warn("[viewer] worker error:", msg.message);
}
};
viewportTracker = new ViewportTracker(
container,
[...pageManager.pageWrappers],
onVisibilityChange
);
zoomController = new ZoomController(
container,
pageManager,
(newScale, bufferSet, visibleSet) => {
pageManager.onScaleChange(newScale, bufferSet, visibleSet);
sendViewerState();
},
() => ({ bufferSet: currentBufferSet, visibleSet: currentVisibleSet }),
zoomLabel,
initialScale
);
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",
() => zoomController.applyScale(fitScale(dims))
);
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();
zoomController.applyScale(fitScale(dims));
}
if (window.parent !== window) {
const msg = {
type: "brittle:keydown",
key: ev.key,
ctrlKey: ev.ctrlKey,
shiftKey: ev.shiftKey,
altKey: ev.altKey,
metaKey: ev.metaKey
};
window.parent.postMessage(msg, "*");
}
});
let scrollSaveTimer = null;
container.addEventListener("scroll", () => {
refreshPageIndicator();
if (scrollSaveTimer !== null) clearTimeout(scrollSaveTimer);
scrollSaveTimer = setTimeout(sendViewerState, 500);
}, { passive: true });
if (savedScrollTop > 0) {
requestAnimationFrame(() => {
container.scrollTop = savedScrollTop;
});
}
window.addEventListener("message", (ev) => {
if (ev.data === "pdf.page.next") {
scrollToPage(Math.min(
pageManager.getCurrentPage(currentVisibleSet) + 1,
numPages
));
}
if (ev.data === "pdf.page.prev") {
scrollToPage(Math.max(
pageManager.getCurrentPage(currentVisibleSet) - 1,
1
));
}
});
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
renderWorker?.postMessage({ type: "cleanup" });
} else {
pageManager?.reconcile(currentBufferSet, currentVisibleSet);
}
});
matchMedia(`(resolution: ${DPR}dppx)`).addEventListener("change", () => {
if (pageManager && zoomController) {
pageManager.onScaleChange(
zoomController.scale,
currentBufferSet,
currentVisibleSet
);
}
});
window.addEventListener("beforeunload", () => {
viewportTracker?.disconnect();
renderWorker?.postMessage({ type: "destroy" });
renderWorker = null;
});
pageIndicator.textContent = `1 / ${numPages}`;
setStatus("Rendering\u2026");
} catch (e) {
showError("Could not load PDF: " + (e.message ?? String(e)));
}
}
load();
})();

View File

@@ -1,255 +0,0 @@
/**
* 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();

View File

@@ -1,85 +0,0 @@
/**
* ViewportTracker — dual IntersectionObserver for page visibility detection.
*
* - visibleSet: pages currently on screen (rootMargin "0px")
* - bufferSet: pages within ~2 viewport heights above/below (rootMargin "200% 0px")
*
* Calls onVisibilityChange(bufferSet, visibleSet) whenever either set changes.
*/
export class ViewportTracker {
/**
* @param {HTMLElement} root - scroll container (#canvas-container)
* @param {HTMLElement[]} pageWrappers - array of .page-wrapper elements
* @param {Function} onVisibilityChange - (bufferSet: Set<number>, visibleSet: Set<number>) => void
*/
constructor(root, pageWrappers, onVisibilityChange) {
this._onVisibilityChange = onVisibilityChange;
this._visibleSet = new Set();
this._bufferSet = new Set();
this._visibleObserver = null;
this._bufferObserver = null;
this._observe(root, pageWrappers);
}
_observe(root, pageWrappers) {
// Both observers update their respective sets and then schedule a single
// notification via rAF. This prevents a stale notify when the two
// observers fire in separate microtasks for the same layout change —
// e.g. bufferObserver removes a page from bufferSet before visibleObserver
// has had a chance to also remove it from visibleSet, which would let
// reconcile() incorrectly tear down a still-visible canvas.
this._rafPending = null;
const scheduleNotify = () => {
if (this._rafPending !== null) return;
this._rafPending = requestAnimationFrame(() => {
this._rafPending = null;
// Hard invariant: every visible page must also be in the buffer.
for (const p of this._visibleSet) this._bufferSet.add(p);
this._onVisibilityChange(new Set(this._bufferSet), new Set(this._visibleSet));
});
};
this._visibleObserver = new IntersectionObserver(
entries => {
for (const e of entries) {
const page = parseInt(e.target.dataset.page, 10);
if (e.isIntersecting) this._visibleSet.add(page);
else this._visibleSet.delete(page);
}
scheduleNotify();
},
{ root, rootMargin: "0px", threshold: 0 }
);
this._bufferObserver = new IntersectionObserver(
entries => {
for (const e of entries) {
const page = parseInt(e.target.dataset.page, 10);
if (e.isIntersecting) this._bufferSet.add(page);
else this._bufferSet.delete(page);
}
scheduleNotify();
},
{ root, rootMargin: "200% 0px", threshold: 0 }
);
for (const wrap of pageWrappers) {
this._visibleObserver.observe(wrap);
this._bufferObserver.observe(wrap);
}
}
/** Observe a newly-added page wrapper. */
observe(wrap) {
this._visibleObserver?.observe(wrap);
this._bufferObserver?.observe(wrap);
}
disconnect() {
this._visibleObserver?.disconnect();
this._bufferObserver?.disconnect();
this._visibleSet.clear();
this._bufferSet.clear();
}
}

View File

@@ -1,148 +0,0 @@
// Must match the CSS constants in index.html:
// #pages-wrapper { padding: 20px 0; gap: 12px; }
const CONTENT_PADDING_TOP = 20;
const PAGE_GAP = 12;
/**
* ZoomController — two-phase zoom pipeline.
*
* Phase 1 (instant, every event):
* CSS `zoom` on each .page-wrapper = newScale / renderScale
* Scroll position adjusted to keep the anchor point fixed
* Zoom label updated
*
* Phase 2 (debounced, 250ms after last event):
* Calls pageManager.onScaleChange(newScale, bufferSet, visibleSet)
* Pages re-rendered at the new native resolution
*
* Ctrl+Scroll coalescing: multiple wheel events within a frame are coalesced
* via requestAnimationFrame. The 250ms debounce starts inside the rAF callback,
* so it begins after the last event in a burst.
*/
export class ZoomController {
/**
* @param {HTMLElement} container - #canvas-container (scrollable)
* @param {object} pageManager - PageManager instance
* @param {Function} onReRender - (newScale, bufferSet, visibleSet) => void
* @param {Function} getBuffer - () => { bufferSet: Set, visibleSet: Set }
* @param {HTMLElement} zoomLabel
* @param {number} initialScale
*/
constructor(container, pageManager, onReRender, getBuffer, zoomLabel, initialScale) {
this._container = container;
this._pm = pageManager;
this._onReRender = onReRender;
this._getBuffer = getBuffer;
this._zoomLabel = zoomLabel;
this._scale = initialScale;
this._renderScale = initialScale;
this._debounce = null;
this._rafPending = null;
this.ZOOM_MIN = 0.1;
this.ZOOM_MAX = 5.0;
this._updateLabel();
this._bindScrollZoom();
}
get scale() { return this._scale; }
clamp(s) {
return Math.max(this.ZOOM_MIN, Math.min(this.ZOOM_MAX, s));
}
_updateLabel() {
this._zoomLabel.textContent = Math.round(this._scale * 100) + "%";
}
/**
* Apply a new zoom level.
*
* @param {number} newScale
* @param {number} [anchorY] - pixel offset within container to hold fixed (default: center)
* @param {number} [anchorX]
*/
applyScale(newScale, anchorY, anchorX) {
const container = this._container;
const oldScale = this._scale;
this._scale = this.clamp(newScale);
this._updateLabel();
this._pm.setZooming(true);
if (anchorY === undefined) anchorY = container.clientHeight / 2;
if (anchorX === undefined) anchorX = container.clientWidth / 2;
// Compute fixedAbove — the non-scaling portion of content above the anchor:
// top padding plus one gap per page that is fully above the anchor.
// Gaps and padding are fixed-size and do not scale with zoom, so a naive
// `(scrollTop + anchorY) * ratio` formula accumulates one error of
// `GAP * (ratio - 1)` per gap above the anchor — enough to shift the
// anchor by tens of pixels near the bottom of a long document.
const anchorContentY = container.scrollTop + anchorY;
let fixedAbove = CONTENT_PADDING_TOP;
let cumY = CONTENT_PADDING_TOP;
for (const wrap of this._pm.pageWrappers) {
// Use the element's current layout height (CSS height × CSS zoom).
const h = parseFloat(wrap.style.height) * (parseFloat(wrap.style.zoom) || 1);
if (cumY + h > anchorContentY) break;
cumY += h + PAGE_GAP;
fixedAbove += PAGE_GAP;
}
// Phase 1: instant CSS zoom feedback
const cssZoom = this._scale / this._renderScale;
for (const wrap of this._pm.pageWrappers) {
wrap.style.zoom = cssZoom;
}
// Exact scroll anchor: scale only the page-content portion; keep the
// fixed portion (gaps + padding) unchanged.
// T_new = fixedAbove + (T_old + anchorY fixedAbove) × ratio anchorY
const ratio = this._scale / oldScale;
const scalable = container.scrollTop + anchorY - fixedAbove;
container.scrollTop = Math.max(0, fixedAbove + scalable * ratio - anchorY);
container.scrollLeft = Math.max(0, (container.scrollLeft + anchorX) * ratio - anchorX);
// Phase 2: debounced native re-render
this._scheduleReRender();
}
_scheduleReRender() {
if (this._rafPending !== null) cancelAnimationFrame(this._rafPending);
this._rafPending = requestAnimationFrame(() => {
this._rafPending = null;
clearTimeout(this._debounce);
this._debounce = setTimeout(() => this._triggerReRender(), 250);
});
}
_triggerReRender() {
this._pm.setZooming(false); // Phase 2: onScaleChange will handle cleanup
const newScale = this._scale;
this._renderScale = newScale;
const { bufferSet, visibleSet } = this._getBuffer();
this._onReRender(newScale, bufferSet, visibleSet);
}
/**
* Set the initial scale without triggering a re-render or CSS zoom.
* Used when the initial fit-to-width is computed before any pages are rendered.
*/
setInitialScale(s) {
this._scale = this.clamp(s);
this._renderScale = this._scale;
this._updateLabel();
}
_bindScrollZoom() {
this._container.addEventListener("wheel", ev => {
if (!ev.ctrlKey) return;
ev.preventDefault();
const rect = this._container.getBoundingClientRect();
const anchorY = ev.clientY - rect.top;
const anchorX = ev.clientX - rect.left;
this.applyScale(this._scale * (ev.deltaY < 0 ? 1.1 : 1 / 1.1), anchorY, anchorX);
}, { passive: false });
}
}