568 lines
20 KiB
JavaScript
568 lines
20 KiB
JavaScript
"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();
|
|
})();
|