mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-12 00:06:37 +08:00
添加点击2次全屏的功能
This commit is contained in:
@@ -104,6 +104,7 @@
|
|||||||
- **文本型漫画源按图片下发**(`internal/service/reader` 的 `imageMarkersOnly`):拷贝漫画这类书源 `bookSourceType=0`(文本),正文规则直接给 `<img>`,服务端把整行标签转成 `[img]<签名地址>` 标记行后类型仍是 `text`,前端就会走文本阅读器——分页把每张图当成一列,一屏只看得到一张,桌面端也谈不上双页铺开。现在 `GetContentForBook` / `LocalChapterContent` 在标记化之后判断「非空行全部是 `[img]` 标记」,是则把 `type` 改成 `image` 并给出 `images`,交由漫画阅读器渲染(整页缩放、双页铺开、进度按图片序号)。判定只认「全是标记」:混了正文的章节一律保持 `text`,孤立的 HTML 标签行(`<div>`/`</div>`)与空行当作无内容忽略,绝不吞掉可读文字。
|
- **文本型漫画源按图片下发**(`internal/service/reader` 的 `imageMarkersOnly`):拷贝漫画这类书源 `bookSourceType=0`(文本),正文规则直接给 `<img>`,服务端把整行标签转成 `[img]<签名地址>` 标记行后类型仍是 `text`,前端就会走文本阅读器——分页把每张图当成一列,一屏只看得到一张,桌面端也谈不上双页铺开。现在 `GetContentForBook` / `LocalChapterContent` 在标记化之后判断「非空行全部是 `[img]` 标记」,是则把 `type` 改成 `image` 并给出 `images`,交由漫画阅读器渲染(整页缩放、双页铺开、进度按图片序号)。判定只认「全是标记」:混了正文的章节一律保持 `text`,孤立的 HTML 标签行(`<div>`/`</div>`)与空行当作无内容忽略,绝不吞掉可读文字。
|
||||||
- 鼠标滚轮翻页(仅翻页模式):向上滚=上一页,向下滚=下一页;菜单打开时也不翻页。鼠标滚轮一格一页(间隔至少 220ms,与翻页动画对齐),触控板小步长累计到阈值翻一页且一次手势只翻一页(避免惯性连翻)。`ctrl/cmd+滚轮` 保留浏览器缩放。
|
- 鼠标滚轮翻页(仅翻页模式):向上滚=上一页,向下滚=下一页;菜单打开时也不翻页。鼠标滚轮一格一页(间隔至少 220ms,与翻页动画对齐),触控板小步长累计到阈值翻一页且一次手势只翻一页(避免惯性连翻)。`ctrl/cmd+滚轮` 保留浏览器缩放。
|
||||||
- **浏览器全屏(沉浸式)**:阅读页本身已经是 `fixed inset-0` 的独立布局(`/reader/*` 不套影视 Layout),所以屏幕上的留白只剩浏览器自带的地址栏/工具栏。底部动作行加「全屏 / 还原」,对 `readerRef` 调标准 Fullscreen API(`requestFullscreen` / `exitFullscreen`),进全屏前先收起菜单。按钮只在 `document.fullscreenEnabled === true` 时出现——**iOS 上的 Safari 至今不支持元素全屏**(只有 `<video>` 能全屏),所以那里不显示按钮,而不是显示一个点了没反应的死按钮;iPhone 想彻底去掉浏览器界面只能「添加到主屏幕」。离开阅读页时卸载 effect 主动 `exitFullscreen`,否则返回书架后浏览器仍停在全屏、整站都被罩住。全屏状态由 `fullscreenchange` 事件回写,不做乐观更新(用户按 Esc / 系统手势退出时也要跟着变)。
|
- **浏览器全屏(沉浸式)**:阅读页本身已经是 `fixed inset-0` 的独立布局(`/reader/*` 不套影视 Layout),所以屏幕上的留白只剩浏览器自带的地址栏/工具栏。底部动作行加「全屏 / 还原」,对 `readerRef` 调标准 Fullscreen API(`requestFullscreen` / `exitFullscreen`),进全屏前先收起菜单。按钮只在 `document.fullscreenEnabled === true` 时出现——**iOS 上的 Safari 至今不支持元素全屏**(只有 `<video>` 能全屏),所以那里不显示按钮,而不是显示一个点了没反应的死按钮;iPhone 想彻底去掉浏览器界面只能「添加到主屏幕」。离开阅读页时卸载 effect 主动 `exitFullscreen`,否则返回书架后浏览器仍停在全屏、整站都被罩住。全屏状态由 `fullscreenchange` 事件回写,不做乐观更新(用户按 Esc / 系统手势退出时也要跟着变)。
|
||||||
|
- **手机端双击中间区域进出全屏**(`hooks/useDoubleTap.ts` + `hooks/useIsTouchDevice.ts`):中间区域单击本来就是呼出菜单,现在双击再切一次全屏,两个动作在同一块屏幕上,得先等一小会儿看有没有第二下。`useDoubleTap` 把这段窗口做成纯函数 `decideDoubleTap`(第一下进 `tap` 窗口等第二下;第二下到达取消单击、改判双击),**双击回调在第二次点击的事件里同步执行**——浏览器只认用户手势上下文里的 `requestFullscreen`,挪进定时器再调会被直接拒绝。只在「触摸设备(`(hover: none)`)+ 支持元素全屏」时启用:桌面端根本不装这套,单击立刻生效,点中间呼出菜单的手感与以前完全一致;iOS 也不装(它不支持元素全屏)。三条中间点击路径(翻页模式的三分区覆盖层、文本滚动模式的 `handleZoneTap`、漫画滚动模式的 `onZone`)统一走 `centerTap()`,左右分区点击会 `cancel()` 掉待判定的单击,免得点完翻页又弹出菜单。菜单打开时整屏遮罩先吃掉那次点击,遮罩在中间区域关闭菜单的同时 `arm()` 一个双击窗口,「双击中间切全屏」在菜单开着时也成立。手机端再给阅读根节点挂 `touch-action: manipulation`,关掉浏览器自带的双击缩放(纵向滚动与双指缩放不受影响),避免同一手势被浏览器抢着缩一次。
|
||||||
- 目录:整屏面板(顶部返回 + 书名 + 章数,Virtuoso 虚拟列表,定位并高亮当前章,点章跳转)。**必须渲染在底部菜单之外**:菜单带 `backdrop-blur`,会成为 `fixed` 后代的包含块,放里面 `h-full` 只能拿到菜单高度;历史上它写的是 `top-0 + bottom-full`,两者同时存在时高度被算成 0,整块目录完全看不见。
|
- 目录:整屏面板(顶部返回 + 书名 + 章数,Virtuoso 虚拟列表,定位并高亮当前章,点章跳转)。**必须渲染在底部菜单之外**:菜单带 `backdrop-blur`,会成为 `fixed` 后代的包含块,放里面 `h-full` 只能拿到菜单高度;历史上它写的是 `top-0 + bottom-full`,两者同时存在时高度被算成 0,整块目录完全看不见。
|
||||||
|
|
||||||
**本地书籍(对应 legado 本地 TXT / EPUB)**
|
**本地书籍(对应 legado 本地 TXT / EPUB)**
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { decideDoubleTap } from './useDoubleTap.ts'
|
||||||
|
|
||||||
|
function check(name: string, condition: boolean) {
|
||||||
|
if (!condition) throw new Error(`useDoubleTap: ${name}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 空闲时的第一下:进入等待窗口,单击不立刻触发(留给定时器)。
|
||||||
|
const first = decideDoubleTap('none', 'tap')
|
||||||
|
check('first tap opens a window', first.window === 'tap')
|
||||||
|
check('first tap does not fire immediately', first.fire === null)
|
||||||
|
|
||||||
|
// 窗口里的第二下:判成双击并立刻触发(必须在事件里同步执行,见实现注释)。
|
||||||
|
const second = decideDoubleTap('tap', 'tap')
|
||||||
|
check('second tap fires double', second.fire === 'double')
|
||||||
|
check('second tap closes the window', second.window === 'none')
|
||||||
|
|
||||||
|
// 菜单遮罩关菜单:只开窗口,不触发单击,也不影响状态语义。
|
||||||
|
const armed = decideDoubleTap('none', 'arm')
|
||||||
|
check('arm opens a window', armed.window === 'armed')
|
||||||
|
check('arm never fires a single', armed.fire === null)
|
||||||
|
|
||||||
|
// 关掉菜单后紧接的第二下要算双击,否则菜单一开一关就永远进不了全屏。
|
||||||
|
check('tap after arm fires double', decideDoubleTap('armed', 'tap').fire === 'double')
|
||||||
|
|
||||||
|
// 已经在双击窗口里再 arm:续期,窗口保持 armed。
|
||||||
|
check('arm during window stays armed', decideDoubleTap('armed', 'arm').window === 'armed')
|
||||||
|
check('arm during tap window stays armed', decideDoubleTap('tap', 'arm').window === 'armed')
|
||||||
|
|
||||||
|
console.log('useDoubleTap.test.ts ok')
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
/** 两次点击间隔在这个毫秒数以内算一次双击。 */
|
||||||
|
export const DOUBLE_TAP_MS = 280
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 双击窗口的状态。
|
||||||
|
*
|
||||||
|
* - `none` 空闲,没有待判定的点击
|
||||||
|
* - `tap` 刚单击过一次,还等第二下;窗口期满仍没有第二下就触发单击
|
||||||
|
* - `armed` 只开了双击窗口(菜单遮罩关掉菜单时用),期满什么也不触发
|
||||||
|
*/
|
||||||
|
export type DoubleTapWindow = 'none' | 'tap' | 'armed'
|
||||||
|
|
||||||
|
/** 一次动作立刻要触发的效果;null 表示只更新窗口状态、不立刻触发(留给定时器)。 */
|
||||||
|
export type DoubleTapFire = 'single' | 'double' | null
|
||||||
|
|
||||||
|
export interface DoubleTapDecision {
|
||||||
|
window: DoubleTapWindow
|
||||||
|
fire: DoubleTapFire
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 双击窗口的纯决策(不碰计时器,便于单测):给定当前窗口状态和这次动作,
|
||||||
|
* 得出窗口进入什么状态、要不要立刻触发回调。
|
||||||
|
*
|
||||||
|
* `tap` = 用户点了一下中间区域;`arm` = 只开窗口不算点击(关菜单时调用)。
|
||||||
|
*/
|
||||||
|
export function decideDoubleTap(window: DoubleTapWindow, action: 'tap' | 'arm'): DoubleTapDecision {
|
||||||
|
if (action === 'arm') {
|
||||||
|
// 只开窗口:已在窗口里就续期,否则新开一个 armed 窗口,永远不触发单击。
|
||||||
|
return { window: 'armed', fire: null }
|
||||||
|
}
|
||||||
|
// 已经有窗口(无论是单击待定的 tap 还是刚 arm 的)→ 第二下到达,判成双击。
|
||||||
|
if (window !== 'none') return { window: 'none', fire: 'double' }
|
||||||
|
// 第一下:进入 tap 窗口等第二下,单击由窗口期满的定时器触发。
|
||||||
|
return { window: 'tap', fire: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 中间区域的「单击 / 双击」识别(手机上用来区分呼出菜单和进出全屏)。
|
||||||
|
*
|
||||||
|
* 中间区域原先单击就是呼出菜单,现在双击还要切全屏,两个动作落在同一块屏幕上,
|
||||||
|
* 就得先等一小会儿看有没有第二下:第一下把窗口置为 `tap`,第二下在窗口内到达就
|
||||||
|
* 取消单击、改判双击。**双击回调必须在第二次点击的事件里同步执行**——浏览器只认
|
||||||
|
* 用户手势上下文里的 `requestFullscreen`,挪进定时器再调会被直接拒绝。
|
||||||
|
*
|
||||||
|
* 返回三个入口:
|
||||||
|
* - `tap()`:分区点击。`enabled` 为假(桌面端,或 iOS 这种不支持元素全屏的浏览器)
|
||||||
|
* 时不做任何延迟,单击立刻生效,端上呼出菜单的手感与改动前完全一致。
|
||||||
|
* - `arm()`:只开一个双击窗口、不排单击。菜单打开时整屏遮罩会先吃掉那次点击
|
||||||
|
* (见 ReaderViewPage 的关闭遮罩),遮罩把菜单关掉的同时调它,紧跟着的第二下
|
||||||
|
* 才能落进窗口被判成双击,而不是又当成单击把菜单重新打开。
|
||||||
|
* - `cancel()`:丢掉待判定的点击。用户点了中间又立刻去翻页时,别让那次单击
|
||||||
|
* 在窗口期满时把菜单弹出来。
|
||||||
|
*/
|
||||||
|
export function useDoubleTap({
|
||||||
|
onDoubleTap,
|
||||||
|
onSingleTap,
|
||||||
|
enabled,
|
||||||
|
delay = DOUBLE_TAP_MS,
|
||||||
|
}: {
|
||||||
|
onDoubleTap: () => void
|
||||||
|
onSingleTap: () => void
|
||||||
|
enabled: boolean
|
||||||
|
delay?: number
|
||||||
|
}): { tap: () => void; arm: () => void; cancel: () => void } {
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const windowRef = useRef<DoubleTapWindow>('none')
|
||||||
|
// 回调每次渲染都可能是新闭包,用 ref 兜住,免得已排队的定时器跟着旧闭包一起失效。
|
||||||
|
const doubleRef = useRef(onDoubleTap)
|
||||||
|
const singleRef = useRef(onSingleTap)
|
||||||
|
useEffect(() => {
|
||||||
|
doubleRef.current = onDoubleTap
|
||||||
|
singleRef.current = onSingleTap
|
||||||
|
})
|
||||||
|
|
||||||
|
const clear = useCallback(() => {
|
||||||
|
if (timerRef.current !== null) {
|
||||||
|
clearTimeout(timerRef.current)
|
||||||
|
timerRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/** 取消待判定的点击并回到空闲。 */
|
||||||
|
const cancel = useCallback(() => {
|
||||||
|
clear()
|
||||||
|
windowRef.current = 'none'
|
||||||
|
}, [clear])
|
||||||
|
|
||||||
|
/** 开一个双击窗口:期满后,tap 窗口触发单击,armed 窗口什么也不做。 */
|
||||||
|
const startWindow = useCallback(
|
||||||
|
(kind: DoubleTapWindow) => {
|
||||||
|
clear()
|
||||||
|
windowRef.current = kind
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
timerRef.current = null
|
||||||
|
const wasTap = windowRef.current === 'tap'
|
||||||
|
windowRef.current = 'none'
|
||||||
|
if (wasTap) singleRef.current()
|
||||||
|
}, delay)
|
||||||
|
},
|
||||||
|
[clear, delay],
|
||||||
|
)
|
||||||
|
|
||||||
|
// 卸载时清掉待执行的单击,避免离开页面后还蹦一下。
|
||||||
|
useEffect(() => cancel, [cancel])
|
||||||
|
// 双击被关掉(如切到桌面布局)时,丢弃已排队的点击。
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) cancel()
|
||||||
|
}, [enabled, cancel])
|
||||||
|
|
||||||
|
const tap = useCallback(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
singleRef.current()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const decision = decideDoubleTap(windowRef.current, 'tap')
|
||||||
|
if (decision.fire === 'double') {
|
||||||
|
// 第二下:取消待定的单击,改判双击(同步执行,保住用户手势上下文)。
|
||||||
|
cancel()
|
||||||
|
doubleRef.current()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
startWindow(decision.window)
|
||||||
|
}, [enabled, cancel, startWindow])
|
||||||
|
|
||||||
|
const arm = useCallback(() => {
|
||||||
|
if (!enabled) return
|
||||||
|
startWindow(decideDoubleTap(windowRef.current, 'arm').window)
|
||||||
|
}, [enabled, startWindow])
|
||||||
|
|
||||||
|
return { tap, arm, cancel }
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
// 按设备能力判断「手机/平板」:没有真正的鼠标悬停能力的就是触摸设备。
|
||||||
|
// 应用里已有同样的做法(PlayerControls / Vr360Stage 的 isHoverlessDevice),
|
||||||
|
// 这里做成 hook:媒体查询在字体缩放、外接鼠标插拔、chrome 设备模拟时会变化,
|
||||||
|
// 跟随 change 事件回写,比渲染时读一次更稳。
|
||||||
|
const HOVERLESS_QUERY = '(hover: none)'
|
||||||
|
|
||||||
|
function queryHoverless(): boolean {
|
||||||
|
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false
|
||||||
|
return window.matchMedia(HOVERLESS_QUERY).matches
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前设备是否是触摸设备(手机/平板)。桌面端(带鼠标)返回 false。 */
|
||||||
|
export function useIsTouchDevice(): boolean {
|
||||||
|
const [touch, setTouch] = useState(queryHoverless)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return
|
||||||
|
const media = window.matchMedia(HOVERLESS_QUERY)
|
||||||
|
const onChange = () => setTouch(media.matches)
|
||||||
|
onChange()
|
||||||
|
media.addEventListener('change', onChange)
|
||||||
|
return () => media.removeEventListener('change', onChange)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return touch
|
||||||
|
}
|
||||||
@@ -252,8 +252,10 @@ export function ReaderComic({ images, theme, mode, page, onZone, initialImage, o
|
|||||||
const rect = e.currentTarget.getBoundingClientRect()
|
const rect = e.currentTarget.getBoundingClientRect()
|
||||||
const x = (e.clientX - rect.left) / rect.width
|
const x = (e.clientX - rect.left) / rect.width
|
||||||
if (x < 0.3) {
|
if (x < 0.3) {
|
||||||
|
onZone('left')
|
||||||
scrollRef.current?.scrollBy({ top: -window.innerHeight * 0.9, behavior: 'auto' })
|
scrollRef.current?.scrollBy({ top: -window.innerHeight * 0.9, behavior: 'auto' })
|
||||||
} else if (x > 0.7) {
|
} else if (x > 0.7) {
|
||||||
|
onZone('right')
|
||||||
scrollRef.current?.scrollBy({ top: window.innerHeight * 0.9, behavior: 'auto' })
|
scrollRef.current?.scrollBy({ top: window.innerHeight * 0.9, behavior: 'auto' })
|
||||||
} else {
|
} else {
|
||||||
onZone('center')
|
onZone('center')
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ import {
|
|||||||
type ReaderSearchOrigin,
|
type ReaderSearchOrigin,
|
||||||
} from '../../api/reader'
|
} from '../../api/reader'
|
||||||
import { useComicSpreads } from '../../hooks/useComicSpreads'
|
import { useComicSpreads } from '../../hooks/useComicSpreads'
|
||||||
|
import { useDoubleTap } from '../../hooks/useDoubleTap'
|
||||||
import { useHorizontalSwipe } from '../../hooks/useHorizontalSwipe'
|
import { useHorizontalSwipe } from '../../hooks/useHorizontalSwipe'
|
||||||
|
import { useIsTouchDevice } from '../../hooks/useIsTouchDevice'
|
||||||
import { useSmoothWheelScroll } from '../../hooks/useSmoothWheelScroll'
|
import { useSmoothWheelScroll } from '../../hooks/useSmoothWheelScroll'
|
||||||
import { useReaderAudioStore } from '../../stores/readerAudio'
|
import { useReaderAudioStore } from '../../stores/readerAudio'
|
||||||
import { COMIC_IMAGE_FITS, READER_THEMES, getReaderTheme, useReaderSettingsStore } from '../../stores/readerSettings'
|
import { COMIC_IMAGE_FITS, READER_THEMES, getReaderTheme, useReaderSettingsStore } from '../../stores/readerSettings'
|
||||||
@@ -384,6 +386,19 @@ export default function ReaderViewPage() {
|
|||||||
void el.requestFullscreen?.().catch(() => {})
|
void el.requestFullscreen?.().catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// ── 手机端:中间区域双击进出全屏 ──
|
||||||
|
// 只在「触摸设备 + 浏览器支持元素全屏」时启用:桌面端不装这套(点击不带任何延迟,
|
||||||
|
// 保持原来的操作手感),iOS Safari 不支持元素全屏(见上)也照旧只留单击呼出菜单。
|
||||||
|
// 区分单击/双击的等待与「第二下同步调 requestFullscreen」的约束都在 useDoubleTap 里。
|
||||||
|
const touchDevice = useIsTouchDevice()
|
||||||
|
const doubleTapFullscreen = fullscreenSupported && touchDevice
|
||||||
|
const { tap: centerTap, arm: armCenterDoubleTap, cancel: cancelCenterTap } = useDoubleTap({
|
||||||
|
// 单击仍是呼出/收起菜单,双击改成进出浏览器全屏。
|
||||||
|
onSingleTap: () => setMenuOpen((v) => !v),
|
||||||
|
onDoubleTap: toggleFullscreen,
|
||||||
|
enabled: doubleTapFullscreen,
|
||||||
|
})
|
||||||
|
|
||||||
const comicImages = contentType === 'image' && media?.images ? media.images : NO_IMAGES
|
const comicImages = contentType === 'image' && media?.images ? media.images : NO_IMAGES
|
||||||
const comicDoublePage =
|
const comicDoublePage =
|
||||||
contentType === 'image' &&
|
contentType === 'image' &&
|
||||||
@@ -1221,11 +1236,18 @@ export default function ReaderViewPage() {
|
|||||||
const handleZoneTap = useCallback(
|
const handleZoneTap = useCallback(
|
||||||
(clientX: number, rect: DOMRect) => {
|
(clientX: number, rect: DOMRect) => {
|
||||||
const x = rect.width > 0 ? (clientX - rect.left) / rect.width : 0.5
|
const x = rect.width > 0 ? (clientX - rect.left) / rect.width : 0.5
|
||||||
if (x < 0.3) goPrev()
|
if (x < 0.3) {
|
||||||
else if (x > 0.7) goNext()
|
cancelCenterTap()
|
||||||
else setMenuOpen((v) => !v)
|
goPrev()
|
||||||
|
} else if (x > 0.7) {
|
||||||
|
cancelCenterTap()
|
||||||
|
goNext()
|
||||||
|
} else {
|
||||||
|
// 中间单击=菜单、双击=全屏,区分逻辑在 useDoubleTap(桌面端原样单击)。
|
||||||
|
centerTap()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[goPrev, goNext],
|
[goPrev, goNext, centerTap, cancelCenterTap],
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── 键盘(桌面端) ──
|
// ── 键盘(桌面端) ──
|
||||||
@@ -1420,7 +1442,13 @@ export default function ReaderViewPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={readerRef} className="fixed inset-0 z-40 flex flex-col" style={{ backgroundColor: theme.bg, color: theme.text }}>
|
<div
|
||||||
|
ref={readerRef}
|
||||||
|
className="fixed inset-0 z-40 flex flex-col"
|
||||||
|
// 手机上双击中间切全屏时,别让浏览器把自己的「双击缩放」也做一遍:
|
||||||
|
// touch-action:manipulation 关掉双击缩放,纵向滚动与双指缩放照常。
|
||||||
|
style={{ backgroundColor: theme.bg, color: theme.text, touchAction: doubleTapFullscreen ? 'manipulation' : undefined }}
|
||||||
|
>
|
||||||
{/* 正文视口 */}
|
{/* 正文视口 */}
|
||||||
<div className="relative flex-1 overflow-hidden">
|
<div className="relative flex-1 overflow-hidden">
|
||||||
<div className={`mx-auto h-full w-full ${comicFullWidth ? '' : 'max-w-[900px]'}`}>
|
<div className={`mx-auto h-full w-full ${comicFullWidth ? '' : 'max-w-[900px]'}`}>
|
||||||
@@ -1443,7 +1471,9 @@ export default function ReaderViewPage() {
|
|||||||
draggable={comicDragSinglePage}
|
draggable={comicDragSinglePage}
|
||||||
stageRef={comicStageRef}
|
stageRef={comicStageRef}
|
||||||
onZone={(zone) => {
|
onZone={(zone) => {
|
||||||
if (zone === 'center') setMenuOpen((v) => !v)
|
if (zone === 'center') centerTap()
|
||||||
|
// 点了左右分区(翻屏)就丢掉待判定的中间单击,别让它在窗口期满时弹菜单。
|
||||||
|
else cancelCenterTap()
|
||||||
}}
|
}}
|
||||||
initialImage={restorePos}
|
initialImage={restorePos}
|
||||||
onProgress={(idx) => {
|
onProgress={(idx) => {
|
||||||
@@ -1571,6 +1601,7 @@ export default function ReaderViewPage() {
|
|||||||
aria-label="上一页"
|
aria-label="上一页"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (consumeSwipe()) return
|
if (consumeSwipe()) return
|
||||||
|
cancelCenterTap()
|
||||||
goPrev()
|
goPrev()
|
||||||
}}
|
}}
|
||||||
className="cursor-w-resize"
|
className="cursor-w-resize"
|
||||||
@@ -1580,7 +1611,8 @@ export default function ReaderViewPage() {
|
|||||||
aria-label="菜单"
|
aria-label="菜单"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (consumeSwipe()) return
|
if (consumeSwipe()) return
|
||||||
setMenuOpen((v) => !v)
|
// 中间单击=菜单、双击=全屏,区分逻辑在 useDoubleTap(桌面端原样单击)。
|
||||||
|
centerTap()
|
||||||
}}
|
}}
|
||||||
className="cursor-default"
|
className="cursor-default"
|
||||||
/>
|
/>
|
||||||
@@ -1589,6 +1621,7 @@ export default function ReaderViewPage() {
|
|||||||
aria-label="下一页"
|
aria-label="下一页"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (consumeSwipe()) return
|
if (consumeSwipe()) return
|
||||||
|
cancelCenterTap()
|
||||||
goNext()
|
goNext()
|
||||||
}}
|
}}
|
||||||
className="cursor-e-resize"
|
className="cursor-e-resize"
|
||||||
@@ -1614,7 +1647,16 @@ export default function ReaderViewPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label="关闭菜单"
|
aria-label="关闭菜单"
|
||||||
className="fixed inset-0 z-40 cursor-default bg-black/30"
|
className="fixed inset-0 z-40 cursor-default bg-black/30"
|
||||||
onClick={() => setMenuOpen(false)}
|
onClick={(e) => {
|
||||||
|
// 关菜单要立刻生效,不套单击延迟。
|
||||||
|
setMenuOpen(false)
|
||||||
|
// 但若这一下关在中间区域,就顺手开个双击窗口:菜单开着时双击中间
|
||||||
|
// 也能切全屏(第二下落在刚关掉菜单的正文上,见 useDoubleTap.arm)。
|
||||||
|
// 点在左右三分区(本意是翻页)时不开窗口,免得被下一拍误判成双击。
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect()
|
||||||
|
const x = rect.width > 0 ? (e.clientX - rect.left) / rect.width : 0.5
|
||||||
|
if (x >= 0.3 && x <= 0.7) armCenterDoubleTap()
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{/* 顶栏 */}
|
{/* 顶栏 */}
|
||||||
<div
|
<div
|
||||||
|
|||||||
Reference in New Issue
Block a user