// 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();
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() {
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 });
}
}