From c6dfb92c7693cd4701a234ef125751417a99ac19 Mon Sep 17 00:00:00 2001 From: Fendy Date: Mon, 7 Sep 2026 23:13:04 +0800 Subject: [PATCH] =?UTF-8?q?fix(cbz):=20smooth=20scrollbar/slider=20draggin?= =?UTF-8?q?g=20=E2=80=94=20rAF-coalesce=20scroll=20handler,=20idle-debounc?= =?UTF-8?q?ed=20prefetch,=20frame-batched=20height=20corrections,=20PageHe?= =?UTF-8?q?ights.setEst=20converges=20unseen=20pages=20to=20the=20first=20?= =?UTF-8?q?measured=20page=20size,=20page=20slider=20seeks=20instantly=20w?= =?UTF-8?q?hile=20scrubbing=20(no=20per-notch=20smooth-animation=20restart?= =?UTF-8?q?);=20restore=20lost=20###=20Fixed=20header=20in=20changelog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/CHANGELOG_web.md | 4 ++ frontend/src/lib/virt.ts | 15 ++++++ frontend/src/readers/CbzReader.tsx | 86 +++++++++++++++++++++++------- frontend/test/virt.test.ts | 8 +++ 4 files changed, 95 insertions(+), 18 deletions(-) diff --git a/docs/CHANGELOG_web.md b/docs/CHANGELOG_web.md index 28a7dde..a676fca 100644 --- a/docs/CHANGELOG_web.md +++ b/docs/CHANGELOG_web.md @@ -24,6 +24,10 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab - Whole UI restyled to a warm library palette (stone surfaces + amber accents, replacing cool zinc + emerald): warmer dark chrome, amber primary buttons/links, hover/press feedback on shelf cards (cover lift + glow), reader header with format badge and blur, pill-style CBZ page indicator, EPUB spread centered like a book page. - 全站换为暖色「书房」配色(stone 深灰面 + amber 琥珀强调,替代冷色 zinc + emerald):书架封面悬停上浮高亮、阅读器顶栏带格式徽章与毛玻璃、CBZ 页码改为悬浮胶囊、EPUB 页面居中成书卷感,主按钮/链接统一琥珀色并补齐悬停/焦点反馈。 +### Fixed / 修复 + +- Dragging the comic scrollbar (or the page slider) is no longer gritty: scroll handling is coalesced to one update per frame instead of per event, prefetching waits until scrolling settles instead of firing mid-drag, height corrections are batched per frame, the virtual list re-estimates unseen pages from the first measured page size (scrollbar geometry stops morphing under your finger), and slider seeks land instantly instead of restarting a smooth-scroll animation per notch. +- 漫画拖动滚动条(及页码滑条)不再发涩:滚动改为每帧合并处理而非逐事件响应,预取等滚动停稳再发(不再拖拽途中抢带宽),量高补偿按帧合并,虚拟列表用首个实测页高重估所有未加载页(滚动条几何不再拖拽中变形),滑条寻址即时落位(不再每格重启一段平滑动画)。 - .txt/.md files encoded in GBK/GB2312/GB18030 or UTF-16 (typical web-novel exports) no longer render as mojibake: the reader strictly tries UTF-8 first and falls back to GB18030, recognizing UTF-16 by BOM. - GBK/GB2312/GB18030 或 UTF-16 编码的 .txt/.md(网文导出常见)不再乱码:阅读器先按 UTF-8 严格解码,失败自动回退 GB18030,UTF-16 靠 BOM 识别。 - Reading a large .txt no longer freezes the browser: the text is split into chapters (「第X章」/Chapter X/序章… markers, pseudo-sections for unmarked files) and rendered one chapter at a time with a chapter selector and prev/next navigation; 「第X卷/部」 are volume groupings (dropdown optgroups), not chapter breaks, and books with only volumes split by volume; reading progress restores to the saved chapter. diff --git a/frontend/src/lib/virt.ts b/frontend/src/lib/virt.ts index 6b85f13..03a75e5 100644 --- a/frontend/src/lib/virt.ts +++ b/frontend/src/lib/virt.ts @@ -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; diff --git a/frontend/src/readers/CbzReader.tsx b/frontend/src/readers/CbzReader.tsx index 15e6973..2a8b558 100644 --- a/frontend/src/readers/CbzReader.tsx +++ b/frontend/src/readers/CbzReader.tsx @@ -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(null); // 页滑条拖拽中的临时值 const [, bump] = useState(0); const restored = useRef(false); + const scrollRaf = useRef(0); + const idle = useRef>(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" : "")} >
{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="页码跳转" /> - {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)}%
diff --git a/frontend/test/virt.test.ts b/frontend/test/virt.test.ts index aafceeb..59214bf 100644 --- a/frontend/test/virt.test.ts +++ b/frontend/test/virt.test.ts @@ -23,6 +23,14 @@ it("set 修正高度并平移后续 offset;返回增量;幂等", () => { expect(ph.set(2, 150)).toBe(0); }); +it("setEst:重估默认页高,返回视口上方「未量高」页数,已量高页保留", () => { + const ph = new PageHeights(5, 100); // cum 顶部: 0,100,200,300,400 + ph.set(0, 160); // 页0 实测 160 → cum 顶部: 0,160,260,360,460 + expect(ph.setEst(140, 260)).toBe(1); // 顶部<260 且未量高:页1(160)。页0 已量高排除,页2(260)不<260 + expect(ph.total()).toBe(160 + 4 * 140); // 页0 实测保留,其余按新估高 + expect(ph.setEst(140, 9999)).toBe(0); // 同值幂等:est 不变直接 0 +}); + it("n=0 时一切归空", () => { const ph = new PageHeights(0, 100); expect(ph.total()).toBe(0);