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