Search / static /reader /ui /reader-scroll-anchor.js
vomebook
refactor: organize project layout and Reader responsibilities
2c1d035
Raw History Blame Contribute Delete
5.56 kB
(function (root) {
"use strict";
function createScrollAnchorManager({
viewport,
candidates,
ResizeObserverImpl = root.ResizeObserver,
requestFrame = root.requestAnimationFrame.bind(root),
cancelFrame = root.cancelAnimationFrame.bind(root),
setTimer = root.setTimeout.bind(root),
clearTimer = root.clearTimeout.bind(root),
scrollIdleDelay = 140
}) {
let anchor = null;
let captureFrame = 0;
let restoreFrame = 0;
let disposed = false;
let generation = 0;
let scrolling = false;
let scrollTimer = 0;
const idleTasks = new Set();
function measure(node) {
if (!node?.isConnected) return null;
const viewportRect = viewport.getBoundingClientRect();
const rect = node.getBoundingClientRect();
return { node, offset: rect.top - viewportRect.top, generation };
}
function capture() {
if (disposed) return null;
const viewportRect = viewport.getBoundingClientRect();
const marker = viewportRect.top + 8;
const measured = candidates()
.filter((node) => node?.isConnected)
.map((node) => ({ node, rect: node.getBoundingClientRect() }))
.filter(
({ rect }) =>
rect.height > 0 && rect.bottom > viewportRect.top && rect.top < viewportRect.bottom
);
const selected = measured
.filter(({ rect }) => rect.bottom > marker)
.sort(
(left, right) =>
Math.abs(left.rect.top - marker) - Math.abs(right.rect.top - marker) ||
left.rect.height - right.rect.height
)[0];
anchor = measure(selected?.node) || anchor;
return anchor;
}
function restore(snapshot = anchor) {
if (disposed || snapshot?.generation !== generation || !snapshot.node?.isConnected)
return false;
if (scrolling) {
capture();
return false;
}
const current = measure(snapshot.node);
if (!current) return false;
const delta = current.offset - snapshot.offset;
if (Math.abs(delta) > 0.5) viewport.scrollTop += delta;
anchor = measure(snapshot.node);
return true;
}
function scheduleRestore(snapshot = anchor) {
if (disposed || !snapshot || restoreFrame) return;
restoreFrame = requestFrame(() => {
restoreFrame = 0;
restore(anchor);
});
}
function remember() {
if (disposed) return;
if (restoreFrame) {
cancelFrame(restoreFrame);
restoreFrame = 0;
}
if (captureFrame) cancelFrame(captureFrame);
captureFrame = 0;
capture();
}
function preserve(change) {
const snapshot = capture();
const result = change();
if (scrolling && snapshot?.node?.isConnected) {
// This synchronous layout change must not move the line under a moving finger.
const current = measure(snapshot.node);
if (current && snapshot.generation === generation)
viewport.scrollTop += current.offset - snapshot.offset;
capture();
return result;
}
restore(snapshot);
scheduleRestore(snapshot);
return result;
}
function finishScroll() {
scrollTimer = 0;
scrolling = false;
const tasks = [...idleTasks];
idleTasks.clear();
for (const task of tasks) task();
capture();
}
function scheduleScrollEnd() {
if (disposed) return;
if (scrollTimer) clearTimer(scrollTimer);
scrollTimer = setTimer(finishScroll, scrollIdleDelay);
}
function handleScrollIntent() {
scrolling = true;
scheduleScrollEnd();
}
function handleScroll() {
if (scrolling) scheduleScrollEnd();
}
function whenIdle(task) {
if (disposed || typeof task !== "function") return;
if (!scrolling) task();
else idleTasks.add(task);
}
viewport.addEventListener?.("scroll", handleScroll, { passive: true });
for (const name of ["wheel", "touchstart", "pointerdown"])
viewport.addEventListener?.(name, handleScrollIntent, { passive: true });
const resizeObserver = ResizeObserverImpl
? new ResizeObserverImpl(() => (scrolling ? capture() : restore(anchor)))
: null;
function observe(node) {
resizeObserver?.observe(node);
if (!anchor) capture();
}
function unobserve(node) {
resizeObserver?.unobserve(node);
if (anchor?.node === node || node?.contains?.(anchor?.node)) anchor = null;
}
function invalidate() {
generation += 1;
if (captureFrame) cancelFrame(captureFrame);
if (restoreFrame) cancelFrame(restoreFrame);
captureFrame = 0;
restoreFrame = 0;
anchor = null;
}
function dispose() {
if (disposed) return;
disposed = true;
viewport.removeEventListener?.("scroll", handleScroll);
for (const name of ["wheel", "touchstart", "pointerdown"])
viewport.removeEventListener?.(name, handleScrollIntent);
if (scrollTimer) clearTimer(scrollTimer);
scrollTimer = 0;
scrolling = false;
idleTasks.clear();
resizeObserver?.disconnect();
invalidate();
}
return Object.freeze({
capture,
restore,
remember,
preserve,
observe,
unobserve,
invalidate,
dispose,
whenIdle,
get scrolling() {
return scrolling;
}
});
}
root.VoiceOfMLReaderScroll = Object.freeze({ createScrollAnchorManager });
})(typeof self !== "undefined" ? self : globalThis);