feat: add mobile gesture navigation system
Implemented comprehensive mobile gesture support for the Bible reader: **Swipe Gestures:** - Swipe left/right to navigate between chapters - Only activates on mobile devices (touch events) - Configurable 50px minimum swipe distance - Prevents scrolling interference **Tap Zones:** - Left 25% of screen: navigate to previous chapter - Right 25% of screen: navigate to next chapter - Center 50%: normal reading interaction - Maintains text selection capabilities **Smooth Page Transitions:** - Fade and scale animation on chapter navigation - 300ms duration with ease-in-out timing - Visual feedback: opacity 0.5 and scale 0.98 during transition - Applied to all navigation methods (swipe, tap, keyboard, buttons) **Settings Controls:** - Enable/disable swipe gestures toggle - Enable/disable tap zones toggle - Pagination mode toggle (for future enhancement) - All settings persist in localStorage **Dependencies:** - Added react-swipeable v7.0.2 for gesture handling - Zero-dependency, lightweight (peer deps: React only) **User Experience:** - Settings grouped under "Mobile Navigation" section - Default enabled for optimal mobile UX - Touch-optimized for tablets and phones - Desktop users can disable if desired This completes all mobile navigation features from Phase 1. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import { OfflineDownloadManager } from '@/components/bible/offline-download-mana
|
|||||||
import { OfflineBibleReader } from '@/components/bible/offline-bible-reader'
|
import { OfflineBibleReader } from '@/components/bible/offline-bible-reader'
|
||||||
import { offlineStorage } from '@/lib/offline-storage'
|
import { offlineStorage } from '@/lib/offline-storage'
|
||||||
import { InstallPrompt, useInstallPrompt } from '@/components/pwa/install-prompt'
|
import { InstallPrompt, useInstallPrompt } from '@/components/pwa/install-prompt'
|
||||||
|
import { useSwipeable } from 'react-swipeable'
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Typography,
|
Typography,
|
||||||
@@ -137,6 +138,9 @@ interface ReadingPreferences {
|
|||||||
wordSpacing: number // 0-4px range for word spacing
|
wordSpacing: number // 0-4px range for word spacing
|
||||||
paragraphSpacing: number // 1.0-2.5x line height for paragraph spacing
|
paragraphSpacing: number // 1.0-2.5x line height for paragraph spacing
|
||||||
maxLineLength: number // 50-100 characters (ch units) for optimal reading width
|
maxLineLength: number // 50-100 characters (ch units) for optimal reading width
|
||||||
|
enableSwipeGestures: boolean // Enable swipe left/right for chapter navigation
|
||||||
|
enableTapZones: boolean // Enable tap zones (left=prev, right=next)
|
||||||
|
paginationMode: boolean // Page-by-page vs continuous scroll
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultPreferences: ReadingPreferences = {
|
const defaultPreferences: ReadingPreferences = {
|
||||||
@@ -150,7 +154,10 @@ const defaultPreferences: ReadingPreferences = {
|
|||||||
letterSpacing: 0.5, // 0.5px default (WCAG 2.1 SC 1.4.12 recommends 0.12em)
|
letterSpacing: 0.5, // 0.5px default (WCAG 2.1 SC 1.4.12 recommends 0.12em)
|
||||||
wordSpacing: 0, // 0px default (browser default is optimal)
|
wordSpacing: 0, // 0px default (browser default is optimal)
|
||||||
paragraphSpacing: 1.8, // 1.8x line spacing (WCAG recommends ≥1.5x)
|
paragraphSpacing: 1.8, // 1.8x line spacing (WCAG recommends ≥1.5x)
|
||||||
maxLineLength: 75 // 75ch optimal reading width (50-75 for desktop)
|
maxLineLength: 75, // 75ch optimal reading width (50-75 for desktop)
|
||||||
|
enableSwipeGestures: true, // Enable by default for mobile
|
||||||
|
enableTapZones: true, // Enable by default for mobile
|
||||||
|
paginationMode: false // Continuous scroll by default
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BibleReaderProps {
|
interface BibleReaderProps {
|
||||||
@@ -238,6 +245,9 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
|
|||||||
const [readingProgress, setReadingProgress] = useState<any>(null)
|
const [readingProgress, setReadingProgress] = useState<any>(null)
|
||||||
const [hasLoadedInitialProgress, setHasLoadedInitialProgress] = useState(false)
|
const [hasLoadedInitialProgress, setHasLoadedInitialProgress] = useState(false)
|
||||||
|
|
||||||
|
// Page transition state
|
||||||
|
const [isTransitioning, setIsTransitioning] = useState(false)
|
||||||
|
|
||||||
// Note dialog state
|
// Note dialog state
|
||||||
const [noteDialog, setNoteDialog] = useState<{
|
const [noteDialog, setNoteDialog] = useState<{
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -944,6 +954,10 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handlePreviousChapter = () => {
|
const handlePreviousChapter = () => {
|
||||||
|
// Trigger transition animation
|
||||||
|
setIsTransitioning(true)
|
||||||
|
setTimeout(() => setIsTransitioning(false), 300) // Match CSS transition duration
|
||||||
|
|
||||||
if (selectedChapter > 1) {
|
if (selectedChapter > 1) {
|
||||||
const newChapter = selectedChapter - 1
|
const newChapter = selectedChapter - 1
|
||||||
setSelectedChapter(newChapter)
|
setSelectedChapter(newChapter)
|
||||||
@@ -961,6 +975,10 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleNextChapter = () => {
|
const handleNextChapter = () => {
|
||||||
|
// Trigger transition animation
|
||||||
|
setIsTransitioning(true)
|
||||||
|
setTimeout(() => setIsTransitioning(false), 300) // Match CSS transition duration
|
||||||
|
|
||||||
if (selectedChapter < maxChapters) {
|
if (selectedChapter < maxChapters) {
|
||||||
const newChapter = selectedChapter + 1
|
const newChapter = selectedChapter + 1
|
||||||
setSelectedChapter(newChapter)
|
setSelectedChapter(newChapter)
|
||||||
@@ -976,6 +994,41 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Swipe handlers for mobile navigation
|
||||||
|
const swipeHandlers = useSwipeable({
|
||||||
|
onSwipedLeft: () => {
|
||||||
|
if (preferences.enableSwipeGestures && isMobile) {
|
||||||
|
handleNextChapter()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSwipedRight: () => {
|
||||||
|
if (preferences.enableSwipeGestures && isMobile) {
|
||||||
|
handlePreviousChapter()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
preventScrollOnSwipe: false,
|
||||||
|
trackMouse: false, // Only track touch, not mouse
|
||||||
|
delta: 50 // Minimum swipe distance in pixels
|
||||||
|
})
|
||||||
|
|
||||||
|
// Tap zone handler for quick navigation
|
||||||
|
const handleTapZone = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!preferences.enableTapZones || !isMobile) return
|
||||||
|
|
||||||
|
const target = event.currentTarget
|
||||||
|
const rect = target.getBoundingClientRect()
|
||||||
|
const clickX = event.clientX - rect.left
|
||||||
|
const tapZoneWidth = rect.width * 0.25 // 25% on each side
|
||||||
|
|
||||||
|
if (clickX < tapZoneWidth) {
|
||||||
|
// Left tap zone - previous chapter
|
||||||
|
handlePreviousChapter()
|
||||||
|
} else if (clickX > rect.width - tapZoneWidth) {
|
||||||
|
// Right tap zone - next chapter
|
||||||
|
handleNextChapter()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleChapterBookmark = async () => {
|
const handleChapterBookmark = async () => {
|
||||||
if (!selectedBook || !selectedChapter) return
|
if (!selectedBook || !selectedChapter) return
|
||||||
|
|
||||||
@@ -1993,6 +2046,41 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
|
|||||||
}
|
}
|
||||||
label={t('readingMode')}
|
label={t('readingMode')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
<Typography variant="subtitle2" gutterBottom sx={{ fontWeight: 600 }}>
|
||||||
|
Mobile Navigation
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={preferences.enableSwipeGestures}
|
||||||
|
onChange={(e) => setPreferences(prev => ({ ...prev, enableSwipeGestures: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Enable Swipe Gestures"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={preferences.enableTapZones}
|
||||||
|
onChange={(e) => setPreferences(prev => ({ ...prev, enableTapZones: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Enable Tap Zones"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={preferences.paginationMode}
|
||||||
|
onChange={(e) => setPreferences(prev => ({ ...prev, paginationMode: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Pagination Mode"
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
@@ -2058,13 +2146,18 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
|
|||||||
|
|
||||||
{/* Reading Content */}
|
{/* Reading Content */}
|
||||||
<Box
|
<Box
|
||||||
|
{...swipeHandlers}
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
|
onClick={handleTapZone}
|
||||||
sx={{
|
sx={{
|
||||||
maxWidth: preferences.columnLayout ? 'none' : '800px',
|
maxWidth: preferences.columnLayout ? 'none' : '800px',
|
||||||
mx: 'auto',
|
mx: 'auto',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
minHeight: '60vh', // Prevent layout shifts
|
minHeight: '60vh', // Prevent layout shifts
|
||||||
position: 'relative'
|
position: 'relative',
|
||||||
|
cursor: preferences.enableTapZones && isMobile ? 'pointer' : 'default',
|
||||||
|
userSelect: 'text', // Ensure text selection still works
|
||||||
|
WebkitUserSelect: 'text'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Paper
|
<Paper
|
||||||
@@ -2075,7 +2168,13 @@ export default function BibleReaderNew({ initialVersion, initialBook, initialCha
|
|||||||
p: preferences.readingMode ? 4 : 3,
|
p: preferences.readingMode ? 4 : 3,
|
||||||
minHeight: preferences.readingMode ? '100vh' : '60vh', // Consistent minimum height
|
minHeight: preferences.readingMode ? '100vh' : '60vh', // Consistent minimum height
|
||||||
border: preferences.readingMode ? 'none' : `1px solid ${getThemeStyles().borderColor}`,
|
border: preferences.readingMode ? 'none' : `1px solid ${getThemeStyles().borderColor}`,
|
||||||
position: 'relative'
|
position: 'relative',
|
||||||
|
opacity: isTransitioning ? 0.5 : 1,
|
||||||
|
transition: 'opacity 0.3s ease-in-out',
|
||||||
|
transform: isTransitioning ? 'scale(0.98)' : 'scale(1)',
|
||||||
|
transitionProperty: 'opacity, transform',
|
||||||
|
transitionDuration: '0.3s',
|
||||||
|
transitionTimingFunction: 'ease-in-out'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{loading && (
|
{loading && (
|
||||||
|
|||||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -63,6 +63,7 @@
|
|||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-swipeable": "^7.0.2",
|
||||||
"recharts": "^3.2.1",
|
"recharts": "^3.2.1",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
@@ -7422,6 +7423,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-swipeable": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-swipeable/-/react-swipeable-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-v1Qx1l+aC2fdxKa9aKJiaU/ZxmJ5o98RMoFwUqAAzVWUcxgfHFXDDruCKXhw6zIYXm6V64JiHgP9f6mlME5l8w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.3 || ^17 || ^18 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-transition-group": {
|
"node_modules/react-transition-group": {
|
||||||
"version": "4.4.5",
|
"version": "4.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||||
|
|||||||
@@ -76,6 +76,7 @@
|
|||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-swipeable": "^7.0.2",
|
||||||
"recharts": "^3.2.1",
|
"recharts": "^3.2.1",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
|
|||||||
Reference in New Issue
Block a user