@@ -15,12 +15,11 @@ import useTilg from 'tilg'
1515import { useSnapshot } from 'valtio'
1616
1717import { RenditionSpread } from '@flow/epubjs/types/rendition'
18- import { navbarState } from '@flow/reader/state'
18+ import { navbarState , useSettings } from '@flow/reader/state'
1919
2020import { db } from '../db'
2121import { handleFiles } from '../file'
2222import {
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 }) => {
202205interface 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 >
0 commit comments