mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-03 20:26:36 +08:00
优化
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user