fix(cbz): smooth scrollbar/slider dragging — rAF-coalesce scroll handler, idle-debounced prefetch, frame-batched height corrections, PageHeights.setEst converges unseen pages to the first measured page size, page slider seeks instantly while scrubbing (no per-notch smooth-animation restart); restore lost ### Fixed header in changelog

This commit is contained in:
2026-09-07 23:13:04 +08:00
parent 065fde5b56
commit c6dfb92c76
4 changed files with 95 additions and 18 deletions
+68 -18
View File
@@ -89,13 +89,20 @@ export default function CbzReader({ book, initialLocator, chrome }: ReaderProps)
const [width, setWidth] = useState(480);
const [vh, setVh] = useState(600);
const [top, setTop] = useState(0);
const [scrub, setScrub] = useState<number | null>(null); // 页滑条拖拽中的临时值
const [, bump] = useState(0);
const restored = useRef(false);
const scrollRaf = useRef(0);
const idle = useRef<undefined | ReturnType<typeof setTimeout>>(undefined);
const curRef = useRef(0);
const shift = useRef(0); // 量高补偿按帧合并,避免一次加载多页各改一次 scrollTop
const shiftRaf = useRef(0);
const ratio = useRef(EST_RATIO); // 学到的页高比(同开本漫画一页量准,全程几何稳定)
const [mode, setMode] = useStoredMode();
const fitH = mode === "fit";
const ph = useMemo(
() => new PageHeights(count, fitH ? vh : width * EST_RATIO),
() => new PageHeights(count, fitH ? vh : width * ratio.current),
[count, width, vh, fitH],
);
@@ -119,25 +126,53 @@ export default function CbzReader({ book, initialLocator, chrome }: ReaderProps)
if (Number.isInteger(p) && p > 0 && p < count) boxRef.current.scrollTop = ph.offset(p);
}, [count, initialLocator, ph]);
// 滚动事件按帧合并;预取等滚动停稳再做——拖拽途中逐事件拉图=带宽/解码风暴
function onScroll() {
const el = boxRef.current;
if (!el) return;
const t = el.scrollTop;
setTop(t);
if (count) {
const cur = ph.pageAt(t + vh / 2);
saver.report({ page: cur }, (cur + 1) / count);
for (let j = cur + 1; j <= Math.min(count - 1, cur + 5); j++)
fetchObjectUrl(formatPageUrl(book.page_url_fmt ?? "", j)).catch(() => {}); // 预取下方 5 页
}
if (scrollRaf.current) return;
scrollRaf.current = requestAnimationFrame(() => {
scrollRaf.current = 0;
const el = boxRef.current;
if (!el) return;
const t = el.scrollTop;
setTop(t);
if (count) {
curRef.current = ph.pageAt(t + vh / 2);
saver.report({ page: curRef.current }, (curRef.current + 1) / count);
}
clearTimeout(idle.current);
idle.current = setTimeout(() => {
for (let j = curRef.current + 1; j <= Math.min(count - 1, curRef.current + 5); j++)
fetchObjectUrl(formatPageUrl(book.page_url_fmt ?? "", j)).catch(() => {});
}, 250);
});
}
useEffect(
() => () => {
cancelAnimationFrame(scrollRaf.current);
cancelAnimationFrame(shiftRaf.current);
clearTimeout(idle.current);
},
[],
);
function applyShift(d: number) {
shift.current += d;
if (shiftRaf.current) return;
shiftRaf.current = requestAnimationFrame(() => {
shiftRaf.current = 0;
const el = boxRef.current;
if (el && shift.current) el.scrollTop += shift.current;
shift.current = 0;
});
}
const cur = ph.pageAt(top + vh / 2);
function jump(i: number) {
function jump(i: number, smooth = true) {
const el = boxRef.current;
if (!el) return;
el.scrollTo({ top: ph.offset(Math.max(0, Math.min(count - 1, i))), behavior: "smooth" });
el.scrollTo({ top: ph.offset(Math.max(0, Math.min(count - 1, i))), behavior: smooth ? "smooth" : "auto" });
chrome.show();
}
@@ -181,7 +216,7 @@ export default function CbzReader({ book, initialLocator, chrome }: ReaderProps)
ref={boxRef}
onScroll={onScroll}
onClick={onZoneClick}
className={"h-full overflow-y-auto" + (mode !== "scroll" ? " snap-y snap-mandatory" : "")}
className={"h-full overflow-y-auto" + (mode !== "scroll" ? " snap-y snap-proximity" : "")}
>
<div className="relative mx-auto" style={{ height: ph.total(), maxWidth: fitH ? "none" : MAX_W }}>
{Array.from({ length: b - a }, (_, k) => {
@@ -199,9 +234,17 @@ export default function CbzReader({ book, initialLocator, chrome }: ReaderProps)
onLoaded={(h) => {
if (fitH) return; // 适高模式定高一页一屏,无需量高
const el = boxRef.current;
const topY = el ? el.scrollTop : 0;
const oldEst = ph.estHeight;
const topOfI = ph.offset(i);
const d = ph.set(i, h);
if (el && d !== 0 && topOfI < el.scrollTop) el.scrollTop += d; // 视口上方页高变化 → 补偿滚动
if (el && d !== 0 && topOfI < topY) applyShift(d); // 视口上方页高变化 → 补偿滚动
// 让未量高页的估高收敛到实测页高:同开本漫画量一页后 d≈0,拖拽滚动条不再逐帧打架
if (width > 0 && Math.abs(h - oldEst) / oldEst > 0.03) {
const n = ph.setEst(h, topY);
if (el && n) applyShift(n * (h - oldEst));
ratio.current = h / width;
}
bump((x) => x + 1);
}}
/>
@@ -217,13 +260,20 @@ export default function CbzReader({ book, initialLocator, chrome }: ReaderProps)
type="range"
min={0}
max={Math.max(0, count - 1)}
value={cur}
onChange={(e) => jump(Number(e.target.value))}
value={scrub ?? cur}
onChange={(e) => {
const v = Number(e.target.value);
setScrub(v);
jump(v, false); // 拖拽中瞬移:平滑动画会被下一格打断,手感像卡顿
}}
onPointerUp={() => setScrub(null)}
onPointerCancel={() => setScrub(null)}
onBlur={() => setScrub(null)}
className="rd-range"
aria-label="页码跳转"
/>
<span className="w-20 shrink-0 text-right text-xs tabular-nums opacity-70">
{cur + 1}/{count} · {Math.round(((cur + 1) / Math.max(1, count)) * 100)}%
{(scrub ?? cur) + 1}/{count} · {Math.round((((scrub ?? cur) + 1) / Math.max(1, count)) * 100)}%
</span>
</div>
<div className="mt-1 flex items-center gap-2 pb-1 text-sm">