mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-09 06:46:37 +08:00
优化
This commit is contained in:
@@ -95,7 +95,9 @@
|
|||||||
- 菜单打开时正文整体下移一个顶栏高度(用 `transform`,不改视口高度、不触发重新分页),顶栏不再压住开头 1–2 行。
|
- 菜单打开时正文整体下移一个顶栏高度(用 `transform`,不改视口高度、不触发重新分页),顶栏不再压住开头 1–2 行。
|
||||||
- 界面面板的字号/行距/段距三个调节组用 `flex-wrap`,窄屏自动折行,不会把「段距」挤出屏幕。
|
- 界面面板的字号/行距/段距三个调节组用 `flex-wrap`,窄屏自动折行,不会把「段距」挤出屏幕。
|
||||||
- 点击分区:翻页模式渲染一层 `absolute inset-0` 的三分区按钮(左 30% 上一页 / 中 40% 呼出菜单 / 右 30% 下一页);**滚动模式不能渲染这层覆盖层**——它不是滚动容器的子节点,手机上手指落在覆盖层按钮上时浏览器找不到可滚动的祖先,纵向滑动完全失效、只剩点左右能翻屏。滚动模式改为在滚动容器自身的 `onClick` 上按 x 坐标分区(`handleZoneTap`),手指拖动不会产生 click,原生纵向滚动照常。
|
- 点击分区:翻页模式渲染一层 `absolute inset-0` 的三分区按钮(左 30% 上一页 / 中 40% 呼出菜单 / 右 30% 下一页);**滚动模式不能渲染这层覆盖层**——它不是滚动容器的子节点,手机上手指落在覆盖层按钮上时浏览器找不到可滚动的祖先,纵向滑动完全失效、只剩点左右能翻屏。滚动模式改为在滚动容器自身的 `onClick` 上按 x 坐标分区(`handleZoneTap`),手指拖动不会产生 click,原生纵向滚动照常。
|
||||||
- 手机横向滑动翻页(`hooks/useHorizontalSwipe.ts`):翻页模式(文本/漫画)在三分区覆盖层上再接一层触摸手势——**从左往右滑=上一页、从右往左滑=下一页**。判定要同时满足「水平位移 ≥45px」「水平位移 ≥ 垂直位移的 1.2 倍」「手势时长 ≤800ms」,所以短距离仍按点击分区处理、纵向拖动不会被误判成翻页。滑动后浏览器可能仍补发一次 click 落在原分区按钮上(会「滑一页又点一页」翻两页),用 `consumeSwipe()` 让三个按钮的 `onClick` 先判断这次 click 是否已被滑动吃掉;标记在每次 `touchstart` 重置,所以真正滑动不产生 click 时也不会吞掉下一次点击。滑动本身复用既有的 220ms 平移动画,不额外做跟手拖拽。
|
- 手机横向滑动翻页(`hooks/useHorizontalSwipe.ts`):翻页模式(文本/漫画)在三分区覆盖层上再接一层触摸手势——**从左往右滑=上一页、从右往左滑=下一页**,并且**跟手**:位移超过 12px 且横向明显大于纵向(1.2 倍)之后,内容就跟着手指走,松手再按「位移 ≥45px」决定翻页还是回弹(跟手手势不再看手势时长,慢慢拖过去同样是拖动)。跟手手势一律吞掉随之而来的 click,否则短距离拖动会「回弹 + 再翻一页」;没走跟手的手势仍按原来的「≥45px + 时长 ≤800ms」判定一次滑动,短距离当点击交给分区按钮。`consumeSwipe()` 让三个分区按钮的 `onClick` 先判断这次 click 是否已被手势吃掉,标记在每次 `touchstart` 重置。
|
||||||
|
- 跟手拖拽平移的是两个不同的元素。文本:正文列外面单挂一层「跟手层」(`pageDragRef`),分页平移仍旧由 React 写在里层,跟手层只管手指位移;松手时两层同时动(跟手位移归零 + 页码变化),合起来就是一次连续滑动。分两层是为了避开「跟手位移把 DOM 改成 React 不知道的值、React 又认为 transform 没变而不去写」把页面留在拖动位置上的坑。漫画:平移 `ReaderComic` 的舞台,舞台左右各摆一页(见下一条)。
|
||||||
|
- 漫画单页翻页的跟手轨道与预取(`ReaderComic` 的 `draggable` 分支):翻页模式且没有并排铺开两页时,当前页左右各摆一页,手指横滑整条舞台跟着走。单元格按图片序号做 key、位置用 `left` 百分比铺开(不是固定三个槽位换内容),所以翻页时变的只是 `left`,`<img>` 的 src 不变,浏览器不会重新解码、也不会先闪一下转圈。松手要翻页时先把新页瞬移到手指离开的位置(这一步必须关掉过渡,否则会先反着滑一段),读一次布局把这一帧定成过渡起点,再打开过渡滑回 0;同时页码 +1/-1,单元格的 `left` 在同一次提交里跟着换,两边相抵,屏幕上看不出切换。**两侧那两页同时就是下一页/上一页的预取**(带 `eager`:它们被 `overflow-hidden` 裁在屏幕外,懒加载会认为「离视口还远」而不去取,横滑过去就会看到转圈)——这正是「手机上翻一页要等一秒、电脑上(双页开着会顺带往后探测 8 张)不卡」的根因。
|
||||||
- 滚动模式的滚轮(`useSmoothWheelScroll`):把滚轮格数累加成一个目标位置,再逐帧向它逼近,滚动连续、松手后自己滑行一段,仿手机上下滑动,而不是浏览器整格跳变。只在文本/漫画的上下滚动模式接管;翻页模式仍走下面的翻页监听器。按真实帧间隔换算逼近比例,120Hz 屏上速度不会翻倍;`ctrl/cmd+滚轮` 保留缩放;手指按下(滑动、拖滚动条、点分区)立刻停掉动画把控制权交还用户。
|
- 滚动模式的滚轮(`useSmoothWheelScroll`):把滚轮格数累加成一个目标位置,再逐帧向它逼近,滚动连续、松手后自己滑行一段,仿手机上下滑动,而不是浏览器整格跳变。只在文本/漫画的上下滚动模式接管;翻页模式仍走下面的翻页监听器。按真实帧间隔换算逼近比例,120Hz 屏上速度不会翻倍;`ctrl/cmd+滚轮` 保留缩放;手指按下(滑动、拖滚动条、点分区)立刻停掉动画把控制权交还用户。
|
||||||
- 漫画双页铺开(`hooks/useComicSpreads.ts` + `utils/comicSpread.ts`,legado 无对应项,是桌面端补充):窗口宽度 ≥900px 且漫画处于翻页模式时,一屏并排显示两页(`settings.comicDoublePage` 为设备级偏好,默认开,可在菜单里关;窗口不够宽时自动退回单页)。分组规则同 Tachiyomi 的 dual page——宽度明显大于高度(比值 ≥1.1)的图视为「跨页宽图」独占一屏,其余连续的竖版页两两配对;列表接口只给 URL,尺寸靠 `new Image()` 探测(`<img>` 渲染时顺带回填),只探测当前页往后 8 张的窗口,避免整章预载。进度仍按图片序号记,翻页时换算成「第几屏」,所以关掉再开双页进度不会错位。菜单动作行改为 `flex` 等分,容纳「双页/单页」入口(`grid-cols-N` 得写死列数字面量,多一个按钮无法复用)。
|
- 漫画双页铺开(`hooks/useComicSpreads.ts` + `utils/comicSpread.ts`,legado 无对应项,是桌面端补充):窗口宽度 ≥900px 且漫画处于翻页模式时,一屏并排显示两页(`settings.comicDoublePage` 为设备级偏好,默认开,可在菜单里关;窗口不够宽时自动退回单页)。分组规则同 Tachiyomi 的 dual page——宽度明显大于高度(比值 ≥1.1)的图视为「跨页宽图」独占一屏,其余连续的竖版页两两配对;列表接口只给 URL,尺寸靠 `new Image()` 探测(`<img>` 渲染时顺带回填),只探测当前页往后 8 张的窗口,避免整章预载。进度仍按图片序号记,翻页时换算成「第几屏」,所以关掉再开双页进度不会错位。菜单动作行改为 `flex` 等分,容纳「双页/单页」入口(`grid-cols-N` 得写死列数字面量,多一个按钮无法复用)。
|
||||||
- 漫画图片显示尺寸(`utils/comicImageFit.ts`,仅上下滚动模式):legado 的漫画阅读有「缩放」,Web 版原先只有一种写死的宽度(正文列封顶 900px),桌面端放不大也缩不小,所以补上一组档位,在界面面板的「图片尺寸」一行切换。`default` 就是老样子(900px 居中),`width` 适应宽度(正文列不再封顶、铺满窗口),`height` 适应高度(一屏一页),`long` 适应长边(整页完整可见),`original` 原图(原始像素 1:1,超出部分横向滚动)。`default`/`width` 由外层列宽决定,其余三个由 `<img>` 自身约束,**所以后三种必须放开 900px 列宽上限**,否则会被列再压回去。`height`/`long` 要按「一屏多高」换算,所以量的是滚动容器本身而不是 `window.innerHeight`(正文区是 root 里的 `flex-1`,另挂上下条之后就不等于窗口高度了),并用 `ResizeObserver` 跟随;只在启用了自尺寸档位时才挂观察器,默认档位不白白重渲染整章。`settings.comicImageFit` 是设备级偏好(不同屏幕和源分辨率合适的尺寸不同),和 `comicDoublePage` 一样只存本机、不参与账号同步。翻页模式是整页缩放进视口,本来就不存在「太大/太小」,档位对它不生效、也不显示入口。`ctrl/cmd+滚轮` 依然留给浏览器缩放,本功能只用档位做粗调。
|
- 漫画图片显示尺寸(`utils/comicImageFit.ts`,仅上下滚动模式):legado 的漫画阅读有「缩放」,Web 版原先只有一种写死的宽度(正文列封顶 900px),桌面端放不大也缩不小,所以补上一组档位,在界面面板的「图片尺寸」一行切换。`default` 就是老样子(900px 居中),`width` 适应宽度(正文列不再封顶、铺满窗口),`height` 适应高度(一屏一页),`long` 适应长边(整页完整可见),`original` 原图(原始像素 1:1,超出部分横向滚动)。`default`/`width` 由外层列宽决定,其余三个由 `<img>` 自身约束,**所以后三种必须放开 900px 列宽上限**,否则会被列再压回去。`height`/`long` 要按「一屏多高」换算,所以量的是滚动容器本身而不是 `window.innerHeight`(正文区是 root 里的 `flex-1`,另挂上下条之后就不等于窗口高度了),并用 `ResizeObserver` 跟随;只在启用了自尺寸档位时才挂观察器,默认档位不白白重渲染整章。`settings.comicImageFit` 是设备级偏好(不同屏幕和源分辨率合适的尺寸不同),和 `comicDoublePage` 一样只存本机、不参与账号同步。翻页模式是整页缩放进视口,本来就不存在「太大/太小」,档位对它不生效、也不显示入口。`ctrl/cmd+滚轮` 依然留给浏览器缩放,本功能只用档位做粗调。
|
||||||
|
|||||||
@@ -4,15 +4,24 @@ import { useCallback, useRef, type TouchEvent as ReactTouchEvent } from 'react'
|
|||||||
// 原来只能点左 30% / 右 30% 分区翻页,手指横滑没有任何反应,翻页很别扭。
|
// 原来只能点左 30% / 右 30% 分区翻页,手指横滑没有任何反应,翻页很别扭。
|
||||||
//
|
//
|
||||||
// 判定规则(和纵向滚动共存的关键):
|
// 判定规则(和纵向滚动共存的关键):
|
||||||
// - 位移要够长(minDistance),短距离当点击处理,交给下面的分区点击;
|
// - 方向要先判定出来:位移超过 slop 后,横向位移要明显大于纵向位移(dominance 倍)
|
||||||
// - 横向位移要明显大于纵向位移(dominance 倍),否则算纵向滑动/滚动,不翻页;
|
// 才算横滑,否则算纵向滑动/滚动,本次手势彻底放弃(连 click 也不吞);
|
||||||
// - 手势时长不能太长(maxDuration),慢慢拖过去不算一次滑动。
|
// - 没走跟手(点按、或没传 onDrag)时,仍按 minDistance + maxDuration 判定一次滑动,
|
||||||
|
// 短距离当点击处理,交给下面的分区点击。
|
||||||
|
//
|
||||||
|
// 跟手拖拽:传了 onDrag 之后,手指横向移动会实时回调位移,松手再回调 onDragEnd,
|
||||||
|
// 由宿主把内容平移到位(正文列 / 漫画舞台)并决定翻页还是回弹。滑动途中内容就跟着
|
||||||
|
// 手指走,不再等到松手才动一下。跟手手势一律按「拖动」处理,不再走 maxDuration 判定
|
||||||
|
// (慢慢拖过去也是拖动)。
|
||||||
//
|
//
|
||||||
// 一次滑动之后浏览器仍可能补一个 click(落在原来的分区按钮上),
|
// 一次滑动之后浏览器仍可能补一个 click(落在原来的分区按钮上),
|
||||||
// 于是会「滑一页又点一页」翻两页。用 consumeSwipe() 让点击回调先问一句:
|
// 于是会「滑一页又点一页」翻两页。用 consumeSwipe() 让点击回调先问一句:
|
||||||
// 这次 click 是不是刚被滑动吃掉,是则跳过。每次 touchstart 会重置这个标记,
|
// 这次 click 是不是刚被滑动吃掉,是则跳过。每次 touchstart 会重置这个标记,
|
||||||
// 所以真实滑动不产生 click 时也不会误吞下一次点击。
|
// 所以真实滑动不产生 click 时也不会误吞下一次点击。
|
||||||
|
|
||||||
|
/** 判定手势方向的位移阈值(px):比这还小就还在点按的抖动范围内。 */
|
||||||
|
const DIRECTION_SLOP = 12
|
||||||
|
|
||||||
interface HorizontalSwipeOptions {
|
interface HorizontalSwipeOptions {
|
||||||
/** 从左往右滑(手指向右移)。 */
|
/** 从左往右滑(手指向右移)。 */
|
||||||
onSwipeRight: () => void
|
onSwipeRight: () => void
|
||||||
@@ -22,12 +31,17 @@ interface HorizontalSwipeOptions {
|
|||||||
minDistance?: number
|
minDistance?: number
|
||||||
/** 水平位移需达到垂直位移的这个倍数才算横滑,避免和纵向滚动打架。 */
|
/** 水平位移需达到垂直位移的这个倍数才算横滑,避免和纵向滚动打架。 */
|
||||||
dominance?: number
|
dominance?: number
|
||||||
/** 手势超过这个时长不算滑动(毫秒)。 */
|
/** 手势超过这个时长不算滑动(毫秒)。只对非跟手的滑动判定生效。 */
|
||||||
maxDuration?: number
|
maxDuration?: number
|
||||||
|
/** 跟手拖拽中的位移(px,向右为正)。传了才启用跟手。 */
|
||||||
|
onDrag?: (dx: number) => void
|
||||||
|
/** 跟手松手:宿主在这里回弹或翻页(随后浏览器补发的 click 会被吞掉)。 */
|
||||||
|
onDragEnd?: (dx: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HorizontalSwipeHandlers {
|
interface HorizontalSwipeHandlers {
|
||||||
onTouchStart: (e: ReactTouchEvent) => void
|
onTouchStart: (e: ReactTouchEvent) => void
|
||||||
|
onTouchMove: (e: ReactTouchEvent) => void
|
||||||
onTouchEnd: (e: ReactTouchEvent) => void
|
onTouchEnd: (e: ReactTouchEvent) => void
|
||||||
onTouchCancel: () => void
|
onTouchCancel: () => void
|
||||||
/** 点击回调里先调用;返回 true 表示这次点击已被滑动消费,应跳过默认动作。 */
|
/** 点击回调里先调用;返回 true 表示这次点击已被滑动消费,应跳过默认动作。 */
|
||||||
@@ -40,21 +54,62 @@ export function useHorizontalSwipe({
|
|||||||
minDistance = 45,
|
minDistance = 45,
|
||||||
dominance = 1.2,
|
dominance = 1.2,
|
||||||
maxDuration = 800,
|
maxDuration = 800,
|
||||||
|
onDrag,
|
||||||
|
onDragEnd,
|
||||||
}: HorizontalSwipeOptions): HorizontalSwipeHandlers {
|
}: HorizontalSwipeOptions): HorizontalSwipeHandlers {
|
||||||
const startRef = useRef<{ x: number; y: number; t: number } | null>(null)
|
const startRef = useRef<{ x: number; y: number; t: number } | null>(null)
|
||||||
const consumedRef = useRef(false)
|
const consumedRef = useRef(false)
|
||||||
|
/** 本次手势是否已判定为横滑并在跟手。 */
|
||||||
|
const draggingRef = useRef(false)
|
||||||
|
const lastDxRef = useRef(0)
|
||||||
|
|
||||||
const onTouchStart = useCallback((e: ReactTouchEvent) => {
|
const onTouchStart = useCallback((e: ReactTouchEvent) => {
|
||||||
const t = e.touches[0]
|
const t = e.touches[0]
|
||||||
if (!t) return
|
if (!t) return
|
||||||
startRef.current = { x: t.clientX, y: t.clientY, t: Date.now() }
|
startRef.current = { x: t.clientX, y: t.clientY, t: Date.now() }
|
||||||
consumedRef.current = false
|
consumedRef.current = false
|
||||||
|
draggingRef.current = false
|
||||||
|
lastDxRef.current = 0
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const onTouchMove = useCallback(
|
||||||
|
(e: ReactTouchEvent) => {
|
||||||
|
const start = startRef.current
|
||||||
|
const t = e.touches[0]
|
||||||
|
if (!start || !t) return
|
||||||
|
const dx = t.clientX - start.x
|
||||||
|
const dy = t.clientY - start.y
|
||||||
|
if (!draggingRef.current) {
|
||||||
|
if (!onDrag) return
|
||||||
|
if (Math.abs(dx) < DIRECTION_SLOP && Math.abs(dy) < DIRECTION_SLOP) return
|
||||||
|
// 纵向占优:这是滚动,不是翻页。丢掉起点,之后不再判定滑动也不吞 click。
|
||||||
|
if (Math.abs(dx) < Math.abs(dy) * dominance) {
|
||||||
|
startRef.current = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
draggingRef.current = true
|
||||||
|
}
|
||||||
|
lastDxRef.current = dx
|
||||||
|
onDrag?.(dx)
|
||||||
|
},
|
||||||
|
[onDrag, dominance],
|
||||||
|
)
|
||||||
|
|
||||||
const onTouchEnd = useCallback(
|
const onTouchEnd = useCallback(
|
||||||
(e: ReactTouchEvent) => {
|
(e: ReactTouchEvent) => {
|
||||||
const start = startRef.current
|
const start = startRef.current
|
||||||
startRef.current = null
|
startRef.current = null
|
||||||
|
if (draggingRef.current) {
|
||||||
|
draggingRef.current = false
|
||||||
|
// 跟手手势一律吞掉随之而来的 click:翻页已经在 onDragEnd 里决定了,
|
||||||
|
// 不吞的话短距离拖动会「回弹 + 再翻一页」。
|
||||||
|
consumedRef.current = true
|
||||||
|
const t = e.changedTouches[0]
|
||||||
|
// 用松手这一刻的位置而不是最后一次 touchmove:手指最后那几像素也要算进去。
|
||||||
|
const dx = start && t ? t.clientX - start.x : lastDxRef.current
|
||||||
|
onDragEnd?.(dx)
|
||||||
|
return
|
||||||
|
}
|
||||||
const t = e.changedTouches[0]
|
const t = e.changedTouches[0]
|
||||||
if (!start || !t) return
|
if (!start || !t) return
|
||||||
|
|
||||||
@@ -68,12 +123,17 @@ export function useHorizontalSwipe({
|
|||||||
if (dx > 0) onSwipeRight()
|
if (dx > 0) onSwipeRight()
|
||||||
else onSwipeLeft()
|
else onSwipeLeft()
|
||||||
},
|
},
|
||||||
[onSwipeRight, onSwipeLeft, minDistance, dominance, maxDuration],
|
[onSwipeRight, onSwipeLeft, minDistance, dominance, maxDuration, onDragEnd],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onTouchCancel = useCallback(() => {
|
const onTouchCancel = useCallback(() => {
|
||||||
startRef.current = null
|
startRef.current = null
|
||||||
}, [])
|
if (!draggingRef.current) return
|
||||||
|
draggingRef.current = false
|
||||||
|
// 手势被系统打断(来电、边缘返回手势):按「没翻页」处理,让内容回弹。
|
||||||
|
// 传 0 而不是最后的位置——打断不等于用户想翻页,别顺手翻过去。
|
||||||
|
onDragEnd?.(0)
|
||||||
|
}, [onDragEnd])
|
||||||
|
|
||||||
const consumeSwipe = useCallback(() => {
|
const consumeSwipe = useCallback(() => {
|
||||||
if (!consumedRef.current) return false
|
if (!consumedRef.current) return false
|
||||||
@@ -81,5 +141,5 @@ export function useHorizontalSwipe({
|
|||||||
return true
|
return true
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return { onTouchStart, onTouchEnd, onTouchCancel, consumeSwipe }
|
return { onTouchStart, onTouchMove, onTouchEnd, onTouchCancel, consumeSwipe }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState, type RefObject } from 'react'
|
||||||
import { Loader2 } from 'lucide-react'
|
import { Loader2 } from 'lucide-react'
|
||||||
|
|
||||||
import { useSmoothWheelScroll } from '../../hooks/useSmoothWheelScroll'
|
import { useSmoothWheelScroll } from '../../hooks/useSmoothWheelScroll'
|
||||||
@@ -27,6 +27,13 @@ interface ReaderComicProps {
|
|||||||
spread?: number[] | null
|
spread?: number[] | null
|
||||||
/** 滚动模式的图片显示尺寸档位(翻页模式不适用,传 default 即可)。 */
|
/** 滚动模式的图片显示尺寸档位(翻页模式不适用,传 default 即可)。 */
|
||||||
imageFit?: ComicImageFit
|
imageFit?: ComicImageFit
|
||||||
|
/**
|
||||||
|
* 翻页模式的跟手拖拽:把当前页左右各摆一页到舞台两侧,整条舞台由外层按
|
||||||
|
* stageRef 直接平移(见 ReaderViewPage 的 onPageDrag)。并排铺开两页时关闭。
|
||||||
|
*/
|
||||||
|
draggable?: boolean
|
||||||
|
/** 跟手拖拽要平移的舞台元素(由外层拿着直接写 transform,不走 React state)。 */
|
||||||
|
stageRef?: RefObject<HTMLDivElement>
|
||||||
}
|
}
|
||||||
|
|
||||||
function ComicImage({
|
function ComicImage({
|
||||||
@@ -36,6 +43,7 @@ function ComicImage({
|
|||||||
half = false,
|
half = false,
|
||||||
imageFit = 'default',
|
imageFit = 'default',
|
||||||
viewportHeight = 0,
|
viewportHeight = 0,
|
||||||
|
eager = false,
|
||||||
}: {
|
}: {
|
||||||
src: string
|
src: string
|
||||||
theme: { bg: string; text: string; accent: string }
|
theme: { bg: string; text: string; accent: string }
|
||||||
@@ -47,6 +55,10 @@ function ComicImage({
|
|||||||
imageFit?: ComicImageFit
|
imageFit?: ComicImageFit
|
||||||
// viewportHeight:滚动容器高度(px),height/long 档位按它换算。
|
// viewportHeight:滚动容器高度(px),height/long 档位按它换算。
|
||||||
viewportHeight?: number
|
viewportHeight?: number
|
||||||
|
// eager:跟手拖拽摆在舞台两侧的那两页。它们被 overflow-hidden 裁在屏幕外,
|
||||||
|
// 浏览器的懒加载认为「离视口还远」而不去取,横滑过去就会看到转圈,
|
||||||
|
// 所以这两页立刻加载(顺带把下一页提前拉好,翻页不用现等)。
|
||||||
|
eager?: boolean
|
||||||
}) {
|
}) {
|
||||||
const [state, setState] = useState<'loading' | 'ok' | 'error'>('loading')
|
const [state, setState] = useState<'loading' | 'ok' | 'error'>('loading')
|
||||||
|
|
||||||
@@ -82,7 +94,7 @@ function ComicImage({
|
|||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={src}
|
src={src}
|
||||||
loading="lazy"
|
loading={eager ? 'eager' : 'lazy'}
|
||||||
alt=""
|
alt=""
|
||||||
onLoad={(e) => {
|
onLoad={(e) => {
|
||||||
const el = e.currentTarget
|
const el = e.currentTarget
|
||||||
@@ -114,7 +126,7 @@ function ComicImage({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReaderComic({ images, theme, mode, page, onZone, initialImage, onProgress, scrollTo, onScrolled, spread, imageFit = 'default' }: ReaderComicProps) {
|
export function ReaderComic({ images, theme, mode, page, onZone, initialImage, onProgress, scrollTo, onScrolled, spread, imageFit = 'default', draggable = false, stageRef }: ReaderComicProps) {
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
const imgRefs = useRef<(HTMLDivElement | null)[]>([])
|
const imgRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||||
const restoredRef = useRef(false)
|
const restoredRef = useRef(false)
|
||||||
@@ -174,11 +186,39 @@ export function ReaderComic({ images, theme, mode, page, onZone, initialImage, o
|
|||||||
const group = (spread ?? []).filter((i) => i >= 0 && i < images.length)
|
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 idxs = group.length > 0 ? group : [Math.min(Math.max(page, 0), images.length - 1)]
|
||||||
const paired = idxs.length > 1
|
const paired = idxs.length > 1
|
||||||
|
|
||||||
|
if (!draggable) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
{idxs.map((i) => (
|
||||||
|
<ComicImage key={images[i]} src={images[i]} theme={theme} fit half={paired} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跟手拖拽:当前页左右各摆一页,整条舞台由外层直接平移(stageRef),
|
||||||
|
// 于是手指横滑时左右两页就在屏幕边上,滑到一半松手能接着滑完。
|
||||||
|
// 外面再套一层 overflow-hidden:桌面端正文列限宽 900px 居中,两侧那两页要是
|
||||||
|
// 溢出到窗口里会在留白处露出来。
|
||||||
|
//
|
||||||
|
// 单元格按图片序号做 key、位置用 left 百分比铺开,而不是固定三个「上一页/当前/下一页」
|
||||||
|
// 槽位换内容:翻页时变的只是 left 偏移,<img> 的 src 不变,浏览器不会重新解码、
|
||||||
|
// 也不会先闪一下转圈。两侧那两页同时充当下一页/上一页的预取。
|
||||||
|
const cells = [page - 1, page, page + 1].filter((i) => i >= 0 && i < images.length)
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="relative h-full w-full overflow-hidden">
|
||||||
{idxs.map((i) => (
|
<div ref={stageRef} className="relative h-full w-full">
|
||||||
<ComicImage key={images[i]} src={images[i]} theme={theme} fit half={paired} />
|
{cells.map((i) => (
|
||||||
))}
|
<div
|
||||||
|
key={images[i]}
|
||||||
|
className="absolute inset-y-0 flex items-center justify-center"
|
||||||
|
style={{ left: `${(i - page) * 100}%`, width: '100%' }}
|
||||||
|
>
|
||||||
|
<ComicImage src={images[i]} theme={theme} fit eager={i !== page} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,11 @@ const COMIC_SAVE_INTERVAL_MS = 2_000
|
|||||||
/** 菜单打开时正文下移过渡(与顶栏动画同节奏)。 */
|
/** 菜单打开时正文下移过渡(与顶栏动画同节奏)。 */
|
||||||
const MENU_SHIFT = 'transition-transform duration-200'
|
const MENU_SHIFT = 'transition-transform duration-200'
|
||||||
|
|
||||||
|
/** 翻页动画时长。正文列的平移过渡,以及跟手拖拽松手后的回弹/滑完,都用这一条。 */
|
||||||
|
const PAGE_TRANSITION = 'transform 220ms ease'
|
||||||
|
/** 跟手拖拽要翻页所需的最小位移(px)。 */
|
||||||
|
const DRAG_TURN_DISTANCE = 45
|
||||||
|
|
||||||
// 滚轮翻页参数(deltaY 已按 deltaMode 归一化成像素)
|
// 滚轮翻页参数(deltaY 已按 deltaMode 归一化成像素)
|
||||||
/** 单次 deltaY 达到这个量视为鼠标滚轮的一格(一格一页)。 */
|
/** 单次 deltaY 达到这个量视为鼠标滚轮的一格(一格一页)。 */
|
||||||
const WHEEL_NOTCH = 40
|
const WHEEL_NOTCH = 40
|
||||||
@@ -88,6 +93,25 @@ function firstReadableIndex(chapters: ReaderChapter[]): number {
|
|||||||
return j === -1 ? 0 : j
|
return j === -1 ? 0 : j
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 漫画跟手拖拽松手后「滑完这一页」。
|
||||||
|
*
|
||||||
|
* 舞台两侧各摆着相邻的一页,手指拖到哪儿舞台就平移到哪儿。松手要翻页时,
|
||||||
|
* 先把新页瞬移到手指离开的位置(这一步必须关掉过渡,否则会先反着滑一段),
|
||||||
|
* 读一次布局把这一帧定成过渡起点,再打开过渡滑回 0;与此同时页码 +1/-1,
|
||||||
|
* ReaderComic 那边单元格的 left 偏移在同一次提交里跟着换,两者相抵,
|
||||||
|
* 屏幕上看到的就是新页从手指离开的位置平滑归位。
|
||||||
|
*/
|
||||||
|
function settleComicDrag(el: HTMLDivElement, turn: 1 | -1, dx: number, turnPage: () => void): void {
|
||||||
|
const width = el.clientWidth || 1
|
||||||
|
el.style.transition = 'none'
|
||||||
|
el.style.transform = `translateX(${turn > 0 ? width + dx : -width + dx}px)`
|
||||||
|
void el.offsetWidth
|
||||||
|
el.style.transition = PAGE_TRANSITION
|
||||||
|
el.style.transform = 'translateX(0px)'
|
||||||
|
turnPage()
|
||||||
|
}
|
||||||
|
|
||||||
export default function ReaderViewPage() {
|
export default function ReaderViewPage() {
|
||||||
const { bookId = '' } = useParams()
|
const { bookId = '' } = useParams()
|
||||||
const settings = useReaderSettingsStore()
|
const settings = useReaderSettingsStore()
|
||||||
@@ -171,6 +195,13 @@ export default function ReaderViewPage() {
|
|||||||
comicDoublePage && comicSpreads.length > 0
|
comicDoublePage && comicSpreads.length > 0
|
||||||
? Math.min(comicSpreadIndexOf[comicPage] ?? 0, comicSpreads.length - 1)
|
? Math.min(comicSpreadIndexOf[comicPage] ?? 0, comicSpreads.length - 1)
|
||||||
: 0
|
: 0
|
||||||
|
/** 当前这一屏是不是并排两页(桌面端双页铺开)。 */
|
||||||
|
const comicPaired = comicDoublePage && (comicSpreads[comicSpreadIndex]?.length ?? 0) > 1
|
||||||
|
/**
|
||||||
|
* 漫画翻页模式且单页显示时接跟手拖拽:舞台左右各摆一页,手指横滑整条舞台跟着走。
|
||||||
|
* 并排两页时不接——两侧要摆的是「下一屏」而不是下一张,交给原来的滑动判定即可。
|
||||||
|
*/
|
||||||
|
const comicDragSinglePage = contentType === 'image' && settings.pageMode === 'page' && !comicPaired
|
||||||
|
|
||||||
// ── 漫画滚动模式的图片显示尺寸 ──
|
// ── 漫画滚动模式的图片显示尺寸 ──
|
||||||
// 档位只在上下滚动模式生效:翻页模式是整页缩放进视口,没有「太大/太小」的问题,
|
// 档位只在上下滚动模式生效:翻页模式是整页缩放进视口,没有「太大/太小」的问题,
|
||||||
@@ -636,11 +667,66 @@ export default function ReaderViewPage() {
|
|||||||
// (只能点左右),所以滚动模式改在滚动容器自身的 onClick 上按 x 坐标分区:
|
// (只能点左右),所以滚动模式改在滚动容器自身的 onClick 上按 x 坐标分区:
|
||||||
// 纵向拖动不会产生 click,浏览器原生滚动照常工作。
|
// 纵向拖动不会产生 click,浏览器原生滚动照常工作。
|
||||||
//
|
//
|
||||||
// 手机上再补一层横向滑动翻页:从左往右滑=上一页、从右往左滑=下一页。
|
// 手机上再补一层横向滑动:从左往右滑=上一页、从右往左滑=下一页,并且跟手——
|
||||||
// 滑动的判定与「滑动后浏览器补发的 click」的去重都在 useHorizontalSwipe 里。
|
// 滑动途中内容就跟着手指走,松手再决定翻页还是回弹(判定、跟手位移与
|
||||||
const { onTouchStart, onTouchEnd, onTouchCancel, consumeSwipe } = useHorizontalSwipe({
|
// 「滑动后浏览器补发的 click」的去重都在 useHorizontalSwipe 里)。
|
||||||
|
//
|
||||||
|
// 跟手拖拽平移的是两个不同的元素:
|
||||||
|
// - 文本:正文列外面单独挂一层「跟手层」(pageDragRef)。分页平移仍旧由 React 写在
|
||||||
|
// 里层,跟手层只管手指位移;松手时两层同时动——跟手位移归零 + 页码变化——
|
||||||
|
// 合起来就是一次连续滑动。分两层而不是直接改里层,是为了避免「跟手位移把 DOM
|
||||||
|
// 改成了 React 不知道的值、React 又认为 transform 没变而不去写」把页面留在拖动位置。
|
||||||
|
// - 漫画:平移 ReaderComic 的舞台(comicStageRef)。舞台两侧摆着上一页/下一页
|
||||||
|
// (见那边的 carousel 分支),翻页时单元格的 left 偏移与舞台位移在同一次提交里
|
||||||
|
// 相抵,画面看不出切换。
|
||||||
|
const pageDragRef = useRef<HTMLDivElement>(null)
|
||||||
|
const comicStageRef = useRef<HTMLDivElement>(null)
|
||||||
|
const pageDragEnabled = settings.pageMode === 'page' && (contentType === 'text' || comicDragSinglePage)
|
||||||
|
|
||||||
|
const onPageDrag = useCallback(
|
||||||
|
(dx: number) => {
|
||||||
|
const el = contentType === 'image' ? comicStageRef.current : pageDragRef.current
|
||||||
|
if (!el) return
|
||||||
|
el.style.transition = 'none'
|
||||||
|
el.style.transform = `translateX(${dx}px)`
|
||||||
|
},
|
||||||
|
[contentType],
|
||||||
|
)
|
||||||
|
|
||||||
|
const onPageDragEnd = useCallback(
|
||||||
|
(dx: number) => {
|
||||||
|
if (contentType === 'image') {
|
||||||
|
const el = comicStageRef.current
|
||||||
|
if (!el) return
|
||||||
|
if (dx <= -DRAG_TURN_DISTANCE) {
|
||||||
|
settleComicDrag(el, 1, dx, goNext)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (dx >= DRAG_TURN_DISTANCE) {
|
||||||
|
settleComicDrag(el, -1, dx, goPrev)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
el.style.transition = PAGE_TRANSITION
|
||||||
|
el.style.transform = 'translateX(0px)'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const el = pageDragRef.current
|
||||||
|
if (!el) return
|
||||||
|
// 跟手层归零与页码平移同时进行,合成一次连续滑动(位移不够时就是回弹)。
|
||||||
|
el.style.transition = PAGE_TRANSITION
|
||||||
|
el.style.transform = 'translateX(0px)'
|
||||||
|
if (dx <= -DRAG_TURN_DISTANCE) goNext()
|
||||||
|
else if (dx >= DRAG_TURN_DISTANCE) goPrev()
|
||||||
|
},
|
||||||
|
[contentType, goNext, goPrev],
|
||||||
|
)
|
||||||
|
|
||||||
|
const { onTouchStart, onTouchMove, onTouchEnd, onTouchCancel, consumeSwipe } = useHorizontalSwipe({
|
||||||
onSwipeRight: goPrev,
|
onSwipeRight: goPrev,
|
||||||
onSwipeLeft: goNext,
|
onSwipeLeft: goNext,
|
||||||
|
minDistance: DRAG_TURN_DISTANCE,
|
||||||
|
onDrag: pageDragEnabled ? onPageDrag : undefined,
|
||||||
|
onDragEnd: pageDragEnabled ? onPageDragEnd : undefined,
|
||||||
})
|
})
|
||||||
const handleZoneTap = useCallback(
|
const handleZoneTap = useCallback(
|
||||||
(clientX: number, rect: DOMRect) => {
|
(clientX: number, rect: DOMRect) => {
|
||||||
@@ -909,6 +995,8 @@ export default function ReaderViewPage() {
|
|||||||
page={comicPage}
|
page={comicPage}
|
||||||
imageFit={comicScrollFit}
|
imageFit={comicScrollFit}
|
||||||
spread={comicDoublePage ? (comicSpreads[comicSpreadIndex] ?? null) : null}
|
spread={comicDoublePage ? (comicSpreads[comicSpreadIndex] ?? null) : null}
|
||||||
|
draggable={comicDragSinglePage}
|
||||||
|
stageRef={comicStageRef}
|
||||||
onZone={(zone) => {
|
onZone={(zone) => {
|
||||||
if (zone === 'center') setMenuOpen((v) => !v)
|
if (zone === 'center') setMenuOpen((v) => !v)
|
||||||
}}
|
}}
|
||||||
@@ -934,20 +1022,23 @@ export default function ReaderViewPage() {
|
|||||||
style={menuShiftStyle}
|
style={menuShiftStyle}
|
||||||
>
|
>
|
||||||
<div ref={viewportRef} className="relative h-full overflow-hidden">
|
<div ref={viewportRef} className="relative h-full overflow-hidden">
|
||||||
<div
|
{/* 跟手层:跟手拖拽的位移写在这一层,分页平移仍旧写在里层(React 管)。 */}
|
||||||
ref={contentRef}
|
<div ref={pageDragRef} className="h-full">
|
||||||
className="h-full"
|
<div
|
||||||
style={{
|
ref={contentRef}
|
||||||
columnWidth: `${Math.max(vw, 1)}px`,
|
className="h-full"
|
||||||
columnGap: `${COLUMN_GAP}px`,
|
style={{
|
||||||
columnFill: 'auto',
|
columnWidth: `${Math.max(vw, 1)}px`,
|
||||||
transform: `translateX(-${page * (vw + COLUMN_GAP)}px)`,
|
columnGap: `${COLUMN_GAP}px`,
|
||||||
transition: 'transform 220ms ease',
|
columnFill: 'auto',
|
||||||
fontSize: settings.fontSize,
|
transform: `translateX(-${page * (vw + COLUMN_GAP)}px)`,
|
||||||
lineHeight: settings.lineHeight,
|
transition: PAGE_TRANSITION,
|
||||||
}}
|
fontSize: settings.fontSize,
|
||||||
>
|
lineHeight: settings.lineHeight,
|
||||||
{blocks.map((b, i) => renderParagraph(b, i))}
|
}}
|
||||||
|
>
|
||||||
|
{blocks.map((b, i) => renderParagraph(b, i))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1016,6 +1107,7 @@ export default function ReaderViewPage() {
|
|||||||
<div
|
<div
|
||||||
className="absolute inset-0 z-[1] grid grid-cols-[30%_40%_30%]"
|
className="absolute inset-0 z-[1] grid grid-cols-[30%_40%_30%]"
|
||||||
onTouchStart={onTouchStart}
|
onTouchStart={onTouchStart}
|
||||||
|
onTouchMove={onTouchMove}
|
||||||
onTouchEnd={onTouchEnd}
|
onTouchEnd={onTouchEnd}
|
||||||
onTouchCancel={onTouchCancel}
|
onTouchCancel={onTouchCancel}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user