manual save(2026-01-23 14:38)
This commit is contained in:
328
src/components/FlipBook.tsx
Normal file
328
src/components/FlipBook.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
type FlipBookSheet = {
|
||||
id: string
|
||||
front: React.ReactNode
|
||||
back?: React.ReactNode
|
||||
}
|
||||
|
||||
type FlipBookProps = {
|
||||
title: string
|
||||
sheets: FlipBookSheet[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
const TRANSITION_MS = 720
|
||||
|
||||
function usePrefersReducedMotion() {
|
||||
const [reduced, setReduced] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
const update = () => setReduced(media.matches)
|
||||
update()
|
||||
|
||||
media.addEventListener('change', update)
|
||||
return () => media.removeEventListener('change', update)
|
||||
}, [])
|
||||
|
||||
return reduced
|
||||
}
|
||||
|
||||
const PageHalf: React.FC<{ side: 'left' | 'right'; children?: React.ReactNode }> = ({ side, children }) => {
|
||||
const isLeft = side === 'left'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'absolute inset-y-0 w-1/2 overflow-hidden',
|
||||
isLeft ? 'left-0 rounded-l-[22px]' : 'left-1/2 rounded-r-[22px]',
|
||||
].join(' ')}
|
||||
>
|
||||
<div
|
||||
className={[
|
||||
'h-full',
|
||||
'bg-[color-mix(in_srgb,var(--tf-bg)_55%,white)]',
|
||||
'shadow-[inset_0_1px_0_rgba(255,255,255,0.65)]',
|
||||
isLeft
|
||||
? 'shadow-[inset_-18px_0_40px_rgba(0,0,0,0.06),inset_0_1px_0_rgba(255,255,255,0.65)]'
|
||||
: 'shadow-[inset_18px_0_40px_rgba(0,0,0,0.06),inset_0_1px_0_rgba(255,255,255,0.65)]',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="h-full p-[clamp(18px,2.2vw,28px)]">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SheetSide: React.FC<{
|
||||
side: 'front' | 'back'
|
||||
contentSide: 'left' | 'right'
|
||||
children?: React.ReactNode
|
||||
}> = ({ side, contentSide, children }) => {
|
||||
const isBack = side === 'back'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'absolute inset-0',
|
||||
'[backface-visibility:hidden]',
|
||||
'rounded-[22px]',
|
||||
'bg-transparent',
|
||||
isBack ? '[transform:rotateY(180deg)]' : '',
|
||||
].join(' ')}
|
||||
>
|
||||
<PageHalf side={contentSide}>{children}</PageHalf>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Sheet: React.FC<{
|
||||
sheet: FlipBookSheet
|
||||
index: number
|
||||
sheetCount: number
|
||||
shouldBeFlipped: boolean
|
||||
isTurning: boolean
|
||||
transitionMs: number
|
||||
}> = ({ sheet, index, sheetCount, shouldBeFlipped, isTurning, transitionMs }) => {
|
||||
const zIndex = isTurning ? sheetCount + 10 : sheetCount - index
|
||||
const clipPath = useMemo(() => {
|
||||
if (isTurning) return undefined
|
||||
if (shouldBeFlipped) return 'inset(0 50% 0 0)'
|
||||
return 'inset(0 0 0 50%)'
|
||||
}, [isTurning, shouldBeFlipped])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'absolute inset-0',
|
||||
'[transform-style:preserve-3d]',
|
||||
'rounded-[22px]',
|
||||
'transition-transform',
|
||||
].join(' ')}
|
||||
style={{
|
||||
zIndex,
|
||||
transitionDuration: `${transitionMs}ms`,
|
||||
transitionTimingFunction: 'cubic-bezier(0.2, 0.9, 0.2, 1)',
|
||||
transformOrigin: '50% 50%',
|
||||
transform: shouldBeFlipped ? 'rotateY(-180deg)' : 'rotateY(0deg)',
|
||||
clipPath,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<SheetSide side="front" contentSide="right">
|
||||
{sheet.front}
|
||||
</SheetSide>
|
||||
<SheetSide side="back" contentSide="left">
|
||||
{sheet.back ?? sheet.front}
|
||||
</SheetSide>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const FlipBook: React.FC<FlipBookProps> = ({ title, sheets, className }) => {
|
||||
const reducedMotion = usePrefersReducedMotion()
|
||||
const transitionMs = reducedMotion ? 0 : TRANSITION_MS
|
||||
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [turning, setTurning] = useState<null | { index: number; direction: 'next' | 'prev' }>(null)
|
||||
|
||||
const busy = Boolean(turning)
|
||||
const canPrev = !busy && currentIndex > 0
|
||||
const canNext = !busy && currentIndex < sheets.length - 1
|
||||
|
||||
const swipeStartX = useRef<number | null>(null)
|
||||
|
||||
const scheduleTurn = useCallback(
|
||||
(action: () => void) => {
|
||||
if (transitionMs === 0) {
|
||||
action()
|
||||
setTurning(null)
|
||||
return
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
action()
|
||||
setTurning(null)
|
||||
}, transitionMs)
|
||||
},
|
||||
[transitionMs],
|
||||
)
|
||||
|
||||
const turnToNext = useCallback(() => {
|
||||
if (!canNext) return
|
||||
setTurning({ index: currentIndex, direction: 'next' })
|
||||
scheduleTurn(() => {
|
||||
setCurrentIndex((value) => Math.min(sheets.length - 1, value + 1))
|
||||
})
|
||||
}, [canNext, currentIndex, scheduleTurn, sheets.length])
|
||||
|
||||
const turnToPrev = useCallback(() => {
|
||||
if (!canPrev) return
|
||||
setTurning({ index: currentIndex - 1, direction: 'prev' })
|
||||
scheduleTurn(() => {
|
||||
setCurrentIndex((value) => Math.max(0, value - 1))
|
||||
})
|
||||
}, [canPrev, currentIndex, scheduleTurn])
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'ArrowRight' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
turnToNext()
|
||||
}
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault()
|
||||
turnToPrev()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [turnToNext, turnToPrev])
|
||||
|
||||
const onPointerDown: React.PointerEventHandler<HTMLDivElement> = (event) => {
|
||||
swipeStartX.current = event.clientX
|
||||
}
|
||||
|
||||
const onPointerUp: React.PointerEventHandler<HTMLDivElement> = (event) => {
|
||||
const startX = swipeStartX.current
|
||||
swipeStartX.current = null
|
||||
if (startX === null) return
|
||||
|
||||
const deltaX = event.clientX - startX
|
||||
if (Math.abs(deltaX) < 34) return
|
||||
|
||||
if (deltaX < 0) {
|
||||
turnToNext()
|
||||
return
|
||||
}
|
||||
|
||||
turnToPrev()
|
||||
}
|
||||
|
||||
const progressLabel = useMemo(() => {
|
||||
const page = Math.min(currentIndex + 1, sheets.length)
|
||||
return `${page} / ${sheets.length}`
|
||||
}, [currentIndex, sheets.length])
|
||||
|
||||
const currentSheet = sheets[currentIndex]
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={title}
|
||||
className={[
|
||||
'w-full',
|
||||
'max-w-[980px]',
|
||||
'mx-auto',
|
||||
'select-none',
|
||||
className ?? '',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="text-center">
|
||||
<h1 className="font-[var(--tf-font-serif)] text-[clamp(32px,4.2vw,54px)] leading-[1.08] tracking-tight">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="mt-4 text-sm text-[var(--tf-text-subtle)]">点击翻页,或使用键盘 ← / →,移动端可左右滑动。</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 flex items-center justify-center">
|
||||
<div
|
||||
className={[
|
||||
'relative',
|
||||
'w-[min(92vw,940px)]',
|
||||
'h-[min(68vh,560px)]',
|
||||
'[perspective:1800px]',
|
||||
].join(' ')}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
<div
|
||||
className={[
|
||||
'absolute inset-0 rounded-[26px]',
|
||||
'bg-[color-mix(in_srgb,var(--tf-bg)_42%,white)]',
|
||||
'shadow-[0_26px_80px_rgba(0,0,0,0.14)]',
|
||||
'border border-[color-mix(in_srgb,var(--tf-border)_70%,white)]',
|
||||
].join(' ')}
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-y-[22px] left-1/2 w-px bg-[color-mix(in_srgb,var(--tf-border)_80%,white)]"
|
||||
/>
|
||||
|
||||
<div
|
||||
className={[
|
||||
'absolute inset-0 z-[60] transition-opacity',
|
||||
busy ? 'opacity-0 pointer-events-none' : 'opacity-100',
|
||||
].join(' ')}
|
||||
>
|
||||
<PageHalf side="left">{currentSheet?.back ?? currentSheet?.front}</PageHalf>
|
||||
<PageHalf side="right">{currentSheet?.front}</PageHalf>
|
||||
</div>
|
||||
|
||||
{sheets
|
||||
.map((sheet, index) => {
|
||||
const flippedAlready = index < currentIndex
|
||||
const shouldBeFlipped =
|
||||
turning?.index === index
|
||||
? turning.direction === 'next'
|
||||
: flippedAlready
|
||||
|
||||
const isTurning = turning?.index === index
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
key={sheet.id}
|
||||
sheet={sheet}
|
||||
index={index}
|
||||
sheetCount={sheets.length}
|
||||
shouldBeFlipped={shouldBeFlipped}
|
||||
isTurning={isTurning}
|
||||
transitionMs={transitionMs}
|
||||
/>
|
||||
)
|
||||
})
|
||||
.reverse()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center justify-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={turnToPrev}
|
||||
disabled={!canPrev}
|
||||
className={[
|
||||
'inline-flex items-center justify-center rounded-full border px-4 py-2 text-sm transition',
|
||||
'border-[var(--tf-border)] bg-[color-mix(in_srgb,var(--tf-bg)_50%,white)]',
|
||||
canPrev
|
||||
? 'text-[var(--tf-text)] hover:bg-[color-mix(in_srgb,var(--tf-bg)_34%,white)]'
|
||||
: 'text-[var(--tf-text-subtle)] opacity-60 cursor-not-allowed',
|
||||
].join(' ')}
|
||||
aria-label="上一页"
|
||||
>
|
||||
← Prev
|
||||
</button>
|
||||
|
||||
<div className="min-w-[88px] text-center text-xs font-[var(--tf-font-ui)] tracking-wide text-[var(--tf-text-subtle)]">
|
||||
{progressLabel}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={turnToNext}
|
||||
disabled={!canNext}
|
||||
className={[
|
||||
'inline-flex items-center justify-center rounded-full border px-4 py-2 text-sm transition',
|
||||
'border-[var(--tf-border)] bg-[color-mix(in_srgb,var(--tf-bg)_50%,white)]',
|
||||
canNext
|
||||
? 'text-[var(--tf-text)] hover:bg-[color-mix(in_srgb,var(--tf-bg)_34%,white)]'
|
||||
: 'text-[var(--tf-text-subtle)] opacity-60 cursor-not-allowed',
|
||||
].join(' ')}
|
||||
aria-label="下一页"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { Footer } from './Footer'
|
||||
|
||||
export const SiteLayout: React.FC = () => {
|
||||
const location = useLocation()
|
||||
const isHome = location.pathname === '/'
|
||||
|
||||
useEffect(() => {
|
||||
if (location.hash) {
|
||||
@@ -21,11 +22,11 @@ export const SiteLayout: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--tf-bg)] text-[var(--tf-text)]">
|
||||
<Header />
|
||||
{isHome ? null : <Header />}
|
||||
<main id="main" className="bg-[var(--tf-bg)]">
|
||||
<Outlet />
|
||||
</main>
|
||||
<Footer />
|
||||
{isHome ? null : <Footer />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user