#756 make calendar easier to swipe
This commit is contained in:
committed by
Benoit TELLIER
parent
fd2282a65f
commit
c8cab5d55d
@@ -134,40 +134,3 @@
|
|||||||
display flex
|
display flex
|
||||||
flex-direction column
|
flex-direction column
|
||||||
align-items center
|
align-items center
|
||||||
.calendar-viewport
|
|
||||||
width 100%
|
|
||||||
height 100%
|
|
||||||
overflow hidden
|
|
||||||
position relative
|
|
||||||
|
|
||||||
.calendar-track
|
|
||||||
display flex
|
|
||||||
width 300%
|
|
||||||
height 100%
|
|
||||||
margin-left -100%
|
|
||||||
will-change transform
|
|
||||||
|
|
||||||
.calendar-track--animate
|
|
||||||
transition transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)
|
|
||||||
|
|
||||||
.calendar-page
|
|
||||||
flex 0 0 33.333%
|
|
||||||
width 33.333%
|
|
||||||
height 100%
|
|
||||||
overflow hidden
|
|
||||||
position relative
|
|
||||||
background-color inherit
|
|
||||||
|
|
||||||
.calendar-page-placeholder
|
|
||||||
width 100%
|
|
||||||
height 100%
|
|
||||||
display flex
|
|
||||||
align-items center
|
|
||||||
justify-content center
|
|
||||||
background-color #f8f9fa
|
|
||||||
|
|
||||||
.placeholder-content
|
|
||||||
text-align center
|
|
||||||
|
|
||||||
[data-theme="dark"] .calendar-page-placeholder
|
|
||||||
background-color #1e1e1e
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import './Calendar.styl'
|
|||||||
import './CustomCalendar.styl'
|
import './CustomCalendar.styl'
|
||||||
import { useCalendarEventHandlers } from './hooks/useCalendarEventHandlers'
|
import { useCalendarEventHandlers } from './hooks/useCalendarEventHandlers'
|
||||||
import { useCalendarViewHandlers } from './hooks/useCalendarViewHandlers'
|
import { useCalendarViewHandlers } from './hooks/useCalendarViewHandlers'
|
||||||
|
import { useSwipeNavigation } from './hooks/useSwipeNavigation'
|
||||||
import Sidebar from './Sidebar/SideBar'
|
import Sidebar from './Sidebar/SideBar'
|
||||||
import TempSearchDialog from './TempSearchDialog'
|
import TempSearchDialog from './TempSearchDialog'
|
||||||
import { useTouchListener } from './useTouchListener'
|
import { useTouchListener } from './useTouchListener'
|
||||||
@@ -48,7 +49,6 @@ import {
|
|||||||
} from './utils/calendarUtils'
|
} from './utils/calendarUtils'
|
||||||
import { CALENDAR_VIEWS } from './utils/constants'
|
import { CALENDAR_VIEWS } from './utils/constants'
|
||||||
import ViewMoreEvents from './ViewMoreEvents'
|
import ViewMoreEvents from './ViewMoreEvents'
|
||||||
import { useSwipeCalendar } from './hooks/useSwipeCalendar'
|
|
||||||
|
|
||||||
const localeMap: Record<string, LocaleInput | undefined> = {
|
const localeMap: Record<string, LocaleInput | undefined> = {
|
||||||
fr: frLocale,
|
fr: frLocale,
|
||||||
@@ -377,12 +377,6 @@ const CalendarApp: React.FC<CalendarAppProps> = ({
|
|||||||
errorHandler: errorHandler.current
|
errorHandler: errorHandler.current
|
||||||
})
|
})
|
||||||
|
|
||||||
const { offsetX, isAnimating } = useSwipeCalendar({
|
|
||||||
calendarRef,
|
|
||||||
containerRef: calendarWrapperRef,
|
|
||||||
isMobile
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (process.env.NODE_ENV === 'test') {
|
if (process.env.NODE_ENV === 'test') {
|
||||||
window.__calendarRef = calendarRef
|
window.__calendarRef = calendarRef
|
||||||
@@ -397,6 +391,8 @@ const CalendarApp: React.FC<CalendarAppProps> = ({
|
|||||||
calendarWrapperRef
|
calendarWrapperRef
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const { handlers } = useSwipeNavigation(calendarRef, calendarWrapperRef)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main
|
<main
|
||||||
className={`main-layout calendar-layout ${menubarProps?.isIframe ? ' isInIframe' : ''}`}
|
className={`main-layout calendar-layout ${menubarProps?.isIframe ? ' isInIframe' : ''}`}
|
||||||
@@ -439,20 +435,13 @@ const CalendarApp: React.FC<CalendarAppProps> = ({
|
|||||||
{view === 'calendar' && (
|
{view === 'calendar' && (
|
||||||
<div
|
<div
|
||||||
ref={calendarWrapperRef}
|
ref={calendarWrapperRef}
|
||||||
className="calendar-viewport"
|
className={isTablet || isMobile ? 'calendar-swipe-container' : ''}
|
||||||
style={{ height: '100%', touchAction: 'pan-y' }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`calendar-track ${isAnimating ? 'calendar-track--animate' : ''}`}
|
|
||||||
style={{
|
style={{
|
||||||
transform: `translateX(${offsetX}px)`,
|
height: '100%',
|
||||||
transition: isAnimating
|
...(isTablet || isMobile ? { touchAction: 'pan-y' } : {})
|
||||||
? 'transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)'
|
|
||||||
: 'none'
|
|
||||||
}}
|
}}
|
||||||
|
{...(isTablet || isMobile ? handlers : {})}
|
||||||
>
|
>
|
||||||
<div className="calendar-page" />
|
|
||||||
<div className="calendar-page">
|
|
||||||
<FullCalendar
|
<FullCalendar
|
||||||
key={hiddenDays.join(',')}
|
key={hiddenDays.join(',')}
|
||||||
ref={ref => {
|
ref={ref => {
|
||||||
@@ -472,6 +461,7 @@ const CalendarApp: React.FC<CalendarAppProps> = ({
|
|||||||
? CALENDAR_VIEWS.timeGridDay
|
? CALENDAR_VIEWS.timeGridDay
|
||||||
: CALENDAR_VIEWS.timeGridWeek)
|
: CALENDAR_VIEWS.timeGridWeek)
|
||||||
}
|
}
|
||||||
|
initialDate={selectedDate}
|
||||||
firstDay={1}
|
firstDay={1}
|
||||||
editable={true}
|
editable={true}
|
||||||
locale={localeMap[lang]}
|
locale={localeMap[lang]}
|
||||||
@@ -513,10 +503,10 @@ const CalendarApp: React.FC<CalendarAppProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div className="weekSelector">
|
<div className="weekSelector">
|
||||||
{displayWeekNumbers && (
|
{displayWeekNumbers && (
|
||||||
|
<>
|
||||||
<div>
|
<div>
|
||||||
{t('menubar.views.week')} {arg.num}
|
{t('menubar.views.week')} {arg.num}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
<TimezoneSelector
|
<TimezoneSelector
|
||||||
value={timezone}
|
value={timezone}
|
||||||
referenceDate={
|
referenceDate={
|
||||||
@@ -526,6 +516,8 @@ const CalendarApp: React.FC<CalendarAppProps> = ({
|
|||||||
dispatch(setTimeZone(newTimezone))
|
dispatch(setTimeZone(newTimezone))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
@@ -630,9 +622,6 @@ const CalendarApp: React.FC<CalendarAppProps> = ({
|
|||||||
eventDidMount={viewHandlers.handleEventDidMount}
|
eventDidMount={viewHandlers.handleEventDidMount}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="calendar-page" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{view === 'search' && <SearchResultsPage />}
|
{view === 'search' && <SearchResultsPage />}
|
||||||
<EventPopover
|
<EventPopover
|
||||||
|
|||||||
@@ -381,3 +381,15 @@ li.MuiButtonBase-root.MuiMenuItem-root
|
|||||||
|
|
||||||
hr.MuiDivider-root.MuiDivider-fullWidth
|
hr.MuiDivider-root.MuiDivider-fullWidth
|
||||||
margin 0
|
margin 0
|
||||||
|
|
||||||
|
.calendar-swipe-container
|
||||||
|
display grid
|
||||||
|
grid-template-areas "main"
|
||||||
|
height 100%
|
||||||
|
overflow hidden
|
||||||
|
touch-action pan-y
|
||||||
|
|
||||||
|
.calendar-swipe-container > div
|
||||||
|
grid-area main
|
||||||
|
width 100%
|
||||||
|
height 100%
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { useState, useCallback, MutableRefObject } from 'react'
|
|
||||||
import { CalendarApi } from '@fullcalendar/core'
|
|
||||||
|
|
||||||
export const useSwipeAnimation = (
|
|
||||||
calendarRef: MutableRefObject<CalendarApi | null>
|
|
||||||
): {
|
|
||||||
offsetX: number
|
|
||||||
isAnimating: boolean
|
|
||||||
setOffsetX: (offset: number) => void
|
|
||||||
finalizeSwipe: (targetOffset: number, deltaX: number) => void
|
|
||||||
cancelSwipe: () => void
|
|
||||||
setIsAnimating: (isAnimating: boolean) => void
|
|
||||||
} => {
|
|
||||||
const [offsetX, setOffsetX] = useState(0)
|
|
||||||
const [isAnimating, setIsAnimating] = useState(false)
|
|
||||||
|
|
||||||
const finalizeSwipe = useCallback(
|
|
||||||
(targetOffset: number, deltaX: number) => {
|
|
||||||
setIsAnimating(true)
|
|
||||||
|
|
||||||
if (deltaX > 0) {
|
|
||||||
calendarRef.current?.prev()
|
|
||||||
} else {
|
|
||||||
calendarRef.current?.next()
|
|
||||||
}
|
|
||||||
|
|
||||||
setOffsetX(0)
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsAnimating(false)
|
|
||||||
}, 300)
|
|
||||||
},
|
|
||||||
[calendarRef]
|
|
||||||
)
|
|
||||||
|
|
||||||
const cancelSwipe = useCallback(() => {
|
|
||||||
setIsAnimating(true)
|
|
||||||
setOffsetX(0)
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsAnimating(false)
|
|
||||||
}, 150)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return {
|
|
||||||
offsetX,
|
|
||||||
isAnimating,
|
|
||||||
setOffsetX,
|
|
||||||
setIsAnimating,
|
|
||||||
finalizeSwipe,
|
|
||||||
cancelSwipe
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
import { useRef, useState, useEffect, MutableRefObject, RefObject } from 'react'
|
|
||||||
import { CalendarApi } from '@fullcalendar/core'
|
|
||||||
import { useSwipeAnimation } from './useSwipeAnimation'
|
|
||||||
|
|
||||||
interface UseSwipeCalendarProps {
|
|
||||||
calendarRef: MutableRefObject<CalendarApi | null>
|
|
||||||
containerRef: RefObject<HTMLElement>
|
|
||||||
isMobile: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SwipeCalendarReturn {
|
|
||||||
offsetX: number
|
|
||||||
isAnimating: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const isHorizontalSwipe = (dx: number, dy: number): boolean =>
|
|
||||||
Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 10
|
|
||||||
|
|
||||||
const getSwipeTarget = (
|
|
||||||
offsetX: number,
|
|
||||||
containerWidth: number
|
|
||||||
): number | null => {
|
|
||||||
if (Math.abs(offsetX) <= containerWidth * 0.1) return null
|
|
||||||
return offsetX > 0 ? containerWidth : -containerWidth
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTouchMove = (
|
|
||||||
e: TouchEvent,
|
|
||||||
touchStartRef: React.MutableRefObject<{ x: number; y: number } | null>,
|
|
||||||
isHorizontalRef: React.MutableRefObject<boolean>,
|
|
||||||
setOffsetX: (dx: number) => void
|
|
||||||
): void => {
|
|
||||||
if (!touchStartRef.current) return
|
|
||||||
const dx = e.touches[0].clientX - touchStartRef.current.x
|
|
||||||
const dy = e.touches[0].clientY - touchStartRef.current.y
|
|
||||||
if (!isHorizontalRef.current)
|
|
||||||
isHorizontalRef.current = isHorizontalSwipe(dx, dy)
|
|
||||||
if (isHorizontalRef.current && e.cancelable) {
|
|
||||||
e.preventDefault()
|
|
||||||
setOffsetX(dx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTouchEnd = ({
|
|
||||||
touchStartRef,
|
|
||||||
isHorizontalRef,
|
|
||||||
calendarRef,
|
|
||||||
offsetX,
|
|
||||||
containerWidth,
|
|
||||||
setOffsetX,
|
|
||||||
finalizeSwipe,
|
|
||||||
cancelSwipe
|
|
||||||
}: {
|
|
||||||
touchStartRef: React.MutableRefObject<{ x: number; y: number } | null>
|
|
||||||
isHorizontalRef: React.MutableRefObject<boolean>
|
|
||||||
calendarRef: MutableRefObject<CalendarApi | null>
|
|
||||||
offsetX: number
|
|
||||||
containerWidth: number
|
|
||||||
setOffsetX: (dx: number) => void
|
|
||||||
finalizeSwipe: (target: number, currentOffset: number) => void
|
|
||||||
cancelSwipe: () => void
|
|
||||||
}): void => {
|
|
||||||
const hasValidTouch = touchStartRef.current && calendarRef.current
|
|
||||||
if (!hasValidTouch) {
|
|
||||||
setOffsetX(0)
|
|
||||||
touchStartRef.current = null
|
|
||||||
isHorizontalRef.current = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (isHorizontalRef.current) {
|
|
||||||
const target = getSwipeTarget(offsetX, containerWidth)
|
|
||||||
if (target !== null) finalizeSwipe(target, offsetX)
|
|
||||||
else cancelSwipe()
|
|
||||||
}
|
|
||||||
touchStartRef.current = null
|
|
||||||
isHorizontalRef.current = false
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useSwipeCalendar = ({
|
|
||||||
calendarRef,
|
|
||||||
containerRef,
|
|
||||||
isMobile
|
|
||||||
}: UseSwipeCalendarProps): SwipeCalendarReturn => {
|
|
||||||
const { offsetX, isAnimating, setOffsetX, finalizeSwipe, cancelSwipe } =
|
|
||||||
useSwipeAnimation(calendarRef)
|
|
||||||
const [containerWidth, setContainerWidth] = useState(0)
|
|
||||||
const touchStartRef = useRef<{ x: number; y: number } | null>(null)
|
|
||||||
const isHorizontalRef = useRef<boolean>(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const el = containerRef.current
|
|
||||||
if (!el || !isMobile) return
|
|
||||||
|
|
||||||
const onTouchStart = (e: TouchEvent): void => {
|
|
||||||
setContainerWidth(el.getBoundingClientRect().width)
|
|
||||||
touchStartRef.current = {
|
|
||||||
x: e.touches[0].clientX,
|
|
||||||
y: e.touches[0].clientY
|
|
||||||
}
|
|
||||||
isHorizontalRef.current = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const onTouchMove = (e: TouchEvent): void =>
|
|
||||||
handleTouchMove(e, touchStartRef, isHorizontalRef, setOffsetX)
|
|
||||||
|
|
||||||
const onTouchEnd = (): void =>
|
|
||||||
handleTouchEnd({
|
|
||||||
touchStartRef,
|
|
||||||
isHorizontalRef,
|
|
||||||
calendarRef,
|
|
||||||
offsetX,
|
|
||||||
containerWidth,
|
|
||||||
setOffsetX,
|
|
||||||
finalizeSwipe,
|
|
||||||
cancelSwipe
|
|
||||||
})
|
|
||||||
|
|
||||||
el.addEventListener('touchstart', onTouchStart, { passive: true })
|
|
||||||
el.addEventListener('touchmove', onTouchMove, { passive: false })
|
|
||||||
el.addEventListener('touchend', onTouchEnd, { passive: true })
|
|
||||||
|
|
||||||
return (): void => {
|
|
||||||
el.removeEventListener('touchstart', onTouchStart)
|
|
||||||
el.removeEventListener('touchmove', onTouchMove)
|
|
||||||
el.removeEventListener('touchend', onTouchEnd)
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
containerRef,
|
|
||||||
isMobile,
|
|
||||||
offsetX,
|
|
||||||
containerWidth,
|
|
||||||
setOffsetX,
|
|
||||||
finalizeSwipe,
|
|
||||||
cancelSwipe,
|
|
||||||
calendarRef
|
|
||||||
])
|
|
||||||
|
|
||||||
return { offsetX, isAnimating }
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import { CalendarApi } from '@fullcalendar/core'
|
||||||
|
import {
|
||||||
|
MutableRefObject,
|
||||||
|
TouchEvent,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef
|
||||||
|
} from 'react'
|
||||||
|
|
||||||
|
const nextFrame = (): Promise<void> =>
|
||||||
|
new Promise(resolve =>
|
||||||
|
requestAnimationFrame(resolve as unknown as FrameRequestCallback)
|
||||||
|
)
|
||||||
|
const delay = (ms: number): Promise<void> =>
|
||||||
|
new Promise((resolve: (value: void | PromiseLike<void>) => void) =>
|
||||||
|
setTimeout(resolve, ms)
|
||||||
|
)
|
||||||
|
|
||||||
|
function clearGestureTimer(state: GestureState): void {
|
||||||
|
if (state.timer) clearTimeout(state.timer)
|
||||||
|
state.timer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetGesture(state: GestureState, wrapper: HTMLDivElement): void {
|
||||||
|
state.swiping = false
|
||||||
|
state.decided = false
|
||||||
|
state.offset = 0
|
||||||
|
clearGestureTimer(state)
|
||||||
|
wrapper.style.transition = 'transform 0.15s cubic-bezier(0.25, 0.8, 0.25, 1)'
|
||||||
|
wrapper.style.transform = 'translateX(0px)'
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySwipeOffset(
|
||||||
|
state: GestureState,
|
||||||
|
wrapper: HTMLDivElement,
|
||||||
|
dx: number
|
||||||
|
): void {
|
||||||
|
state.offset = dx
|
||||||
|
wrapper.style.transform = `translateX(${dx}px)`
|
||||||
|
wrapper.style.transition = 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
function decideGesture(state: GestureState, dx: number, dy: number): boolean {
|
||||||
|
if (state.decided) return state.swiping
|
||||||
|
|
||||||
|
if (Math.abs(dx) <= 10) {
|
||||||
|
if (Math.abs(dy) <= 10) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
state.decided = true
|
||||||
|
state.swiping = Math.abs(dx) > Math.abs(dy)
|
||||||
|
clearGestureTimer(state)
|
||||||
|
return state.swiping
|
||||||
|
}
|
||||||
|
|
||||||
|
async function animatePagination(
|
||||||
|
dir: number,
|
||||||
|
api: CalendarApi,
|
||||||
|
wrapper: HTMLDivElement,
|
||||||
|
isMounted: () => boolean
|
||||||
|
): Promise<void> {
|
||||||
|
const isSafe = (): boolean => isMounted() && !!wrapper
|
||||||
|
|
||||||
|
wrapper.style.transition = 'none'
|
||||||
|
wrapper.style.transform = `translateX(${dir * window.innerWidth}px)`
|
||||||
|
|
||||||
|
await nextFrame()
|
||||||
|
if (!isSafe()) return
|
||||||
|
api[dir === 1 ? 'next' : 'prev']()
|
||||||
|
|
||||||
|
await nextFrame()
|
||||||
|
if (!isSafe()) return
|
||||||
|
wrapper.style.transition = 'transform 0.2s cubic-bezier(0.25, 0.8, 0.25, 1)'
|
||||||
|
wrapper.style.transform = 'translateX(0px)'
|
||||||
|
|
||||||
|
await delay(250)
|
||||||
|
if (isSafe()) wrapper.style.transition = 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
type GestureState = {
|
||||||
|
startX: number
|
||||||
|
startY: number
|
||||||
|
offset: number
|
||||||
|
decided: boolean
|
||||||
|
swiping: boolean
|
||||||
|
longPress: boolean
|
||||||
|
timer: ReturnType<typeof setTimeout> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchStart(
|
||||||
|
e: TouchEvent<HTMLDivElement>,
|
||||||
|
state: GestureState,
|
||||||
|
wrapperRef: MutableRefObject<HTMLDivElement | null>
|
||||||
|
): void {
|
||||||
|
if (e.touches.length !== 1) return
|
||||||
|
|
||||||
|
state.startX = e.touches[0].clientX
|
||||||
|
state.startY = e.touches[0].clientY
|
||||||
|
state.offset = 0
|
||||||
|
state.decided = false
|
||||||
|
state.swiping = false
|
||||||
|
state.longPress = false
|
||||||
|
|
||||||
|
clearGestureTimer(state)
|
||||||
|
state.timer = setTimeout(() => {
|
||||||
|
state.longPress = true
|
||||||
|
}, 300)
|
||||||
|
|
||||||
|
if (wrapperRef.current) wrapperRef.current.style.transition = 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchMove(
|
||||||
|
e: TouchEvent<HTMLDivElement>,
|
||||||
|
state: GestureState,
|
||||||
|
wrapperRef: MutableRefObject<HTMLDivElement | null>
|
||||||
|
): void {
|
||||||
|
const wrapper = wrapperRef.current
|
||||||
|
if (state.longPress) return
|
||||||
|
if (!wrapper) return
|
||||||
|
|
||||||
|
if (e.touches.length !== 1) {
|
||||||
|
resetGesture(state, wrapper)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const touch = e.touches[0]
|
||||||
|
if (!touch) {
|
||||||
|
resetGesture(state, wrapper)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const dx = touch.clientX - state.startX
|
||||||
|
const dy = touch.clientY - state.startY
|
||||||
|
|
||||||
|
if (decideGesture(state, dx, dy)) {
|
||||||
|
applySwipeOffset(state, wrapper, dx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchEnd(
|
||||||
|
state: GestureState,
|
||||||
|
wrapperRef: MutableRefObject<HTMLDivElement | null>,
|
||||||
|
paginate: (dir: number) => Promise<void>
|
||||||
|
): void {
|
||||||
|
clearGestureTimer(state)
|
||||||
|
|
||||||
|
const isSwiping = state.swiping
|
||||||
|
const finalOffset = state.offset
|
||||||
|
|
||||||
|
state.offset = 0
|
||||||
|
state.decided = false
|
||||||
|
state.swiping = false
|
||||||
|
state.longPress = false
|
||||||
|
|
||||||
|
if (!isSwiping) return
|
||||||
|
|
||||||
|
if (finalOffset < -60) {
|
||||||
|
void paginate(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalOffset > 60) {
|
||||||
|
void paginate(-1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wrapperRef.current) {
|
||||||
|
wrapperRef.current.style.transition =
|
||||||
|
'transform 0.15s cubic-bezier(0.25, 0.8, 0.25, 1)'
|
||||||
|
wrapperRef.current.style.transform = 'translateX(0px)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSwipePagination(
|
||||||
|
calendarRef: MutableRefObject<CalendarApi | null>,
|
||||||
|
wrapperRef: MutableRefObject<HTMLDivElement | null>
|
||||||
|
): (dir: number) => Promise<void> {
|
||||||
|
const isMountedRef = useRef(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isMountedRef.current = true
|
||||||
|
return (): void => {
|
||||||
|
isMountedRef.current = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return useCallback(
|
||||||
|
async (dir: number): Promise<void> => {
|
||||||
|
const api = calendarRef.current
|
||||||
|
const wrapper = wrapperRef.current
|
||||||
|
|
||||||
|
if (!api) return
|
||||||
|
if (!wrapper) return
|
||||||
|
if (!isMountedRef.current) return
|
||||||
|
|
||||||
|
await animatePagination(dir, api, wrapper, () => isMountedRef.current)
|
||||||
|
},
|
||||||
|
[calendarRef, wrapperRef]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function useGestureTracking(
|
||||||
|
wrapperRef: MutableRefObject<HTMLDivElement | null>,
|
||||||
|
paginate: (dir: number) => Promise<void>
|
||||||
|
): {
|
||||||
|
onTouchStart: (e: TouchEvent<HTMLDivElement>) => void
|
||||||
|
onTouchMove: (e: TouchEvent<HTMLDivElement>) => void
|
||||||
|
onTouchEnd: () => void
|
||||||
|
} {
|
||||||
|
const state = useRef<GestureState>({
|
||||||
|
startX: 0,
|
||||||
|
startY: 0,
|
||||||
|
offset: 0,
|
||||||
|
decided: false,
|
||||||
|
swiping: false,
|
||||||
|
longPress: false,
|
||||||
|
timer: null
|
||||||
|
}).current
|
||||||
|
|
||||||
|
return {
|
||||||
|
onTouchStart: useCallback(
|
||||||
|
(e: TouchEvent<HTMLDivElement>) => handleTouchStart(e, state, wrapperRef),
|
||||||
|
[state, wrapperRef]
|
||||||
|
),
|
||||||
|
onTouchMove: useCallback(
|
||||||
|
(e: TouchEvent<HTMLDivElement>) => handleTouchMove(e, state, wrapperRef),
|
||||||
|
[state, wrapperRef]
|
||||||
|
),
|
||||||
|
onTouchEnd: useCallback(
|
||||||
|
() => handleTouchEnd(state, wrapperRef, paginate),
|
||||||
|
[state, wrapperRef, paginate]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSwipeNavigation = (
|
||||||
|
calendarRef: MutableRefObject<CalendarApi | null>,
|
||||||
|
wrapperRef: MutableRefObject<HTMLDivElement | null>
|
||||||
|
): {
|
||||||
|
handlers: {
|
||||||
|
onTouchStart: (e: TouchEvent<HTMLDivElement>) => void
|
||||||
|
onTouchMove: (e: TouchEvent<HTMLDivElement>) => void
|
||||||
|
onTouchEnd: () => void
|
||||||
|
}
|
||||||
|
} => {
|
||||||
|
const paginate = useSwipePagination(calendarRef, wrapperRef)
|
||||||
|
const handlers = useGestureTracking(wrapperRef, paginate)
|
||||||
|
|
||||||
|
return { handlers }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user