From a313644e4e656ccb18811ae3e0a1ea3b1527d3eb Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sat, 28 Mar 2026 18:38:08 +0000 Subject: [PATCH 1/3] [#623] Add book-style page flip + swipe gesture navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace slide+fade with 3D rotateY page flip animation (400ms) using CSS perspective and transform-origin for authentic book feel - Add touch swipe navigation: swipe left → next, swipe right → prev with 50px threshold and horizontal dominance check to avoid interfering with vertical scrolling Fixes #623 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/globals.css | 33 ++++++++++++++++++++++++--------- src/components/ReadingMode.tsx | 22 ++++++++++++++++++++-- 2 files changed, 44 insertions(+), 11 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 53adebfc..4bd1a353 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -210,20 +210,35 @@ code, pre, .font-mono { } } -/* Reading Mode page-flip transitions */ -@keyframes flip-in-left { - from { opacity: 0; transform: translateX(40px); } - to { opacity: 1; transform: translateX(0); } +/* Reading Mode page-flip transitions (3D book-style) */ +.page-flip-container { + perspective: 1200px; } -@keyframes flip-in-right { - from { opacity: 0; transform: translateX(-40px); } - to { opacity: 1; transform: translateX(0); } + +@keyframes flip-forward { + 0% { transform: rotateY(0deg); opacity: 1; } + 100% { transform: rotateY(-90deg); opacity: 0; } +} +@keyframes flip-backward { + 0% { transform: rotateY(0deg); opacity: 1; } + 100% { transform: rotateY(90deg); opacity: 0; } +} +@keyframes flip-in-from-right { + 0% { transform: rotateY(90deg); opacity: 0; } + 100% { transform: rotateY(0deg); opacity: 1; } } +@keyframes flip-in-from-left { + 0% { transform: rotateY(-90deg); opacity: 0; } + 100% { transform: rotateY(0deg); opacity: 1; } +} + .page-flip-left { - animation: flip-in-left 0.25s ease-out; + transform-origin: left center; + animation: flip-in-from-right 400ms ease-in-out; } .page-flip-right { - animation: flip-in-right 0.25s ease-out; + transform-origin: right center; + animation: flip-in-from-left 400ms ease-in-out; } /* Custom select dropdown styling */ diff --git a/src/components/ReadingMode.tsx b/src/components/ReadingMode.tsx index 67e90a5c..84700922 100644 --- a/src/components/ReadingMode.tsx +++ b/src/components/ReadingMode.tsx @@ -29,6 +29,8 @@ export function ReadingMode({ const [showToc, setShowToc] = useState(false); const [flipDir, setFlipDir] = useState<"left" | "right" | null>(null); const contentRef = useRef(null); + const touchStartX = useRef(0); + const touchStartY = useRef(0); const { isMiniApp } = usePlatformDetection(); const chapter = chapters[currentIdx]; @@ -46,7 +48,7 @@ export function ReadingMode({ setCurrentIdx(idx); scrollToTop(); // Clear the animation class after transition completes - setTimeout(() => setFlipDir(null), 250); + setTimeout(() => setFlipDir(null), 400); }); }, [scrollToTop]); @@ -110,7 +112,23 @@ export function ReadingMode({ {/* Content area */} -
+
{ + touchStartX.current = e.touches[0].clientX; + touchStartY.current = e.touches[0].clientY; + }} + onTouchEnd={(e) => { + const dx = e.changedTouches[0].clientX - touchStartX.current; + const dy = e.changedTouches[0].clientY - touchStartY.current; + // Only trigger if horizontal swipe exceeds threshold and is more horizontal than vertical + if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5) { + if (dx < 0) goNext(); + else goPrev(); + } + }} + >
From 8a26da79e38676348a6d7700972de55e12d0a152 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sat, 28 Mar 2026 18:39:50 +0000 Subject: [PATCH 2/3] [#623] Remove unused flip-forward/flip-backward keyframes Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/globals.css | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 4bd1a353..8376d5a8 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -215,14 +215,6 @@ code, pre, .font-mono { perspective: 1200px; } -@keyframes flip-forward { - 0% { transform: rotateY(0deg); opacity: 1; } - 100% { transform: rotateY(-90deg); opacity: 0; } -} -@keyframes flip-backward { - 0% { transform: rotateY(0deg); opacity: 1; } - 100% { transform: rotateY(90deg); opacity: 0; } -} @keyframes flip-in-from-right { 0% { transform: rotateY(90deg); opacity: 0; } 100% { transform: rotateY(0deg); opacity: 1; } From 4e13669a66066661a699d4d38c4b6618e226f8c1 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sat, 28 Mar 2026 18:41:14 +0000 Subject: [PATCH 3/3] [#623] Two-phase page flip: outgoing page flips out before incoming flips in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split animation into flip-out (200ms) → content swap → flip-in (200ms) so the old page visibly turns away before the new page appears. Added a flipping guard to prevent rapid-fire navigation during animation. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/globals.css | 26 ++++++++++++++++++++++---- src/components/ReadingMode.tsx | 26 ++++++++++++++++++++------ 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 8376d5a8..651b7283 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -215,6 +215,14 @@ code, pre, .font-mono { perspective: 1200px; } +@keyframes flip-out-forward { + 0% { transform: rotateY(0deg); opacity: 1; } + 100% { transform: rotateY(-90deg); opacity: 0; } +} +@keyframes flip-out-backward { + 0% { transform: rotateY(0deg); opacity: 1; } + 100% { transform: rotateY(90deg); opacity: 0; } +} @keyframes flip-in-from-right { 0% { transform: rotateY(90deg); opacity: 0; } 100% { transform: rotateY(0deg); opacity: 1; } @@ -224,13 +232,23 @@ code, pre, .font-mono { 100% { transform: rotateY(0deg); opacity: 1; } } -.page-flip-left { +/* Outgoing page: flips away */ +.page-flip-out-left { + transform-origin: left center; + animation: flip-out-forward 200ms ease-in forwards; +} +.page-flip-out-right { + transform-origin: right center; + animation: flip-out-backward 200ms ease-in forwards; +} +/* Incoming page: flips into view */ +.page-flip-in-left { transform-origin: left center; - animation: flip-in-from-right 400ms ease-in-out; + animation: flip-in-from-right 200ms ease-out; } -.page-flip-right { +.page-flip-in-right { transform-origin: right center; - animation: flip-in-from-left 400ms ease-in-out; + animation: flip-in-from-left 200ms ease-out; } /* Custom select dropdown styling */ diff --git a/src/components/ReadingMode.tsx b/src/components/ReadingMode.tsx index 84700922..f9a7ec91 100644 --- a/src/components/ReadingMode.tsx +++ b/src/components/ReadingMode.tsx @@ -28,9 +28,11 @@ 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 contentRef = useRef(null); const touchStartX = useRef(0); const touchStartY = useRef(0); + const flipping = useRef(false); const { isMiniApp } = usePlatformDetection(); const chapter = chapters[currentIdx]; @@ -42,14 +44,22 @@ 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 setFlipDir(dir); - // Brief delay to trigger the CSS animation before content swap - requestAnimationFrame(() => { + setFlipPhase("out"); + setTimeout(() => { + // Phase 2: swap content and flip in the new page setCurrentIdx(idx); scrollToTop(); - // Clear the animation class after transition completes - setTimeout(() => setFlipDir(null), 400); - }); + setFlipPhase("in"); + setTimeout(() => { + setFlipDir(null); + setFlipPhase(null); + flipping.current = false; + }, 200); + }, 200); }, [scrollToTop]); const goPrev = useCallback(() => { @@ -130,7 +140,11 @@ export function ReadingMode({ }} >
{chapter?.content ? (