This commit is contained in:
truewhile
2026-10-02 17:56:16 +08:00
parent 2c6646beea
commit f3b88fad7c
10 changed files with 581 additions and 22 deletions
+70
View File
@@ -0,0 +1,70 @@
import { useEffect, useMemo, useState } from 'react'
import {
buildSpreadIndexOf,
buildSpreads,
hasImageSize,
loadImageSize,
type ComicSpread,
} from '../utils/comicSpread'
// 漫画双页铺开的分组 + 尺寸探测。
//
// 分组结果依赖每张图是否「横跨两页」,而这要等图片尺寸探测出来才能判断,
// 于是探测每完成一张就 bump 一次版本号、重新分组。为避免把整章图片都拉一遍,
// 只探测当前页附近的一小段窗口:向前看 8 张,翻到下一屏时它的尺寸已经就绪,
// 不会出现「先按竖版配好、渲染完再重排」的跳动。
/** 当前页往后探测几张。 */
const PROBE_AHEAD = 8
/** 当前页往前回看几张(滑块往回拖时补上断档)。 */
const PROBE_BEHIND = 1
export interface ComicSpreads {
/** 每屏要显示的图片序号。 */
spreads: ComicSpread[]
/** 图片序号 → 所在屏序号。 */
spreadIndexOf: number[]
}
/**
* @param srcs 整章图片地址。
* @param enabled 只在真的会用到双页时探测,单页/滚动模式不额外发请求。
* @param center 当前图片序号,决定探测窗口。
*/
export function useComicSpreads(srcs: string[], enabled: boolean, center: number): ComicSpreads {
const [revision, setRevision] = useState(0)
useEffect(() => {
if (!enabled || srcs.length === 0) return
let cancelled = false
const from = Math.max(0, center - PROBE_BEHIND)
const to = Math.min(srcs.length - 1, center + PROBE_AHEAD)
const targets: string[] = []
for (let i = from; i <= to; i += 1) {
const src = srcs[i]
if (src && !hasImageSize(src)) targets.push(src)
}
if (targets.length === 0) return
for (const src of targets) {
loadImageSize(src)
.then(() => {
if (!cancelled) setRevision((v) => v + 1)
})
.catch(() => undefined)
}
return () => {
cancelled = true
}
}, [srcs, enabled, center])
const spreads = useMemo(() => {
// revision 参与依赖:尺寸探测每完成一张就重新分组,宽图才能正确独占一屏
void revision
return buildSpreads(srcs)
}, [srcs, revision])
const spreadIndexOf = useMemo(() => buildSpreadIndexOf(spreads, srcs.length), [spreads, srcs.length])
return { spreads, spreadIndexOf }
}
+104
View File
@@ -0,0 +1,104 @@
import { useEffect, useRef, type RefObject } from 'react'
/**
* 滚轮平滑滚动:把离散的滚轮格数累加成一个「目标位置」,
* 再让 scrollTop 每帧向它逼近,于是滚动像手机上下滑动一样连续、带减速惯性,
* 而不是浏览器的整格跳变。
*
* 参考帧间隔(60Hz)下的逼近比例:越大越跟手,越小越飘。
*/
const EASE_PER_FRAME = 0.2
/** 每帧的基准毫秒数。按真实帧间隔换算,120Hz 屏上速度才不会翻倍。 */
const FRAME_MS = 1000 / 60
/** 与目标相差小于这个像素直接落位,省掉最后一帧帧逼近。 */
const EPSILON = 0.5
/** 单帧最大推进间隔,避免切标签页回来后一步跳很远。 */
const MAX_FRAME_MS = 64
/**
* 给一个纵向滚动容器接上「类手机滑动」的滚轮滚动。
*
* - 只用滚轮事件驱动,触摸/拖动滚动条时立刻交还控制权,不和手指打架。
* - `ctrl/cmd + 滚轮`(浏览器缩放)不接管。
* - 容器不可滚动时不做 `preventDefault`。
*
* @param ref 滚动容器(`overflow-y: auto`)。
* @param enabled 是否接管;翻页模式等由别处处理滚轮的场景传 `false`。
*/
export function useSmoothWheelScroll(ref: RefObject<HTMLElement>, enabled: boolean): void {
const targetRef = useRef<number | null>(null)
const rafRef = useRef<number | null>(null)
const lastFrameRef = useRef(0)
useEffect(() => {
const el = ref.current
if (!el || !enabled) return
const stop = () => {
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
targetRef.current = null
lastFrameRef.current = 0
}
const step = (now: number) => {
rafRef.current = null
const target = targetRef.current
const node = ref.current
if (target === null || !node) return
const dt = lastFrameRef.current === 0 ? FRAME_MS : Math.min(MAX_FRAME_MS, now - lastFrameRef.current)
lastFrameRef.current = now
const diff = target - node.scrollTop
if (Math.abs(diff) < EPSILON) {
node.scrollTop = target
targetRef.current = null
lastFrameRef.current = 0
return
}
const before = node.scrollTop
node.scrollTop = before + diff * (1 - Math.pow(1 - EASE_PER_FRAME, dt / FRAME_MS))
// 到了滚动边界(或内容变短被浏览器夹住)时 scrollTop 不动了,
// 再继续逼近就是 rAF 空转,直接收手。
if (node.scrollTop === before) {
targetRef.current = null
lastFrameRef.current = 0
return
}
rafRef.current = requestAnimationFrame(step)
}
const onWheel = (e: WheelEvent) => {
// ctrl/cmd+滚轮是浏览器缩放,别抢
if (e.ctrlKey || e.metaKey || e.deltaY === 0) return
const max = el.scrollHeight - el.clientHeight
if (max <= 0) return
e.preventDefault()
// deltaMode: 0=像素 1=行 2=页,统一折算成像素
const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? el.clientHeight : 1
const base = targetRef.current ?? el.scrollTop
targetRef.current = Math.min(max, Math.max(0, base + e.deltaY * unit))
if (rafRef.current === null) {
lastFrameRef.current = 0
rafRef.current = requestAnimationFrame(step)
}
}
// 手指按下(移动端滑动、拖滚动条、点分区)立刻停掉动画,把控制权交还用户
const onTakeover = () => stop()
el.addEventListener('wheel', onWheel, { passive: false })
el.addEventListener('pointerdown', onTakeover)
el.addEventListener('touchstart', onTakeover, { passive: true })
return () => {
el.removeEventListener('wheel', onWheel)
el.removeEventListener('pointerdown', onTakeover)
el.removeEventListener('touchstart', onTakeover)
stop()
}
}, [ref, enabled])
}
+39 -9
View File
@@ -1,8 +1,14 @@
import { useEffect, useRef, useState } from 'react'
import { Loader2 } from 'lucide-react'
import { useSmoothWheelScroll } from '../../hooks/useSmoothWheelScroll'
import { rememberImageSize } from '../../utils/comicSpread'
// 漫画/图片阅读器(仿 legado MangaMenu 能力面):
// 上下滚动(默认)/ 左右单页两种模式;点击分区翻页/呼出菜单;图片懒加载。
// 上下滚动(默认)/ 左右单页 / 左右双页三种呈现;点击分区翻页/呼出菜单;图片懒加载。
//
// 双页铺开由外层算好「这一屏显示哪几张」传进来(见 utils/comicSpread.ts),
// 这里只负责把它们并排摆好、各自撑满视口高度。
interface ReaderComicProps {
images: string[]
@@ -14,17 +20,22 @@ interface ReaderComicProps {
onProgress: (imageIndex: number) => void
scrollTo: number | null // 滚动模式:外部要求滚动到的图片序号
onScrolled: () => void
/** 双页模式:本屏要并排显示的图片序号(1 张=独占的宽图/单页,2 张=左右一对)。 */
spread?: number[] | null
}
function ComicImage({
src,
theme,
fit = false,
half = false,
}: {
src: string
theme: { bg: string; text: string; accent: string }
// fit:单页模式用。整页缩放至视口内,长图不再被 overflow-hidden 的容器裁掉。
// fit:整页缩放至视口内,长图不再被 overflow-hidden 的容器裁掉。
fit?: boolean
// half:双页并排时占位不超过半屏;容器收窄到图片本身宽度,两页之间不留缝。
half?: boolean
}) {
const [state, setState] = useState<'loading' | 'ok' | 'error'>('loading')
@@ -40,14 +51,25 @@ function ComicImage({
// 改成图片正常参与布局,未加载时用 minHeight 占位,转圈/失败信息盖在上层。
return (
<div
className={`relative flex w-full items-center justify-center ${fit ? 'h-full' : ''}`}
style={{ backgroundColor: theme.bg }}
className={`relative flex items-center justify-center ${fit ? 'h-full' : ''} ${half ? '' : 'w-full'}`}
style={{
backgroundColor: theme.bg,
// 半屏占位:宽度取「图片自然宽度」与「半屏」的较小值。容器是 flex item,
// 百分比 max-width 相对这一行的宽度解析,所以图片不会撑破半屏;
// 图片比半屏窄时容器跟着收窄,左右两页自然贴在一起。
...(half ? { maxWidth: '50%', minWidth: 0 } : null),
}}
>
<img
src={src}
loading="lazy"
alt=""
onLoad={() => setState('ok')}
onLoad={(e) => {
const el = e.currentTarget
// 顺带回填原始尺寸:双页分组要用它判断这张图是否横跨两页
rememberImageSize(src, el.naturalWidth, el.naturalHeight)
setState('ok')
}}
onError={() => setState('error')}
className={fit ? 'block max-h-full w-auto max-w-full object-contain' : 'block w-full'}
style={state === 'ok' ? undefined : { minHeight: '10rem' }}
@@ -72,7 +94,7 @@ function ComicImage({
)
}
export function ReaderComic({ images, theme, mode, page, onZone, initialImage, onProgress, scrollTo, onScrolled }: ReaderComicProps) {
export function ReaderComic({ images, theme, mode, page, onZone, initialImage, onProgress, scrollTo, onScrolled, spread }: ReaderComicProps) {
const scrollRef = useRef<HTMLDivElement>(null)
const imgRefs = useRef<(HTMLDivElement | null)[]>([])
const restoredRef = useRef(false)
@@ -98,6 +120,9 @@ export function ReaderComic({ images, theme, mode, page, onZone, initialImage, o
restoredRef.current = false
}, [mode])
// 上下滚动模式的滚轮同样走「类手机滑动」的平滑惯性滚动
useSmoothWheelScroll(scrollRef, mode === 'scroll')
if (images.length === 0) {
return (
<div className="flex h-full items-center justify-center text-sm opacity-60" style={{ color: theme.text }}>
@@ -107,11 +132,16 @@ export function ReaderComic({ images, theme, mode, page, onZone, initialImage, o
}
if (mode === 'page') {
// 单页模式:翻页由外层点击区驱动(page 语义 = 图片序号)
const idx = Math.min(Math.max(page, 0), images.length - 1)
// 翻页模式:翻页由外层点击区驱动(page 语义 = 图片序号)。
// 双页铺开时外层按「屏」给好成员(spread),这里只负责并排摆开。
const group = (spread ?? []).filter((i) => i >= 0 && i < images.length)
const idxs = group.length > 0 ? group : [Math.min(Math.max(page, 0), images.length - 1)]
const paired = idxs.length > 1
return (
<div className="flex h-full items-center justify-center">
<ComicImage key={images[idx]} src={images[idx]} theme={theme} fit />
{idxs.map((i) => (
<ComicImage key={images[i]} src={images[i]} theme={theme} fit half={paired} />
))}
</div>
)
}
+101 -11
View File
@@ -5,6 +5,7 @@ import {
ArrowLeft,
ArrowLeftRight,
BookOpen,
Columns2,
LayoutList,
ListEnd,
Loader2,
@@ -17,6 +18,8 @@ import {
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
import { readerAPI, type ReaderBook, type ReaderChapter, type ReaderChapterContent, type ReaderSearchOrigin } from '../../api/reader'
import { useComicSpreads } from '../../hooks/useComicSpreads'
import { useSmoothWheelScroll } from '../../hooks/useSmoothWheelScroll'
import { READER_THEMES, getReaderTheme, useReaderSettingsStore } from '../../stores/readerSettings'
import { buildChapterGroups, chapterGroupIndexOf } from '../../utils/chapterGroups'
import { ReaderAudioPanel } from './ReaderAudioPanel'
@@ -49,6 +52,15 @@ const WHEEL_TURN_COOLDOWN = 220
/** 滚轮事件间隔超过这个毫秒数算新手势,重新累计(用于判断触控板一次滑动结束)。 */
const WHEEL_GESTURE_GAP = 180
/**
* 漫画双页铺开的最小窗口宽度。比这窄时并排两页每页只剩一条竖条,
* 反而比单页更难看清,所以渲染层按窗口宽度自动退回单页(偏好设置不动)。
*/
const COMIC_SPREAD_MIN_WIDTH = 900
/** 空图片列表的常量引用:避免每次渲染产生新数组、把分组结果的 memo 依赖打散。 */
const NO_IMAGES: string[] = []
function firstReadableIndex(chapters: ReaderChapter[]): number {
const i = chapters.findIndex((c) => !c.is_volume && c.url)
if (i !== -1) return i
@@ -109,6 +121,34 @@ export default function ReaderViewPage() {
const pendingPosRef = useRef(0)
const pendingEndRef = useRef(false)
// ── 漫画双页铺开(桌面端) ──
// 窗口宽度决定双页是否可用:偏好在 settings 里(用户可关),但窗口不够宽时
// 即使开着也退回单页,所以这里额外跟一个窗口宽度。
const [windowWidth, setWindowWidth] = useState(() => window.innerWidth)
useEffect(() => {
const onResize = () => setWindowWidth(window.innerWidth)
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
}, [])
const comicImages = contentType === 'image' && media?.images ? media.images : NO_IMAGES
const comicDoublePage =
contentType === 'image' &&
settings.pageMode === 'page' &&
settings.comicDoublePage &&
windowWidth >= COMIC_SPREAD_MIN_WIDTH
// 分组依赖每张图的原始宽高(横跨两页的宽图要独占一屏),探测窗口跟着当前页走
const { spreads: comicSpreads, spreadIndexOf: comicSpreadIndexOf } = useComicSpreads(
comicImages,
comicDoublePage,
comicPage,
)
/** 当前是第几「屏」。进度仍按图片序号记,屏序号只用于翻页与渲染。 */
const comicSpreadIndex =
comicDoublePage && comicSpreads.length > 0
? Math.min(comicSpreadIndexOf[comicPage] ?? 0, comicSpreads.length - 1)
: 0
// ── 加载书籍与章节 ──
useEffect(() => {
let cancelled = false
@@ -259,6 +299,11 @@ export default function ReaderViewPage() {
}
}, [content, settings.pageMode])
// 滚动模式的滚轮改成「类手机滑动」:把滚轮格数累加成目标位置再逐帧逼近,
// 滚动连续、松手后自己滑行一段,而不是浏览器整格跳变。
// 翻页模式的滚轮由下面的翻页监听器接管,这里必须关掉。
useSmoothWheelScroll(scrollRef, settings.pageMode === 'scroll' && contentType === 'text')
// ── 进度保存(翻页 / 滚动 / 听书 / 漫画) ──
// pos 对文本是页码、漫画是图片序号、听书是秒;audio.currentTime 带小数,
// 统一取整后再上报,和 dur_chapter_pos 的 int 语义对齐。
@@ -446,6 +491,12 @@ export default function ReaderViewPage() {
return
}
if (contentType === 'image' && settings.pageMode === 'page') {
// 双页:一次退一屏;进度落在该屏的首张图上
if (comicDoublePage && comicSpreads.length > 0) {
if (comicSpreadIndex > 0) setComicPage(comicSpreads[comicSpreadIndex - 1][0])
else goChapter(-1, true)
return
}
if (comicPage > 0) setComicPage((p) => p - 1)
else goChapter(-1, true)
return
@@ -456,7 +507,7 @@ export default function ReaderViewPage() {
}
if (page > 0) setPage((p) => p - 1)
else goChapter(-1, true)
}, [contentType, settings.pageMode, comicPage, page, goChapter])
}, [contentType, settings.pageMode, comicDoublePage, comicSpreads, comicSpreadIndex, comicPage, page, goChapter])
const goNext = useCallback(() => {
if (contentType === 'audio') {
@@ -464,6 +515,11 @@ export default function ReaderViewPage() {
return
}
if (contentType === 'image' && settings.pageMode === 'page') {
if (comicDoublePage && comicSpreads.length > 0) {
if (comicSpreadIndex < comicSpreads.length - 1) setComicPage(comicSpreads[comicSpreadIndex + 1][0])
else goChapter(1)
return
}
const n = media?.images?.length ?? 0
if (comicPage < n - 1) setComicPage((p) => p + 1)
else goChapter(1)
@@ -477,7 +533,22 @@ export default function ReaderViewPage() {
}
if (page < pageCount - 1) setPage((p) => p + 1)
else goChapter(1)
}, [contentType, settings.pageMode, media, comicPage, page, pageCount, goChapter])
}, [contentType, settings.pageMode, comicDoublePage, comicSpreads, comicSpreadIndex, media, comicPage, page, pageCount, goChapter])
// ── 点击分区(左 30% 上一页 / 中间呼出菜单 / 右 30% 下一页) ──
// 翻页模式用覆盖层上的三个按钮驱动;滚动模式不能再用覆盖层——
// 覆盖层不是滚动容器的子节点,手机上手指落在它上面会让纵向滑动失效
// (只能点左右),所以滚动模式改在滚动容器自身的 onClick 上按 x 坐标分区:
// 纵向拖动不会产生 click,浏览器原生滚动照常工作。
const handleZoneTap = useCallback(
(clientX: number, rect: DOMRect) => {
const x = rect.width > 0 ? (clientX - rect.left) / rect.width : 0.5
if (x < 0.3) goPrev()
else if (x > 0.7) goNext()
else setMenuOpen((v) => !v)
},
[goPrev, goNext],
)
// ── 键盘(桌面端) ──
useEffect(() => {
@@ -499,12 +570,13 @@ export default function ReaderViewPage() {
}, [goPrev, goNext, menuOpen, panel])
// ── 鼠标滚轮翻页(仅翻页模式) ──
// 往上滚=上一页,往下滚=下一页。滚动模式保持浏览器原生滚动,不做接管。
// 往上滚=上一页,往下滚=下一页。滚动模式的滚轮不在这里处理,
// 由 useSmoothWheelScroll 接管做平滑惯性滚动。
// 滚轮必须用原生监听器:React 的 onWheel 是 passive 的,调不了 preventDefault。
useEffect(() => {
const el = readerRef.current
if (!el) return
// 滚动模式/听书面板/漫画滚动交给浏览器自己处理;菜单打开时不翻页
// 滚动模式/听书面板/漫画滚动交给各自的滚动逻辑;菜单打开时不翻页
if (settings.pageMode !== 'page' || menuOpen) return
if (contentType !== 'text' && contentType !== 'image') return
@@ -639,7 +711,7 @@ export default function ReaderViewPage() {
<div ref={readerRef} className="fixed inset-0 z-40 flex flex-col" style={{ backgroundColor: theme.bg, color: theme.text }}>
{/* 正文视口 */}
<div className="relative flex-1 overflow-hidden">
<div className="mx-auto h-full w-full max-w-[900px]">
<div className={`mx-auto h-full w-full ${contentType === 'image' && comicDoublePage ? '' : 'max-w-[900px]'}`}>
{contentType === 'audio' ? (
media && media.tracks && media.tracks.length > 0 ? (
<ReaderAudioPanel
@@ -670,10 +742,11 @@ export default function ReaderViewPage() {
)
) : contentType === 'image' ? (
<ReaderComic
images={media?.images ?? []}
images={comicImages}
theme={theme}
mode={settings.pageMode}
page={comicPage}
spread={comicDoublePage ? (comicSpreads[comicSpreadIndex] ?? null) : null}
onZone={(zone) => {
if (zone === 'center') setMenuOpen((v) => !v)
}}
@@ -711,6 +784,7 @@ export default function ReaderViewPage() {
) : (
<div
ref={scrollRef}
onClick={(e) => handleZoneTap(e.clientX, e.currentTarget.getBoundingClientRect())}
onScroll={(e) => {
const el = e.currentTarget
if (chapterIndex === null) return
@@ -723,7 +797,7 @@ export default function ReaderViewPage() {
}
}
}}
className={`h-full overflow-y-auto px-4 ${MENU_SHIFT}`}
className={`h-full overflow-y-auto overscroll-contain px-4 ${MENU_SHIFT}`}
style={{ fontSize: settings.fontSize, lineHeight: settings.lineHeight, ...menuShiftStyle }}
>
<div className="py-4">
@@ -763,7 +837,10 @@ export default function ReaderViewPage() {
)}
</div>
{(contentType === 'text' || (contentType === 'image' && settings.pageMode === 'page')) && (
{/* 点击分区覆盖层:只在翻页模式用。滚动模式用覆盖层会挡住正文的
原生纵向滚动(手指落在覆盖层上时找不到可滚动的祖先节点),
所以滚动模式由正文容器的 onClick 分区(见 handleZoneTap)。 */}
{settings.pageMode === 'page' && (contentType === 'text' || contentType === 'image') && (
<div className="absolute inset-0 grid grid-cols-[30%_40%_30%]">
<button type="button" aria-label="上一页" onClick={goPrev} className="cursor-w-resize" />
<button
@@ -846,8 +923,10 @@ export default function ReaderViewPage() {
</button>
</div>
{/* 动作行(目录 / 界面 / 夜间 / 模式 / 换源) */}
<div className={`${canSwitchSource ? 'grid-cols-5' : 'grid-cols-4'} grid pt-1`} style={{ color: theme.text }}>
{/* 动作行(目录 / 界面 / 夜间 / 模式 / 双页 / 换源)。
用 flex 等分而不是 grid-cols-N:动作数量随内容类型和书源能力变化,
grid 列数得写死成字面量类名,多一个按钮就要再加一档。 */}
<div className="flex pt-1" style={{ color: theme.text }}>
{([
{ icon: <LayoutList size={18} />, label: '目录', action: () => openPanel('toc') },
{ icon: <BookOpen size={18} />, label: '界面', action: () => openPanel('style') },
@@ -861,6 +940,17 @@ export default function ReaderViewPage() {
label: settings.pageMode === 'page' ? '滚动' : '翻页',
action: () => settings.setPageMode(settings.pageMode === 'page' ? 'scroll' : 'page'),
},
// 双页铺开只对漫画的翻页模式有意义;窗口太窄时并排两页没法看,
// 干脆不显示入口(不是灰掉,避免用户以为坏了)。
...(contentType === 'image' && settings.pageMode === 'page' && windowWidth >= COMIC_SPREAD_MIN_WIDTH
? [
{
icon: <Columns2 size={18} />,
label: settings.comicDoublePage ? '单页' : '双页',
action: () => settings.setComicDoublePage(!settings.comicDoublePage),
},
]
: []),
...(canSwitchSource
? [
{
@@ -877,7 +967,7 @@ export default function ReaderViewPage() {
key={item.label}
type="button"
onClick={item.action}
className="flex flex-col items-center gap-1 py-1 opacity-80 hover:opacity-100"
className="flex flex-1 flex-col items-center gap-1 py-1 opacity-80 hover:opacity-100"
>
{item.icon}
<span className="text-2xs">{item.label}</span>
+13 -1
View File
@@ -77,6 +77,10 @@ interface ReaderSettingsState {
pageMode: ReaderPageMode
setPageMode: (mode: ReaderPageMode) => void
/** 漫画左右「双页铺开」偏好(legado 无对应项,是 Web 端桌面化补充)。 */
comicDoublePage: boolean
setComicDoublePage: (on: boolean) => void
fontSize: number
setFontSize: (size: number) => void
@@ -136,6 +140,13 @@ export const useReaderSettingsStore = create<ReaderSettingsState>()(
pageMode: 'page',
setPageMode: (pageMode) => set({ pageMode }),
// 默认开启:桌面端(窗口够宽)漫画自动两页铺开,窄屏(手机/窗口拉窄)
// 由渲染层按窗口宽度自动退回单页,所以这里开着不会影响手机阅读。
// 刻意不做跨设备同步:双页是否合适取决于屏幕宽度,属于设备级偏好,
// 和 homeMode 同理(见 utils/readerSettingsSync.ts 的载荷字段)。
comicDoublePage: true,
setComicDoublePage: (comicDoublePage) => set({ comicDoublePage }),
fontSize: 20,
setFontSize: (fontSize) => set({ fontSize: Math.min(32, Math.max(14, fontSize)) }),
@@ -162,7 +173,8 @@ export const useReaderSettingsStore = create<ReaderSettingsState>()(
* 与账号同步的阅读器偏好(对应后端 GET/PUT /reader/profile 的载荷)。
*
* 这些设置原先是设备级的(只存 localStorage);现在按用户落库,换设备也能保持一致。
* 首页的「影视 / 阅读」模式(homeMode)属于设备偏好,故意不参与同步。
* 首页的「影视 / 阅读」模式(homeMode)与漫画双页(comicDoublePage)属于设备偏好,
* 故意不参与同步——前者是入口选择,后者是否合适取决于屏幕宽度。
*/
export interface ReaderSettingsProfile {
theme_id: string
+108
View File
@@ -0,0 +1,108 @@
// 漫画「双页铺开」的纯逻辑与图片尺寸探测。
//
// 桌面端一屏能并排放下两页,漫画按「左右两页」成对观看才接近纸质阅读体验
// (尤其日漫的跨页)。麻烦在于章节里常混着「本来就横跨两页」的宽图(跨页扉页、
// 彩页):这类图必须独占一屏,否则会被硬塞进一对里,从它开始整章都错位。
//
// 所以分组规则与 Tachiyomi 的 dual page 一致:宽度明显大于高度的图独占一组,
// 其余连续的竖版页按顺序两两成对。判断需要原始宽高,而列表接口只给 URL,
// 这里就自己探测并缓存(<img> 渲染时也会顺带回填,能省掉一次请求)。
/** 图片原始像素尺寸。 */
export interface ImageSize {
width: number
height: number
}
/** 宽高比达到这个值即视为「跨页宽图」。普通页约 0.6~0.7,跨页图约 1.3~1.5。 */
const WIDE_RATIO = 1.1
/** 已探测到的尺寸,按 URL 缓存(null = 探测失败,当竖版处理),跨章节复用。 */
const sizeCache = new Map<string, ImageSize | null>()
/** 正在探测的图片,避免同一 URL 并发发起多次请求。 */
const inflight = new Map<string, Promise<ImageSize | null>>()
/** 渲染中的 <img> onLoad 顺带回填尺寸,省掉一次探测请求。 */
export function rememberImageSize(src: string, width: number, height: number): void {
if (!src || !width || !height) return
sizeCache.set(src, { width, height })
}
/** 该 URL 是否已经探测过(成功的记尺寸,失败的记 null)。 */
export function hasImageSize(src: string): boolean {
return sizeCache.has(src)
}
/** 探测一张图片的原始尺寸;失败(404 / 被拦 / 解码失败)返回 null。 */
export function loadImageSize(src: string): Promise<ImageSize | null> {
const cached = sizeCache.get(src)
if (cached !== undefined) return Promise.resolve(cached)
const running = inflight.get(src)
if (running) return running
const task = new Promise<ImageSize | null>((resolve) => {
const img = new Image()
const settle = (size: ImageSize | null) => {
sizeCache.set(src, size)
inflight.delete(src)
resolve(size)
}
img.onload = () =>
settle(
img.naturalWidth > 0 && img.naturalHeight > 0
? { width: img.naturalWidth, height: img.naturalHeight }
: null,
)
img.onerror = () => settle(null)
img.src = src
})
inflight.set(src, task)
return task
}
/** 该图是否横跨两页(宽明显大于高)。尺寸未知时按竖版处理,宁可先配对。 */
export function isWideImage(src: string): boolean {
const size = sizeCache.get(src)
if (!size) return false
return size.width / size.height >= WIDE_RATIO
}
/** 一「屏」要显示的图片序号:一张=独占的宽图/单页,两张=左右并排的一对。 */
export type ComicSpread = number[]
/**
* 把整章图片按双页铺开分组:连续的竖版页两两成对,宽图独占一组并打断前后配对。
* 尺寸还没探测出来的图先按竖版参与配对,探测完成后再重新分组(见 useComicSpreads)。
*/
export function buildSpreads(srcs: string[]): ComicSpread[] {
const spreads: ComicSpread[] = []
let i = 0
while (i < srcs.length) {
if (isWideImage(srcs[i])) {
spreads.push([i])
i += 1
continue
}
// 收集一段连续的竖版页,两两成对;落单的最后一张自己一屏(常见于章尾)
const run: number[] = []
while (i < srcs.length && !isWideImage(srcs[i])) {
run.push(i)
i += 1
}
for (let k = 0; k < run.length; k += 2) spreads.push(run.slice(k, k + 2))
}
return spreads
}
/**
* 图片序号 → 所在屏序号。进度是按图片序号记的,翻页要换算成「当前在第几屏」,
* 并且不管展开/收起双页都稳定(只由图片序号推出来,不额外存一屏序号)。
*/
export function buildSpreadIndexOf(spreads: ComicSpread[], total: number): number[] {
const map = new Array<number>(Math.max(0, total)).fill(0)
spreads.forEach((spread, si) => {
for (const idx of spread) {
if (idx >= 0 && idx < map.length) map[idx] = si
}
})
return map
}