diff --git a/docs/reader-ui-spec.md b/docs/reader-ui-spec.md index 22499d3..8d3c816 100644 --- a/docs/reader-ui-spec.md +++ b/docs/reader-ui-spec.md @@ -95,6 +95,7 @@ - 菜单打开时正文整体下移一个顶栏高度(用 `transform`,不改视口高度、不触发重新分页),顶栏不再压住开头 1–2 行。 - 界面面板的字号/行距/段距三个调节组用 `flex-wrap`,窄屏自动折行,不会把「段距」挤出屏幕。 - 点击分区:翻页模式渲染一层 `absolute inset-0` 的三分区按钮(左 30% 上一页 / 中 40% 呼出菜单 / 右 30% 下一页);**滚动模式不能渲染这层覆盖层**——它不是滚动容器的子节点,手机上手指落在覆盖层按钮上时浏览器找不到可滚动的祖先,纵向滑动完全失效、只剩点左右能翻屏。滚动模式改为在滚动容器自身的 `onClick` 上按 x 坐标分区(`handleZoneTap`),手指拖动不会产生 click,原生纵向滚动照常。 +- 手机横向滑动翻页(`hooks/useHorizontalSwipe.ts`):翻页模式(文本/漫画)在三分区覆盖层上再接一层触摸手势——**从左往右滑=上一页、从右往左滑=下一页**。判定要同时满足「水平位移 ≥45px」「水平位移 ≥ 垂直位移的 1.2 倍」「手势时长 ≤800ms」,所以短距离仍按点击分区处理、纵向拖动不会被误判成翻页。滑动后浏览器可能仍补发一次 click 落在原分区按钮上(会「滑一页又点一页」翻两页),用 `consumeSwipe()` 让三个按钮的 `onClick` 先判断这次 click 是否已被滑动吃掉;标记在每次 `touchstart` 重置,所以真正滑动不产生 click 时也不会吞掉下一次点击。滑动本身复用既有的 220ms 平移动画,不额外做跟手拖拽。 - 滚动模式的滚轮(`useSmoothWheelScroll`):把滚轮格数累加成一个目标位置,再逐帧向它逼近,滚动连续、松手后自己滑行一段,仿手机上下滑动,而不是浏览器整格跳变。只在文本/漫画的上下滚动模式接管;翻页模式仍走下面的翻页监听器。按真实帧间隔换算逼近比例,120Hz 屏上速度不会翻倍;`ctrl/cmd+滚轮` 保留缩放;手指按下(滑动、拖滚动条、点分区)立刻停掉动画把控制权交还用户。 - 漫画双页铺开(`hooks/useComicSpreads.ts` + `utils/comicSpread.ts`,legado 无对应项,是桌面端补充):窗口宽度 ≥900px 且漫画处于翻页模式时,一屏并排显示两页(`settings.comicDoublePage` 为设备级偏好,默认开,可在菜单里关;窗口不够宽时自动退回单页)。分组规则同 Tachiyomi 的 dual page——宽度明显大于高度(比值 ≥1.1)的图视为「跨页宽图」独占一屏,其余连续的竖版页两两配对;列表接口只给 URL,尺寸靠 `new Image()` 探测(`` 渲染时顺带回填),只探测当前页往后 8 张的窗口,避免整章预载。进度仍按图片序号记,翻页时换算成「第几屏」,所以关掉再开双页进度不会错位。菜单动作行改为 `flex` 等分,容纳「双页/单页」入口(`grid-cols-N` 得写死列数字面量,多一个按钮无法复用)。 - 漫画图片显示尺寸(`utils/comicImageFit.ts`,仅上下滚动模式):legado 的漫画阅读有「缩放」,Web 版原先只有一种写死的宽度(正文列封顶 900px),桌面端放不大也缩不小,所以补上一组档位,在界面面板的「图片尺寸」一行切换。`default` 就是老样子(900px 居中),`width` 适应宽度(正文列不再封顶、铺满窗口),`height` 适应高度(一屏一页),`long` 适应长边(整页完整可见),`original` 原图(原始像素 1:1,超出部分横向滚动)。`default`/`width` 由外层列宽决定,其余三个由 `` 自身约束,**所以后三种必须放开 900px 列宽上限**,否则会被列再压回去。`height`/`long` 要按「一屏多高」换算,所以量的是滚动容器本身而不是 `window.innerHeight`(正文区是 root 里的 `flex-1`,另挂上下条之后就不等于窗口高度了),并用 `ResizeObserver` 跟随;只在启用了自尺寸档位时才挂观察器,默认档位不白白重渲染整章。`settings.comicImageFit` 是设备级偏好(不同屏幕和源分辨率合适的尺寸不同),和 `comicDoublePage` 一样只存本机、不参与账号同步。翻页模式是整页缩放进视口,本来就不存在「太大/太小」,档位对它不生效、也不显示入口。`ctrl/cmd+滚轮` 依然留给浏览器缩放,本功能只用档位做粗调。 diff --git a/web/src/hooks/useHorizontalSwipe.ts b/web/src/hooks/useHorizontalSwipe.ts new file mode 100644 index 0000000..b7ab39f --- /dev/null +++ b/web/src/hooks/useHorizontalSwipe.ts @@ -0,0 +1,85 @@ +import { useCallback, useRef, type TouchEvent as ReactTouchEvent } from 'react' + +// 横向滑动手势:给「左右翻页」的阅读区补上手机上的滑动手势。 +// 原来只能点左 30% / 右 30% 分区翻页,手指横滑没有任何反应,翻页很别扭。 +// +// 判定规则(和纵向滚动共存的关键): +// - 位移要够长(minDistance),短距离当点击处理,交给下面的分区点击; +// - 横向位移要明显大于纵向位移(dominance 倍),否则算纵向滑动/滚动,不翻页; +// - 手势时长不能太长(maxDuration),慢慢拖过去不算一次滑动。 +// +// 一次滑动之后浏览器仍可能补一个 click(落在原来的分区按钮上), +// 于是会「滑一页又点一页」翻两页。用 consumeSwipe() 让点击回调先问一句: +// 这次 click 是不是刚被滑动吃掉,是则跳过。每次 touchstart 会重置这个标记, +// 所以真实滑动不产生 click 时也不会误吞下一次点击。 + +interface HorizontalSwipeOptions { + /** 从左往右滑(手指向右移)。 */ + onSwipeRight: () => void + /** 从右往左滑(手指向左移)。 */ + onSwipeLeft: () => void + /** 触发翻页所需的最小水平位移(px)。 */ + minDistance?: number + /** 水平位移需达到垂直位移的这个倍数才算横滑,避免和纵向滚动打架。 */ + dominance?: number + /** 手势超过这个时长不算滑动(毫秒)。 */ + maxDuration?: number +} + +interface HorizontalSwipeHandlers { + onTouchStart: (e: ReactTouchEvent) => void + onTouchEnd: (e: ReactTouchEvent) => void + onTouchCancel: () => void + /** 点击回调里先调用;返回 true 表示这次点击已被滑动消费,应跳过默认动作。 */ + consumeSwipe: () => boolean +} + +export function useHorizontalSwipe({ + onSwipeRight, + onSwipeLeft, + minDistance = 45, + dominance = 1.2, + maxDuration = 800, +}: HorizontalSwipeOptions): HorizontalSwipeHandlers { + const startRef = useRef<{ x: number; y: number; t: number } | null>(null) + const consumedRef = useRef(false) + + const onTouchStart = useCallback((e: ReactTouchEvent) => { + const t = e.touches[0] + if (!t) return + startRef.current = { x: t.clientX, y: t.clientY, t: Date.now() } + consumedRef.current = false + }, []) + + const onTouchEnd = useCallback( + (e: ReactTouchEvent) => { + const start = startRef.current + startRef.current = null + const t = e.changedTouches[0] + if (!start || !t) return + + const dx = t.clientX - start.x + const dy = t.clientY - start.y + if (Date.now() - start.t > maxDuration) return + if (Math.abs(dx) < minDistance) return + if (Math.abs(dx) < Math.abs(dy) * dominance) return + + consumedRef.current = true + if (dx > 0) onSwipeRight() + else onSwipeLeft() + }, + [onSwipeRight, onSwipeLeft, minDistance, dominance, maxDuration], + ) + + const onTouchCancel = useCallback(() => { + startRef.current = null + }, []) + + const consumeSwipe = useCallback(() => { + if (!consumedRef.current) return false + consumedRef.current = false + return true + }, []) + + return { onTouchStart, onTouchEnd, onTouchCancel, consumeSwipe } +} diff --git a/web/src/pages/reader/ReaderViewPage.tsx b/web/src/pages/reader/ReaderViewPage.tsx index 9bb64e8..a086e3a 100644 --- a/web/src/pages/reader/ReaderViewPage.tsx +++ b/web/src/pages/reader/ReaderViewPage.tsx @@ -28,6 +28,7 @@ import { type ReaderSearchOrigin, } from '../../api/reader' import { useComicSpreads } from '../../hooks/useComicSpreads' +import { useHorizontalSwipe } from '../../hooks/useHorizontalSwipe' import { useSmoothWheelScroll } from '../../hooks/useSmoothWheelScroll' import { useReaderAudioStore } from '../../stores/readerAudio' import { COMIC_IMAGE_FITS, READER_THEMES, getReaderTheme, useReaderSettingsStore } from '../../stores/readerSettings' @@ -634,6 +635,13 @@ export default function ReaderViewPage() { // 覆盖层不是滚动容器的子节点,手机上手指落在它上面会让纵向滑动失效 // (只能点左右),所以滚动模式改在滚动容器自身的 onClick 上按 x 坐标分区: // 纵向拖动不会产生 click,浏览器原生滚动照常工作。 + // + // 手机上再补一层横向滑动翻页:从左往右滑=上一页、从右往左滑=下一页。 + // 滑动的判定与「滑动后浏览器补发的 click」的去重都在 useHorizontalSwipe 里。 + const { onTouchStart, onTouchEnd, onTouchCancel, consumeSwipe } = useHorizontalSwipe({ + onSwipeRight: goPrev, + onSwipeLeft: goNext, + }) const handleZoneTap = useCallback( (clientX: number, rect: DOMRect) => { const x = rect.width > 0 ? (clientX - rect.left) / rect.width : 0.5 @@ -1005,15 +1013,39 @@ export default function ReaderViewPage() { 原生纵向滚动(手指落在覆盖层上时找不到可滚动的祖先节点), 所以滚动模式由正文容器的 onClick 分区(见 handleZoneTap)。 */} {settings.pageMode === 'page' && (contentType === 'text' || contentType === 'image') && ( -
-
)}