Smooth scroll zoom with rAF accumulation and debounced PDF re-rendering

Previously, each wheel event immediately called #updateFrameScales which
re-renders PDF canvases via onZoom on every tick. This caused visible
jank because canvas re-rendering is expensive and blocks the main thread.

Two improvements:

1. rAF accumulation: wheel events now accumulate zoom deltas and apply
   them once per animation frame via requestAnimationFrame, coalescing
   rapid scroll events into a single transform update. Reduced zoomStep
   from 0.1 to 0.05 for finer granularity.

2. PDF zoom debouncing: during active scroll-zoom, PDF iframes are
   visually scaled via CSS transform without calling onZoom (no canvas
   re-render). After zoom settles (150ms timeout), onZoom is called to
   re-render the canvas at the final resolution for crisp text. The
   #pdfLastRenderedScale field tracks what resolution the canvas was
   last rendered at so CSS scaling is relative to the rendered state.

   Comics continue to call #updateFrameScales every frame since their
   zoom is just a cheap CSS transform with no canvas work.

Timeouts are cleared on page turn, resize, and destroy to prevent
stale re-renders.
This commit is contained in:
2026-04-18 23:52:50 -04:00
parent fa790c2abb
commit 48feeeacad
+58 -6
View File
@@ -44,8 +44,11 @@ export class FixedLayout extends HTMLElement {
#zoomState = { #zoomState = {
minScale: 0.1, minScale: 0.1,
maxScale: 10, maxScale: 10,
zoomStep: 0.1, zoomStep: 0.05,
}; };
#zoomAccum = { ratio: 1, cx: 0, cy: 0, raf: null };
#pdfLastRenderedScale = 1;
#pdfSettleTimeout = null;
#dragState = { #dragState = {
isDragging: false, isDragging: false,
startX: 0, startX: 0,
@@ -164,6 +167,7 @@ export class FixedLayout extends HTMLElement {
} }
#onResize() { #onResize() {
clearTimeout(this.#pdfSettleTimeout);
if (typeof this.#zoom === "number" && !isNaN(this.#zoom)) { if (typeof this.#zoom === "number" && !isNaN(this.#zoom)) {
// Numeric zoom: rescale frames but preserve user's pan position // Numeric zoom: rescale frames but preserve user's pan position
this.#updateFrameScales(this.#transform.scale); this.#updateFrameScales(this.#transform.scale);
@@ -326,6 +330,34 @@ export class FixedLayout extends HTMLElement {
this.#wrapper.style.transform = `translate(${this.#transform.x}px, ${this.#transform.y}px)`; this.#wrapper.style.transform = `translate(${this.#transform.x}px, ${this.#transform.y}px)`;
} }
#applyPDFZoomScale(scale) {
const cssScale = scale / this.#pdfLastRenderedScale;
const candidates = this.#center
? [this.#center]
: [this.#left, this.#right];
for (const frame of candidates) {
if (!frame?.onZoom || !frame.iframe) continue;
Object.assign(frame.iframe.style, {
width: `${frame.width * this.#pdfLastRenderedScale}px`,
height: `${frame.height * this.#pdfLastRenderedScale}px`,
transform: `scale(${cssScale})`,
transformOrigin: "top left",
});
Object.assign(frame.element.style, {
width: `${frame.width * scale}px`,
height: `${frame.height * scale}px`,
});
}
}
#schedulePDFRerender(scale) {
clearTimeout(this.#pdfSettleTimeout);
this.#pdfSettleTimeout = setTimeout(() => {
this.#updateFrameScales(scale);
this.#pdfSettleTimeout = null;
}, 150);
}
#updateFrameScales(scale) { #updateFrameScales(scale) {
const left = this.#left ?? {}; const left = this.#left ?? {};
const right = this.#center ?? this.#right ?? {}; const right = this.#center ?? this.#right ?? {};
@@ -368,6 +400,7 @@ export class FixedLayout extends HTMLElement {
transform(left); transform(left);
transform(right); transform(right);
} }
if (this.isPDF) this.#pdfLastRenderedScale = scale;
} }
#getContentSize() { #getContentSize() {
@@ -416,7 +449,12 @@ export class FixedLayout extends HTMLElement {
this.#transform.scale = newScale; this.#transform.scale = newScale;
this.#zoom = newScale; this.#zoom = newScale;
this.#updateFrameScales(newScale); if (this.isPDF) {
this.#applyPDFZoomScale(newScale);
this.#schedulePDFRerender(newScale);
} else {
this.#updateFrameScales(newScale);
}
this.#applyTransform(); this.#applyTransform();
this.dispatchEvent( this.dispatchEvent(
@@ -430,14 +468,26 @@ export class FixedLayout extends HTMLElement {
event.preventDefault(); event.preventDefault();
const rect = this.getBoundingClientRect(); const rect = this.getBoundingClientRect();
const cx = event.clientX - rect.left; this.#zoomAccum.cx = event.clientX - rect.left;
const cy = event.clientY - rect.top; this.#zoomAccum.cy = event.clientY - rect.top;
const ratio = const tick =
event.deltaY > 0 event.deltaY > 0
? 1 - this.#zoomState.zoomStep ? 1 - this.#zoomState.zoomStep
: 1 + this.#zoomState.zoomStep; : 1 + this.#zoomState.zoomStep;
this.#zoomByRatio(cx, cy, ratio); this.#zoomAccum.ratio = tick;
if (!this.#zoomAccum.raf) {
this.#zoomAccum.raf = requestAnimationFrame(() => {
this.#zoomByRatio(
this.#zoomAccum.cx,
this.#zoomAccum.cy,
this.#zoomAccum.ratio,
);
this.#zoomAccum.ratio = 1;
this.#zoomAccum.raf = null;
});
}
} }
#handleMouseDown(event) { #handleMouseDown(event) {
@@ -706,6 +756,7 @@ export class FixedLayout extends HTMLElement {
} }
async #showSpread({ left, right, center, side }) { async #showSpread({ left, right, center, side }) {
clearTimeout(this.#pdfSettleTimeout);
this.#wrapper.replaceChildren(); this.#wrapper.replaceChildren();
this.#left = null; this.#left = null;
this.#right = null; this.#right = null;
@@ -921,6 +972,7 @@ export class FixedLayout extends HTMLElement {
destroy() { destroy() {
this.#observer.unobserve(this); this.#observer.unobserve(this);
clearTimeout(this.#pdfSettleTimeout);
} }
} }