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