147 lines
5.2 KiB
JavaScript
147 lines
5.2 KiB
JavaScript
// 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 });
|
||
}
|
||
}
|