Skip to content

Commit 957e095

Browse files
committed
feat(reader): Add support for touch screen swipe navigation
1 parent 9d9143f commit 957e095

File tree

4 files changed

+167
-35
lines changed

4 files changed

+167
-35
lines changed

apps/reader/locales/en-US.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export default {
3838
'typography.font_weight': 'Font Weight',
3939
'typography.line_height': 'Line Height',
4040
'typography.zoom': 'Zoom',
41+
'typography.swipe_navigation': 'Swipe Navigation',
4142

4243
'theme.title': 'Theme',
4344
'theme.source_color': 'Source Color',
@@ -49,6 +50,8 @@ export default {
4950
'settings.color_scheme.system': 'System',
5051
'settings.color_scheme.light': 'Light',
5152
'settings.color_scheme.dark': 'Dark',
53+
'settings.navigation': 'Navigation',
54+
'settings.swipe_navigation': 'Swipe Navigation',
5255
'settings.synchronization.title': 'Synchronization',
5356
'settings.synchronization.authorize': 'Authorize',
5457
'settings.synchronization.unauthorize': 'Unauthorize',

apps/reader/src/components/Reader.tsx

Lines changed: 141 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,11 @@ import useTilg from 'tilg'
1515
import { useSnapshot } from 'valtio'
1616

1717
import { RenditionSpread } from '@flow/epubjs/types/rendition'
18-
import { navbarState } from '@flow/reader/state'
18+
import { navbarState, useSettings } from '@flow/reader/state'
1919

2020
import { db } from '../db'
2121
import { handleFiles } from '../file'
2222
import {
23-
hasSelection,
2423
useBackground,
2524
useColorScheme,
2625
useDisablePinchZooming,
@@ -181,7 +180,11 @@ function ReaderGroup({ index }: ReaderGroupProps) {
181180
{group.tabs.map((tab, i) => (
182181
<PaneContainer active={i === selectedIndex} key={tab.id}>
183182
{tab instanceof BookTab ? (
184-
<BookPane tab={tab} onMouseDown={handleMouseDown} />
183+
<BookPane
184+
tab={tab}
185+
onMouseDown={handleMouseDown}
186+
swipeThreshold={60}
187+
/>
185188
) : (
186189
<tab.Component />
187190
)}
@@ -202,17 +205,60 @@ const PaneContainer: React.FC<PaneContainerProps> = ({ active, children }) => {
202205
interface BookPaneProps {
203206
tab: BookTab
204207
onMouseDown: () => void
208+
swipeThreshold?: number
205209
}
206210

207-
function BookPane({ tab, onMouseDown }: BookPaneProps) {
211+
function BookPane({ tab, onMouseDown, swipeThreshold = 60 }: BookPaneProps) {
212+
// Constants for swipe behavior
213+
const SWIPE_DETECTION_THRESHOLD = 15
214+
const SWIPE_DAMPING_FACTOR = 0.4
215+
const MAX_SWIPE_OFFSET = 150
216+
const SWIPE_RESISTANCE_THRESHOLD = 0.7
217+
const SWIPE_TIME_LIMIT = 400
218+
const GRADIENT_WIDTH = 60
219+
208220
const ref = useRef<HTMLDivElement>(null)
209221
const prevSize = useRef(0)
210222
const typography = useTypography(tab)
211223
const { dark } = useColorScheme()
212224
const [background] = useBackground()
225+
const [swipeOffset, setSwipeOffset] = useState(0)
226+
const [isSwiping, setIsSwiping] = useState(false)
227+
const [swipeDirection, setSwipeDirection] = useState<'left' | 'right' | null>(
228+
null,
229+
)
230+
const [showPageTurnGradient, setShowPageTurnGradient] = useState(false)
231+
const [settings] = useSettings()
213232

214233
const { iframe, rendition, rendered, container } = useSnapshot(tab)
215234

235+
// Helper function to generate theme-aware gradient
236+
const generateGradient = (direction: 'left' | 'right', isDark: boolean) => {
237+
const position = direction === 'right' ? 'left center' : 'right center'
238+
const opacity = isDark
239+
? { start: 0.1, mid: 0.05 }
240+
: { start: 0.03, mid: 0.015 }
241+
242+
return `radial-gradient(ellipse 120px 100% at ${position}, rgba(0,0,0,${opacity.start}) 0%, rgba(0,0,0,${opacity.mid}) 40%, transparent 70%)`
243+
}
244+
245+
// Helper function to apply damping to swipe offset
246+
const applySwipeDamping = (deltaX: number) => {
247+
let dampedOffset = deltaX * SWIPE_DAMPING_FACTOR
248+
249+
// Add resistance when approaching maximum offset
250+
if (
251+
Math.abs(dampedOffset) >
252+
MAX_SWIPE_OFFSET * SWIPE_RESISTANCE_THRESHOLD
253+
) {
254+
const resistance = Math.abs(dampedOffset) / MAX_SWIPE_OFFSET
255+
dampedOffset = dampedOffset * (1 - resistance * 0.5)
256+
}
257+
258+
// Clamp to maximum offset
259+
return Math.max(-MAX_SWIPE_OFFSET, Math.min(MAX_SWIPE_OFFSET, dampedOffset))
260+
}
261+
216262
useTilg()
217263

218264
useEffect(() => {
@@ -341,47 +387,88 @@ function BookPane({ tab, onMouseDown }: BookPaneProps) {
341387
useEventListener(iframe, 'keydown', handleKeyDown(tab))
342388

343389
useEventListener(iframe, 'touchstart', (e) => {
344-
const x0 = e.targetTouches[0]?.clientX ?? 0
345-
const y0 = e.targetTouches[0]?.clientY ?? 0
346-
const t0 = Date.now()
390+
// Early return if swipes are disabled
391+
if (!settings.swipeEnabled) return
347392

348-
if (!iframe) return
349-
350-
// When selecting text with long tap, `touchend` is not fired,
351-
// so instead of use `addEventlistener`, we should use `on*`
352-
// to remove the previous listener.
353-
iframe.ontouchend = function handleTouchEnd(e: TouchEvent) {
354-
iframe.ontouchend = undefined
355-
const selection = iframe.getSelection()
356-
if (hasSelection(selection)) return
393+
const startX = e.targetTouches[0]?.clientX ?? 0
394+
const startY = e.targetTouches[0]?.clientY ?? 0
395+
const startTime = Date.now()
357396

358-
const x1 = e.changedTouches[0]?.clientX ?? 0
359-
const y1 = e.changedTouches[0]?.clientY ?? 0
360-
const t1 = Date.now()
397+
if (!iframe) return
361398

362-
const deltaX = x1 - x0
363-
const deltaY = y1 - y0
364-
const deltaT = t1 - t0
399+
let hasShownGradient = false
365400

401+
const touchMoveHandler: (e: TouchEvent) => void = (e: TouchEvent) => {
402+
const currentX = e.touches[0]?.clientX ?? 0
403+
const currentY = e.touches[0]?.clientY ?? 0
404+
const deltaX = currentX - startX
405+
const deltaY = currentY - startY
366406
const absX = Math.abs(deltaX)
367407
const absY = Math.abs(deltaY)
368408

369-
if (absX < 10) return
409+
// Only handle horizontal swipes above detection threshold
410+
if (absX > absY && absX > SWIPE_DETECTION_THRESHOLD) {
411+
e.preventDefault()
412+
setIsSwiping(true)
413+
414+
// Apply damping for smooth movement
415+
const dampedOffset = applySwipeDamping(deltaX)
416+
setSwipeOffset(dampedOffset)
370417

371-
if (absY / absX > 2) {
372-
if (deltaT > 100 || absX < 30) {
373-
return
418+
// Determine swipe direction
419+
const direction = deltaX > 0 ? 'right' : 'left'
420+
setSwipeDirection(direction)
421+
422+
// Show gradient when swipe threshold is met
423+
const shouldShowGradient = absX > absY && absX > swipeThreshold
424+
setShowPageTurnGradient(shouldShowGradient)
425+
426+
// Track gradient state for page turn commitment
427+
if (shouldShowGradient) {
428+
hasShownGradient = true
374429
}
375430
}
431+
}
376432

377-
if (deltaX > 0) {
378-
tab.prev()
379-
}
433+
const touchEndHandler: (e: TouchEvent) => void = (e: TouchEvent) => {
434+
if (!touchMoveHandler || !touchEndHandler) return
380435

381-
if (deltaX < 0) {
382-
tab.next()
436+
// Clean up event listeners
437+
iframe.removeEventListener('touchmove', touchMoveHandler)
438+
iframe.removeEventListener('touchend', touchEndHandler)
439+
440+
const endX = e.changedTouches[0]?.clientX ?? 0
441+
const endY = e.changedTouches[0]?.clientY ?? 0
442+
const endTime = Date.now()
443+
444+
const deltaX = endX - startX
445+
const deltaY = endY - startY
446+
const deltaTime = endTime - startTime
447+
const absX = Math.abs(deltaX)
448+
const absY = Math.abs(deltaY)
449+
450+
// Page turn logic: gradient shown OR fast swipe criteria met
451+
const fastSwipeCondition =
452+
absX > absY && absX > swipeThreshold && deltaTime < SWIPE_TIME_LIMIT
453+
const shouldTurnPage = hasShownGradient || fastSwipeCondition
454+
455+
if (shouldTurnPage && absX > absY) {
456+
if (deltaX > 0) {
457+
tab.prev()
458+
} else {
459+
tab.next()
460+
}
383461
}
462+
463+
// Reset swipe state
464+
setIsSwiping(false)
465+
setSwipeOffset(0)
466+
setSwipeDirection(null)
467+
setShowPageTurnGradient(false)
384468
}
469+
470+
iframe.addEventListener('touchmove', touchMoveHandler)
471+
iframe.addEventListener('touchend', touchEndHandler)
385472
})
386473

387474
useDisablePinchZooming(iframe)
@@ -399,18 +486,38 @@ function BookPane({ tab, onMouseDown }: BookPaneProps) {
399486
<div
400487
ref={ref}
401488
className={clsx('relative flex-1', isTouchScreen || 'h-0')}
402-
// `color-scheme: dark` will make iframe background white
403-
style={{ colorScheme: 'auto' }}
489+
style={{
490+
colorScheme: 'auto',
491+
transform: isSwiping ? `translateX(${swipeOffset}px)` : undefined,
492+
transition: isSwiping
493+
? 'none'
494+
: 'transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
495+
}}
404496
>
405497
<div
406498
className={clsx(
407499
'absolute inset-0',
408-
// do not cover `sash`
409500
'z-20',
410501
rendered && 'hidden',
411502
background,
412503
)}
413504
/>
505+
506+
{/* Page turn gradient - provides visual feedback for swipe threshold */}
507+
{showPageTurnGradient && swipeDirection && (
508+
<div
509+
className={clsx(
510+
'pointer-events-none absolute inset-y-0 z-30',
511+
'transition-opacity duration-150 ease-out',
512+
swipeDirection === 'right' ? 'left-0' : 'right-0',
513+
)}
514+
style={{
515+
width: `${GRADIENT_WIDTH}px`,
516+
background: generateGradient(swipeDirection, dark ?? false),
517+
}}
518+
/>
519+
)}
520+
414521
<TextSelectionMenu tab={tab} />
415522
<Annotations tab={tab} />
416523
</div>

apps/reader/src/components/pages/settings.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
useForceRender,
1010
useTranslation,
1111
} from '@flow/reader/hooks'
12+
import { useSettings } from '@flow/reader/state'
1213
import { dbx, mapToToken, OAUTH_SUCCESS_MESSAGE } from '@flow/reader/sync'
1314

1415
import { Button } from '../Button'
@@ -19,6 +20,7 @@ export const Settings: React.FC = () => {
1920
const { scheme, setScheme } = useColorScheme()
2021
const { asPath, push, locale } = useRouter()
2122
const t = useTranslation('settings')
23+
const [settings, setSettings] = useSettings()
2224

2325
return (
2426
<Page headline={t('title')}>
@@ -47,6 +49,23 @@ export const Settings: React.FC = () => {
4749
<option value="dark">{t('color_scheme.dark')}</option>
4850
</Select>
4951
</Item>
52+
<Item title={t('navigation')}>
53+
<div className="flex items-center gap-2">
54+
<input
55+
type="checkbox"
56+
checked={settings.swipeEnabled ?? false}
57+
onChange={(e) => {
58+
setSettings((prev) => ({
59+
...prev,
60+
swipeEnabled: e.target.checked,
61+
}))
62+
}}
63+
/>
64+
<span className="typescale-body-medium">
65+
{t('swipe_navigation')}
66+
</span>
67+
</div>
68+
</Item>
5069
<Synchronization />
5170
<Item title={t('cache')}>
5271
<Button

apps/reader/src/state.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export const navbarState = atom<boolean>({
2929

3030
export interface Settings extends TypographyConfiguration {
3131
theme?: ThemeConfiguration
32+
swipeEnabled?: boolean
3233
}
3334

3435
export interface TypographyConfiguration {
@@ -45,7 +46,9 @@ interface ThemeConfiguration {
4546
background?: number
4647
}
4748

48-
export const defaultSettings: Settings = {}
49+
export const defaultSettings: Settings = {
50+
swipeEnabled: false,
51+
}
4952

5053
const settingsState = atom<Settings>({
5154
key: 'settings',

0 commit comments

Comments
 (0)