diff --git a/src/app/globals.css b/src/app/globals.css index 651b7283..8ce10920 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -210,45 +210,63 @@ code, pre, .font-mono { } } -/* Reading Mode page-flip transitions (3D book-style) */ +/* Reading Mode — two-phase book page turn */ .page-flip-container { - perspective: 1200px; + perspective: 1500px; } -@keyframes flip-out-forward { - 0% { transform: rotateY(0deg); opacity: 1; } - 100% { transform: rotateY(-90deg); opacity: 0; } +.page-flip-stack { + position: relative; + min-height: 100%; } -@keyframes flip-out-backward { - 0% { transform: rotateY(0deg); opacity: 1; } - 100% { transform: rotateY(90deg); opacity: 0; } + +.page-flip-page { + position: relative; + will-change: transform, opacity; } -@keyframes flip-in-from-right { - 0% { transform: rotateY(90deg); opacity: 0; } - 100% { transform: rotateY(0deg); opacity: 1; } + +/* Outgoing page stacks on top and flips away */ +.page-outgoing { + position: absolute; + inset: 0; + z-index: 2; + backface-visibility: hidden; + overflow: hidden; } -@keyframes flip-in-from-left { - 0% { transform: rotateY(-90deg); opacity: 0; } - 100% { transform: rotateY(0deg); opacity: 1; } + +/* Incoming page: subtle fade-in from underneath */ +.page-incoming { + animation: page-reveal 500ms ease-out; } -/* Outgoing page: flips away */ -.page-flip-out-left { - transform-origin: left center; - animation: flip-out-forward 200ms ease-in forwards; +@keyframes page-reveal { + 0% { opacity: 0.4; } + 60% { opacity: 0.8; } + 100% { opacity: 1; } } -.page-flip-out-right { - transform-origin: right center; - animation: flip-out-backward 200ms ease-in forwards; + +/* Next: outgoing page flips from right edge (like turning a book page forward) */ +@keyframes flip-out-forward { + 0% { transform: rotateY(0deg); box-shadow: -4px 0 16px rgba(0,0,0,0.1); } + 40% { box-shadow: -10px 0 30px rgba(0,0,0,0.2); } + 100% { transform: rotateY(-120deg); box-shadow: none; } +} + +/* Prev: outgoing page flips from left edge (like turning a book page backward) */ +@keyframes flip-out-backward { + 0% { transform: rotateY(0deg); box-shadow: 4px 0 16px rgba(0,0,0,0.1); } + 40% { box-shadow: 10px 0 30px rgba(0,0,0,0.2); } + 100% { transform: rotateY(120deg); box-shadow: none; } } -/* Incoming page: flips into view */ -.page-flip-in-left { + +.page-flip-out-left { transform-origin: left center; - animation: flip-in-from-right 200ms ease-out; + animation: flip-out-forward 500ms ease-in forwards; } -.page-flip-in-right { + +.page-flip-out-right { transform-origin: right center; - animation: flip-in-from-left 200ms ease-out; + animation: flip-out-backward 500ms ease-in forwards; } /* Custom select dropdown styling */ diff --git a/src/components/ReadingMode.tsx b/src/components/ReadingMode.tsx index f9a7ec91..31cc9d2b 100644 --- a/src/components/ReadingMode.tsx +++ b/src/components/ReadingMode.tsx @@ -28,14 +28,17 @@ export function ReadingMode({ const [currentIdx, setCurrentIdx] = useState(initialChapterIndex); const [showToc, setShowToc] = useState(false); const [flipDir, setFlipDir] = useState<"left" | "right" | null>(null); - const [flipPhase, setFlipPhase] = useState<"out" | "in" | null>(null); + const [outgoingIdx, setOutgoingIdx] = useState(null); + const [outgoingScroll, setOutgoingScroll] = useState(0); const contentRef = useRef(null); + const stackRef = useRef(null); const touchStartX = useRef(0); const touchStartY = useRef(0); const flipping = useRef(false); const { isMiniApp } = usePlatformDetection(); const chapter = chapters[currentIdx]; + const outgoingChapter = outgoingIdx !== null ? chapters[outgoingIdx] : null; const hasPrev = currentIdx > 0; const hasNext = currentIdx < chapters.length - 1; @@ -46,21 +49,28 @@ export function ReadingMode({ const navigate = useCallback((idx: number, dir: "left" | "right" | null) => { if (flipping.current) return; flipping.current = true; - // Phase 1: flip out the current page + // Capture scroll offset so the outgoing page can render at its old position + const scrollOffset = contentRef.current?.scrollTop ?? 0; + setOutgoingScroll(scrollOffset); + // Freeze container height so layout is stable during the flip + if (stackRef.current) { + stackRef.current.style.minHeight = `${stackRef.current.offsetHeight}px`; + } + // Set outgoing, swap to incoming, and scroll to top immediately + setOutgoingIdx(currentIdx); setFlipDir(dir); - setFlipPhase("out"); + setCurrentIdx(idx); + scrollToTop(); + // After animation: unfreeze height, clean up setTimeout(() => { - // Phase 2: swap content and flip in the new page - setCurrentIdx(idx); - scrollToTop(); - setFlipPhase("in"); - setTimeout(() => { - setFlipDir(null); - setFlipPhase(null); - flipping.current = false; - }, 200); - }, 200); - }, [scrollToTop]); + if (stackRef.current) { + stackRef.current.style.minHeight = ""; + } + setOutgoingIdx(null); + setFlipDir(null); + flipping.current = false; + }, 500); + }, [currentIdx, scrollToTop]); const goPrev = useCallback(() => { if (hasPrev) navigate(currentIdx - 1, "right"); @@ -139,17 +149,37 @@ export function ReadingMode({ } }} > -
- {chapter?.content ? ( - - ) : ( -

Content unavailable

+
+ {/* Incoming page (underneath) */} +
+
+ {chapter?.content ? ( + + ) : ( +

Content unavailable

+ )} +
+
+ + {/* Outgoing page (on top, flipping away at its old scroll offset) */} + {outgoingChapter && flipDir && ( +
+
+ {outgoingChapter.content ? ( + + ) : ( +

Content unavailable

+ )} +
+
)}