Add PDF state persistence

This commit is contained in:
2026-03-30 09:29:19 +02:00
parent d1bb79570d
commit 4613b8e5dd
15 changed files with 380 additions and 55 deletions

View File

@@ -18,6 +18,17 @@ export class MessageBridge {
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;

View File

@@ -32,15 +32,17 @@ export class PageManager {
* @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._wrapper = wrapper;
this._viewports = viewports;
this._scale = initialScale;
this._dpr = dpr;
this._dispatchRender = dispatchRender;
this._renderGen = 0;
this._states = new Array(viewports.length).fill(State.PLACEHOLDER);
this._canvases = new Array(viewports.length).fill(null);
this._wrappers = [];
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();
}
@@ -49,6 +51,9 @@ export class PageManager {
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];
@@ -87,34 +92,45 @@ export class PageManager {
}
}
// Clean up pages no longer in the buffer
// 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 (!bufferSet.has(pageNum) && this._states[i] === State.RENDERED) {
if (!this._zooming && !bufferSet.has(pageNum) && this._states[i] === State.RENDERED) {
this._cleanup(i);
}
// Also cancel stale RENDERING pages outside the buffer
// 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;
}
// Reset wrapper size (without canvas it still holds placeholder dimensions)
}
/**
@@ -134,6 +150,7 @@ export class PageManager {
bitmap.close();
return;
}
this._inFlight--;
const vp = this._viewports[i];
const wrap = this._wrappers[i];
@@ -160,8 +177,11 @@ export class PageManager {
bitmap.close();
}
// Remove CSS zoom and set explicit size (canvas is now at the right dimensions)
wrap.style.zoom = "1";
// 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);
@@ -202,9 +222,10 @@ export class PageManager {
canvas.style.width = cssW + "px";
canvas.style.height = cssH + "px";
}
if (this._states[i] !== State.PLACEHOLDER) {
this._states[i] = State.PLACEHOLDER;
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];

View File

@@ -33,8 +33,11 @@ const zoomLabel = document.getElementById("zoom-label");
const pageIndicator = document.getElementById("page-indicator");
// ── Global state ─────────────────────────────────────────────────────────────
const refId = new URLSearchParams(location.search).get("ref_id") || "";
const DPR = window.devicePixelRatio || 1;
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;
@@ -61,12 +64,13 @@ function refreshPageIndicator() {
pageIndicator.textContent = cur + " / " + pageManager.numPages;
}
async function fitToWidth() {
async function fitToPage() {
if (!pdfDoc) return 1.0;
const page = await pdfDoc.getPage(1);
const vp = page.getViewport({ scale: 1.0 });
const avail = container.clientWidth - 40;
return Math.max(0.1, Math.min(5.0, avail / vp.width));
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) {
@@ -103,7 +107,7 @@ async function renderPage(pageNum, scale, gen) {
const bitmap = offscreen.transferToImageBitmap();
pageManager?.onRendered(pageNum, gen, bitmap);
refreshPageIndicator();
setStatus("Ready");
if (pageManager?.allRendered) setStatus("Ready");
} catch (e) {
if (e?.name !== "RenderingCancelledException") {
console.warn("[viewer] render error page", pageNum, e);
@@ -113,6 +117,11 @@ async function renderPage(pageNum, scale, gen) {
}
}
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;
@@ -145,9 +154,12 @@ async function load() {
viewports.push({ width: vp.width, height: vp.height });
}
// 3. Compute initial fit-to-width scale
const avail = container.clientWidth - 40;
const initialScale = Math.max(0.1, Math.min(5.0, avail / viewports[0].width));
// 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(
@@ -160,11 +172,14 @@ async function load() {
container, pageManager.pageWrappers, onVisibilityChange,
);
// 6. ZoomController
// 6. ZoomController — send state after each debounced re-render
zoomController = new ZoomController(
container,
pageManager,
(newScale, bufferSet, visibleSet) => pageManager.onScaleChange(newScale, bufferSet, visibleSet),
(newScale, bufferSet, visibleSet) => {
pageManager.onScaleChange(newScale, bufferSet, visibleSet);
sendViewerState();
},
() => ({ bufferSet: currentBufferSet, visibleSet: currentVisibleSet }),
zoomLabel,
initialScale,
@@ -182,19 +197,29 @@ async function load() {
document.getElementById("btn-zoom-in").addEventListener("click",
() => zoomController.applyScale(zoomController.scale * 1.25));
document.getElementById("btn-zoom-fit").addEventListener("click",
async () => zoomController.applyScale(await fitToWidth()));
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(); fitToWidth().then(s => zoomController.applyScale(s)); }
if (ev.key === "0") { ev.preventDefault(); fitToPage().then(s => zoomController.applyScale(s)); }
bridge.forwardKeydown(ev);
});
// Scroll → update page indicator
container.addEventListener("scroll", refreshPageIndicator, { passive: true });
// 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", () => {

View File

@@ -23,8 +23,21 @@ export class ViewportTracker {
}
_observe(root, pageWrappers) {
const notify = () => {
this._onVisibilityChange(new Set(this._bufferSet), new Set(this._visibleSet));
// 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(
@@ -34,7 +47,7 @@ export class ViewportTracker {
if (e.isIntersecting) this._visibleSet.add(page);
else this._visibleSet.delete(page);
}
notify();
scheduleNotify();
},
{ root, rootMargin: "0px", threshold: 0 }
);
@@ -46,7 +59,7 @@ export class ViewportTracker {
if (e.isIntersecting) this._bufferSet.add(page);
else this._bufferSet.delete(page);
}
notify();
scheduleNotify();
},
{ root, rootMargin: "200% 0px", threshold: 0 }
);

View File

@@ -68,6 +68,7 @@ export class ZoomController {
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;
@@ -117,6 +118,7 @@ export class ZoomController {
}
_triggerReRender() {
this._pm.setZooming(false); // Phase 2: onScaleChange will handle cleanup
const newScale = this._scale;
this._renderScale = newScale;
const { bufferSet, visibleSet } = this._getBuffer();