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
+15
View File
@@ -18,6 +18,21 @@ export class PageHeights {
for (let i = from; i < this.n; i++) this.cum[i + 1] = this.cum[i] + (this.actual[i] ?? this.est);
}
get estHeight(): number {
return this.est;
}
/** 重估默认页高(同开本漫画量出真实比例后,滚动条几何就不再逐页变了)。
* 返回起始偏移 < y 的未量高页数——调用方可按 n×Δest 自行补偿 scrollTop。 */
setEst(e: number, y: number): number {
if (!Number.isFinite(e) || e <= 0 || e === this.est) return 0;
let n = 0;
for (let i = 0; i < this.n && this.cum[i] < y; i++) if (this.actual[i] === undefined) n++;
this.est = e;
this.rebuild(0);
return n;
}
/** 返回高度增量(相对旧值)。同值幂等返 0;越界/非法高度忽略。 */
set(i: number, h: number): number {
if (i < 0 || i >= this.n || !Number.isFinite(h) || h <= 0) return 0;
+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">