#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
|
||||
flex-direction column
|
||||
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 { useCalendarEventHandlers } from './hooks/useCalendarEventHandlers'
|
||||
import { useCalendarViewHandlers } from './hooks/useCalendarViewHandlers'
|
||||
import { useSwipeNavigation } from './hooks/useSwipeNavigation'
|
||||
import Sidebar from './Sidebar/SideBar'
|
||||
import TempSearchDialog from './TempSearchDialog'
|
||||
import { useTouchListener } from './useTouchListener'
|
||||
@@ -48,7 +49,6 @@ import {
|
||||
} from './utils/calendarUtils'
|
||||
import { CALENDAR_VIEWS } from './utils/constants'
|
||||
import ViewMoreEvents from './ViewMoreEvents'
|
||||
import { useSwipeCalendar } from './hooks/useSwipeCalendar'
|
||||
|
||||
const localeMap: Record<string, LocaleInput | undefined> = {
|
||||
fr: frLocale,
|
||||
@@ -377,12 +377,6 @@ const CalendarApp: React.FC<CalendarAppProps> = ({
|
||||
errorHandler: errorHandler.current
|
||||
})
|
||||
|
||||
const { offsetX, isAnimating } = useSwipeCalendar({
|
||||
calendarRef,
|
||||
containerRef: calendarWrapperRef,
|
||||
isMobile
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
window.__calendarRef = calendarRef
|
||||
@@ -397,6 +391,8 @@ const CalendarApp: React.FC<CalendarAppProps> = ({
|
||||
calendarWrapperRef
|
||||
)
|
||||
|
||||
const { handlers } = useSwipeNavigation(calendarRef, calendarWrapperRef)
|
||||
|
||||
return (
|
||||
<main
|
||||
className={`main-layout calendar-layout ${menubarProps?.isIframe ? ' isInIframe' : ''}`}
|
||||
@@ -439,84 +435,78 @@ const CalendarApp: React.FC<CalendarAppProps> = ({
|
||||
{view === 'calendar' && (
|
||||
<div
|
||||
ref={calendarWrapperRef}
|
||||
className="calendar-viewport"
|
||||
style={{ height: '100%', touchAction: 'pan-y' }}
|
||||
className={isTablet || isMobile ? 'calendar-swipe-container' : ''}
|
||||
style={{
|
||||
height: '100%',
|
||||
...(isTablet || isMobile ? { touchAction: 'pan-y' } : {})
|
||||
}}
|
||||
{...(isTablet || isMobile ? handlers : {})}
|
||||
>
|
||||
<div
|
||||
className={`calendar-track ${isAnimating ? 'calendar-track--animate' : ''}`}
|
||||
style={{
|
||||
transform: `translateX(${offsetX}px)`,
|
||||
transition: isAnimating
|
||||
? 'transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)'
|
||||
: 'none'
|
||||
<FullCalendar
|
||||
key={hiddenDays.join(',')}
|
||||
ref={ref => {
|
||||
if (ref) {
|
||||
calendarRef.current = ref.getApi()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="calendar-page" />
|
||||
<div className="calendar-page">
|
||||
<FullCalendar
|
||||
key={hiddenDays.join(',')}
|
||||
ref={ref => {
|
||||
if (ref) {
|
||||
calendarRef.current = ref.getApi()
|
||||
}
|
||||
}}
|
||||
plugins={[
|
||||
dayGridPlugin,
|
||||
timeGridPlugin,
|
||||
interactionPlugin,
|
||||
momentTimezonePlugin
|
||||
]}
|
||||
initialView={
|
||||
currentView ||
|
||||
(isTablet
|
||||
? CALENDAR_VIEWS.timeGridDay
|
||||
: CALENDAR_VIEWS.timeGridWeek)
|
||||
}
|
||||
firstDay={1}
|
||||
editable={true}
|
||||
locale={localeMap[lang]}
|
||||
hiddenDays={hiddenDays}
|
||||
selectable={true}
|
||||
timeZone={timezone}
|
||||
height="100%"
|
||||
select={eventHandlers.handleDateSelect}
|
||||
nowIndicator
|
||||
slotLabelClassNames={arg => [
|
||||
updateSlotLabelVisibility(new Date(), arg, timezone)
|
||||
]}
|
||||
nowIndicatorContent={viewHandlers.handleNowIndicatorContent}
|
||||
headerToolbar={false}
|
||||
views={{
|
||||
timeGridWeek: {
|
||||
titleFormat: { month: 'long', year: 'numeric' }
|
||||
}
|
||||
}}
|
||||
dayMaxEvents={true}
|
||||
moreLinkClick={handleMoreLinkClick}
|
||||
events={eventToFullCalendarFormat(
|
||||
filteredEvents,
|
||||
filteredTempEvents,
|
||||
userId,
|
||||
userData?.email,
|
||||
isPending,
|
||||
calendars
|
||||
)}
|
||||
eventOrder={(a: EventApi, b: EventApi) =>
|
||||
a.extendedProps.priority - b.extendedProps.priority
|
||||
}
|
||||
weekNumbers={
|
||||
currentView === CALENDAR_VIEWS.timeGridWeek ||
|
||||
currentView === CALENDAR_VIEWS.timeGridDay
|
||||
}
|
||||
weekNumberFormat={{ week: 'long' }}
|
||||
weekNumberContent={arg => {
|
||||
return (
|
||||
<div className="weekSelector">
|
||||
{displayWeekNumbers && (
|
||||
<div>
|
||||
{t('menubar.views.week')} {arg.num}
|
||||
</div>
|
||||
)}
|
||||
plugins={[
|
||||
dayGridPlugin,
|
||||
timeGridPlugin,
|
||||
interactionPlugin,
|
||||
momentTimezonePlugin
|
||||
]}
|
||||
initialView={
|
||||
currentView ||
|
||||
(isTablet
|
||||
? CALENDAR_VIEWS.timeGridDay
|
||||
: CALENDAR_VIEWS.timeGridWeek)
|
||||
}
|
||||
initialDate={selectedDate}
|
||||
firstDay={1}
|
||||
editable={true}
|
||||
locale={localeMap[lang]}
|
||||
hiddenDays={hiddenDays}
|
||||
selectable={true}
|
||||
timeZone={timezone}
|
||||
height="100%"
|
||||
select={eventHandlers.handleDateSelect}
|
||||
nowIndicator
|
||||
slotLabelClassNames={arg => [
|
||||
updateSlotLabelVisibility(new Date(), arg, timezone)
|
||||
]}
|
||||
nowIndicatorContent={viewHandlers.handleNowIndicatorContent}
|
||||
headerToolbar={false}
|
||||
views={{
|
||||
timeGridWeek: {
|
||||
titleFormat: { month: 'long', year: 'numeric' }
|
||||
}
|
||||
}}
|
||||
dayMaxEvents={true}
|
||||
moreLinkClick={handleMoreLinkClick}
|
||||
events={eventToFullCalendarFormat(
|
||||
filteredEvents,
|
||||
filteredTempEvents,
|
||||
userId,
|
||||
userData?.email,
|
||||
isPending,
|
||||
calendars
|
||||
)}
|
||||
eventOrder={(a: EventApi, b: EventApi) =>
|
||||
a.extendedProps.priority - b.extendedProps.priority
|
||||
}
|
||||
weekNumbers={
|
||||
currentView === CALENDAR_VIEWS.timeGridWeek ||
|
||||
currentView === CALENDAR_VIEWS.timeGridDay
|
||||
}
|
||||
weekNumberFormat={{ week: 'long' }}
|
||||
weekNumberContent={arg => {
|
||||
return (
|
||||
<div className="weekSelector">
|
||||
{displayWeekNumbers && (
|
||||
<>
|
||||
<div>
|
||||
{t('menubar.views.week')} {arg.num}
|
||||
</div>
|
||||
<TimezoneSelector
|
||||
value={timezone}
|
||||
referenceDate={
|
||||
@@ -526,112 +516,111 @@ const CalendarApp: React.FC<CalendarAppProps> = ({
|
||||
dispatch(setTimeZone(newTimezone))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
dayCellContent={arg => {
|
||||
const month = arg.date.toLocaleDateString(t('locale'), {
|
||||
month: 'short',
|
||||
timeZone: timezone
|
||||
})
|
||||
if (arg.view.type === CALENDAR_VIEWS.dayGridMonth) {
|
||||
return (
|
||||
<span
|
||||
className={`fc-daygrid-day-number ${
|
||||
arg.isToday ? 'current-date' : ''
|
||||
}`}
|
||||
>
|
||||
{arg.dayNumberText === '1' ? month : ''}{' '}
|
||||
{arg.dayNumberText}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
}}
|
||||
slotDuration="00:30:00"
|
||||
slotLabelInterval="01:00:00"
|
||||
scrollTime="12:00:00"
|
||||
unselectAuto={false}
|
||||
allDayText=""
|
||||
slotLabelFormat={{
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
}}
|
||||
datesSet={arg => {
|
||||
onViewChange?.(arg.view.type)
|
||||
const calendarCurrentDate =
|
||||
calendarRef.current?.getDate() || new Date(arg.start)
|
||||
setDisplayedDateAndRange(calendarCurrentDate)
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
dayCellContent={arg => {
|
||||
const month = arg.date.toLocaleDateString(t('locale'), {
|
||||
month: 'short',
|
||||
timeZone: timezone
|
||||
})
|
||||
if (arg.view.type === CALENDAR_VIEWS.dayGridMonth) {
|
||||
return (
|
||||
<span
|
||||
className={`fc-daygrid-day-number ${
|
||||
arg.isToday ? 'current-date' : ''
|
||||
}`}
|
||||
>
|
||||
{arg.dayNumberText === '1' ? month : ''}{' '}
|
||||
{arg.dayNumberText}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
}}
|
||||
slotDuration="00:30:00"
|
||||
slotLabelInterval="01:00:00"
|
||||
scrollTime="12:00:00"
|
||||
unselectAuto={false}
|
||||
allDayText=""
|
||||
slotLabelFormat={{
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
}}
|
||||
datesSet={arg => {
|
||||
onViewChange?.(arg.view.type)
|
||||
const calendarCurrentDate =
|
||||
calendarRef.current?.getDate() || new Date(arg.start)
|
||||
setDisplayedDateAndRange(calendarCurrentDate)
|
||||
|
||||
if (arg.view.type === CALENDAR_VIEWS.dayGridMonth) {
|
||||
const start = new Date(arg.start).getTime()
|
||||
const end = new Date(arg.end).getTime()
|
||||
const middle = start + (end - start) / 2
|
||||
setSelectedDate(new Date(middle))
|
||||
setSelectedMiniDate(calendarCurrentDate)
|
||||
} else {
|
||||
setSelectedDate(calendarCurrentDate)
|
||||
setSelectedMiniDate(calendarCurrentDate)
|
||||
}
|
||||
if (arg.view.type === CALENDAR_VIEWS.dayGridMonth) {
|
||||
const start = new Date(arg.start).getTime()
|
||||
const end = new Date(arg.end).getTime()
|
||||
const middle = start + (end - start) / 2
|
||||
setSelectedDate(new Date(middle))
|
||||
setSelectedMiniDate(calendarCurrentDate)
|
||||
} else {
|
||||
setSelectedDate(calendarCurrentDate)
|
||||
setSelectedMiniDate(calendarCurrentDate)
|
||||
}
|
||||
|
||||
// Always use the calendar's current date for consistency
|
||||
if (onDateChange) {
|
||||
onDateChange(calendarCurrentDate)
|
||||
}
|
||||
// Always use the calendar's current date for consistency
|
||||
if (onDateChange) {
|
||||
onDateChange(calendarCurrentDate)
|
||||
}
|
||||
|
||||
// Notify parent about view change
|
||||
setCurrentView(arg.view.type)
|
||||
// Notify parent about view change
|
||||
setCurrentView(arg.view.type)
|
||||
|
||||
// Update slot label visibility when view changes
|
||||
setTimeout(() => {
|
||||
updateSlotLabelVisibility(new Date())
|
||||
}, 100)
|
||||
}}
|
||||
dayHeaderContent={arg => {
|
||||
const m = moment.tz(arg.date, timezone)
|
||||
// Update slot label visibility when view changes
|
||||
setTimeout(() => {
|
||||
updateSlotLabelVisibility(new Date())
|
||||
}, 100)
|
||||
}}
|
||||
dayHeaderContent={arg => {
|
||||
const m = moment.tz(arg.date, timezone)
|
||||
|
||||
const date = m.date()
|
||||
const weekDay = m
|
||||
.toDate()
|
||||
.toLocaleDateString(t('locale'), {
|
||||
weekday: 'short',
|
||||
timeZone: timezone
|
||||
})
|
||||
.toUpperCase()
|
||||
const date = m.date()
|
||||
const weekDay = m
|
||||
.toDate()
|
||||
.toLocaleDateString(t('locale'), {
|
||||
weekday: 'short',
|
||||
timeZone: timezone
|
||||
})
|
||||
.toUpperCase()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fc-daygrid-day-top ${isTablet || isMobile ? 'fc-daygrid-day-top--mobile' : ''}`}
|
||||
return (
|
||||
<div
|
||||
className={`fc-daygrid-day-top ${isTablet || isMobile ? 'fc-daygrid-day-top--mobile' : ''}`}
|
||||
>
|
||||
<small>{weekDay}</small>
|
||||
{arg.view.type !== CALENDAR_VIEWS.dayGridMonth && (
|
||||
<span
|
||||
className={`fc-daygrid-day-number ${arg.isToday ? 'current-date' : ''}`}
|
||||
>
|
||||
<small>{weekDay}</small>
|
||||
{arg.view.type !== CALENDAR_VIEWS.dayGridMonth && (
|
||||
<span
|
||||
className={`fc-daygrid-day-number ${arg.isToday ? 'current-date' : ''}`}
|
||||
>
|
||||
{date}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
dayHeaderDidMount={viewHandlers.handleDayHeaderDidMount}
|
||||
dayHeaderWillUnmount={viewHandlers.handleDayHeaderWillUnmount}
|
||||
viewDidMount={viewHandlers.handleViewDidMount}
|
||||
viewWillUnmount={viewHandlers.handleViewWillUnmount}
|
||||
eventClick={eventHandlers.handleEventClick}
|
||||
eventAllow={eventHandlers.handleEventAllow}
|
||||
eventDrop={arg => {
|
||||
void eventHandlers.handleEventDrop(arg)
|
||||
}}
|
||||
eventResize={arg => {
|
||||
void eventHandlers.handleEventResize(arg)
|
||||
}}
|
||||
eventContent={viewHandlers.handleEventContent}
|
||||
eventDidMount={viewHandlers.handleEventDidMount}
|
||||
/>
|
||||
</div>
|
||||
<div className="calendar-page" />
|
||||
</div>
|
||||
{date}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
dayHeaderDidMount={viewHandlers.handleDayHeaderDidMount}
|
||||
dayHeaderWillUnmount={viewHandlers.handleDayHeaderWillUnmount}
|
||||
viewDidMount={viewHandlers.handleViewDidMount}
|
||||
viewWillUnmount={viewHandlers.handleViewWillUnmount}
|
||||
eventClick={eventHandlers.handleEventClick}
|
||||
eventAllow={eventHandlers.handleEventAllow}
|
||||
eventDrop={arg => {
|
||||
void eventHandlers.handleEventDrop(arg)
|
||||
}}
|
||||
eventResize={arg => {
|
||||
void eventHandlers.handleEventResize(arg)
|
||||
}}
|
||||
eventContent={viewHandlers.handleEventContent}
|
||||
eventDidMount={viewHandlers.handleEventDidMount}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{view === 'search' && <SearchResultsPage />}
|
||||
|
||||
@@ -380,4 +380,16 @@ li.MuiButtonBase-root.MuiMenuItem-root
|
||||
background #f6f6f7
|
||||
|
||||
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