Files
brittle/src-tauri/assets/viewer/zoom-controller.js

147 lines
5.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 });
}
}