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:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user