This commit is contained in:
truewhile
2026-10-08 17:42:07 +08:00
parent 99916c0b97
commit d7665e4ef0
4 changed files with 226 additions and 32 deletions
+47 -7
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useRef, useState, type RefObject } from 'react'
import { Loader2 } from 'lucide-react'
import { useSmoothWheelScroll } from '../../hooks/useSmoothWheelScroll'
@@ -27,6 +27,13 @@ interface ReaderComicProps {
spread?: number[] | null
/** 滚动模式的图片显示尺寸档位(翻页模式不适用,传 default 即可)。 */
imageFit?: ComicImageFit
/**
* 翻页模式的跟手拖拽:把当前页左右各摆一页到舞台两侧,整条舞台由外层按
* stageRef 直接平移(见 ReaderViewPage 的 onPageDrag)。并排铺开两页时关闭。
*/
draggable?: boolean
/** 跟手拖拽要平移的舞台元素(由外层拿着直接写 transform,不走 React state)。 */
stageRef?: RefObject<HTMLDivElement>
}
function ComicImage({
@@ -36,6 +43,7 @@ function ComicImage({
half = false,
imageFit = 'default',
viewportHeight = 0,
eager = false,
}: {
src: string
theme: { bg: string; text: string; accent: string }
@@ -47,6 +55,10 @@ function ComicImage({
imageFit?: ComicImageFit
// viewportHeight:滚动容器高度(px),height/long 档位按它换算。
viewportHeight?: number
// eager:跟手拖拽摆在舞台两侧的那两页。它们被 overflow-hidden 裁在屏幕外,
// 浏览器的懒加载认为「离视口还远」而不去取,横滑过去就会看到转圈,
// 所以这两页立刻加载(顺带把下一页提前拉好,翻页不用现等)。
eager?: boolean
}) {
const [state, setState] = useState<'loading' | 'ok' | 'error'>('loading')
@@ -82,7 +94,7 @@ function ComicImage({
>
<img
src={src}
loading="lazy"
loading={eager ? 'eager' : 'lazy'}
alt=""
onLoad={(e) => {
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 imgRefs = useRef<(HTMLDivElement | null)[]>([])
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 idxs = group.length > 0 ? group : [Math.min(Math.max(page, 0), images.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 (
<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 className="relative h-full w-full overflow-hidden">
<div ref={stageRef} className="relative h-full w-full">
{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>
)
}
+109 -17
View File
@@ -55,6 +55,11 @@ const COMIC_SAVE_INTERVAL_MS = 2_000
/** 菜单打开时正文下移过渡(与顶栏动画同节奏)。 */
const MENU_SHIFT = 'transition-transform duration-200'
/** 翻页动画时长。正文列的平移过渡,以及跟手拖拽松手后的回弹/滑完,都用这一条。 */
const PAGE_TRANSITION = 'transform 220ms ease'
/** 跟手拖拽要翻页所需的最小位移(px)。 */
const DRAG_TURN_DISTANCE = 45
// 滚轮翻页参数(deltaY 已按 deltaMode 归一化成像素)
/** 单次 deltaY 达到这个量视为鼠标滚轮的一格(一格一页)。 */
const WHEEL_NOTCH = 40
@@ -88,6 +93,25 @@ function firstReadableIndex(chapters: ReaderChapter[]): number {
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() {
const { bookId = '' } = useParams()
const settings = useReaderSettingsStore()
@@ -171,6 +195,13 @@ export default function ReaderViewPage() {
comicDoublePage && comicSpreads.length > 0
? Math.min(comicSpreadIndexOf[comicPage] ?? 0, comicSpreads.length - 1)
: 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 坐标分区:
// 纵向拖动不会产生 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,
onSwipeLeft: goNext,
minDistance: DRAG_TURN_DISTANCE,
onDrag: pageDragEnabled ? onPageDrag : undefined,
onDragEnd: pageDragEnabled ? onPageDragEnd : undefined,
})
const handleZoneTap = useCallback(
(clientX: number, rect: DOMRect) => {
@@ -909,6 +995,8 @@ export default function ReaderViewPage() {
page={comicPage}
imageFit={comicScrollFit}
spread={comicDoublePage ? (comicSpreads[comicSpreadIndex] ?? null) : null}
draggable={comicDragSinglePage}
stageRef={comicStageRef}
onZone={(zone) => {
if (zone === 'center') setMenuOpen((v) => !v)
}}
@@ -934,20 +1022,23 @@ export default function ReaderViewPage() {
style={menuShiftStyle}
>
<div ref={viewportRef} className="relative h-full overflow-hidden">
<div
ref={contentRef}
className="h-full"
style={{
columnWidth: `${Math.max(vw, 1)}px`,
columnGap: `${COLUMN_GAP}px`,
columnFill: 'auto',
transform: `translateX(-${page * (vw + COLUMN_GAP)}px)`,
transition: 'transform 220ms ease',
fontSize: settings.fontSize,
lineHeight: settings.lineHeight,
}}
>
{blocks.map((b, i) => renderParagraph(b, i))}
{/* 跟手层:跟手拖拽的位移写在这一层,分页平移仍旧写在里层(React 管)。 */}
<div ref={pageDragRef} className="h-full">
<div
ref={contentRef}
className="h-full"
style={{
columnWidth: `${Math.max(vw, 1)}px`,
columnGap: `${COLUMN_GAP}px`,
columnFill: 'auto',
transform: `translateX(-${page * (vw + COLUMN_GAP)}px)`,
transition: PAGE_TRANSITION,
fontSize: settings.fontSize,
lineHeight: settings.lineHeight,
}}
>
{blocks.map((b, i) => renderParagraph(b, i))}
</div>
</div>
</div>
</div>
@@ -1016,6 +1107,7 @@ export default function ReaderViewPage() {
<div
className="absolute inset-0 z-[1] grid grid-cols-[30%_40%_30%]"
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
onTouchCancel={onTouchCancel}
>