From c8cab5d55de4dc282909f4f91e0719b4dfa87b0c Mon Sep 17 00:00:00 2001 From: lethemanh Date: Mon, 20 Apr 2026 10:14:16 +0700 Subject: [PATCH] #756 make calendar easier to swipe --- src/components/Calendar/Calendar.styl | 37 -- src/components/Calendar/Calendar.tsx | 353 +++++++++--------- src/components/Calendar/CustomCalendar.styl | 14 +- .../Calendar/hooks/useSwipeAnimation.ts | 51 --- .../Calendar/hooks/useSwipeCalendar.ts | 139 ------- .../Calendar/hooks/useSwipeNavigation.ts | 250 +++++++++++++ 6 files changed, 434 insertions(+), 410 deletions(-) delete mode 100644 src/components/Calendar/hooks/useSwipeAnimation.ts delete mode 100644 src/components/Calendar/hooks/useSwipeCalendar.ts create mode 100644 src/components/Calendar/hooks/useSwipeNavigation.ts diff --git a/src/components/Calendar/Calendar.styl b/src/components/Calendar/Calendar.styl index 094431d..2a18eb8 100644 --- a/src/components/Calendar/Calendar.styl +++ b/src/components/Calendar/Calendar.styl @@ -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 diff --git a/src/components/Calendar/Calendar.tsx b/src/components/Calendar/Calendar.tsx index dfd5db7..474c361 100644 --- a/src/components/Calendar/Calendar.tsx +++ b/src/components/Calendar/Calendar.tsx @@ -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 = { fr: frLocale, @@ -377,12 +377,6 @@ const CalendarApp: React.FC = ({ 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 = ({ calendarWrapperRef ) + const { handlers } = useSwipeNavigation(calendarRef, calendarWrapperRef) + return (
= ({ {view === 'calendar' && (
-
{ + if (ref) { + calendarRef.current = ref.getApi() + } }} - > -
-
- { - 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 ( -
- {displayWeekNumbers && ( -
- {t('menubar.views.week')} {arg.num} -
- )} + 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 ( +
+ {displayWeekNumbers && ( + <> +
+ {t('menubar.views.week')} {arg.num} +
= ({ dispatch(setTimeZone(newTimezone)) } /> -
- ) - }} - dayCellContent={arg => { - const month = arg.date.toLocaleDateString(t('locale'), { - month: 'short', - timeZone: timezone - }) - if (arg.view.type === CALENDAR_VIEWS.dayGridMonth) { - return ( - - {arg.dayNumberText === '1' ? month : ''}{' '} - {arg.dayNumberText} - - ) - } - }} - 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) + + )} +
+ ) + }} + dayCellContent={arg => { + const month = arg.date.toLocaleDateString(t('locale'), { + month: 'short', + timeZone: timezone + }) + if (arg.view.type === CALENDAR_VIEWS.dayGridMonth) { + return ( + + {arg.dayNumberText === '1' ? month : ''}{' '} + {arg.dayNumberText} + + ) + } + }} + 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 ( -
+ {weekDay} + {arg.view.type !== CALENDAR_VIEWS.dayGridMonth && ( + - {weekDay} - {arg.view.type !== CALENDAR_VIEWS.dayGridMonth && ( - - {date} - - )} -
- ) - }} - 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} - /> -
-
-
+ {date} + + )} +
+ ) + }} + 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} + />
)} {view === 'search' && } diff --git a/src/components/Calendar/CustomCalendar.styl b/src/components/Calendar/CustomCalendar.styl index c5464ef..4306f84 100644 --- a/src/components/Calendar/CustomCalendar.styl +++ b/src/components/Calendar/CustomCalendar.styl @@ -380,4 +380,16 @@ li.MuiButtonBase-root.MuiMenuItem-root background #f6f6f7 hr.MuiDivider-root.MuiDivider-fullWidth - margin 0 \ No newline at end of file + 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% \ No newline at end of file diff --git a/src/components/Calendar/hooks/useSwipeAnimation.ts b/src/components/Calendar/hooks/useSwipeAnimation.ts deleted file mode 100644 index b3f434f..0000000 --- a/src/components/Calendar/hooks/useSwipeAnimation.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { useState, useCallback, MutableRefObject } from 'react' -import { CalendarApi } from '@fullcalendar/core' - -export const useSwipeAnimation = ( - calendarRef: MutableRefObject -): { - 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 - } -} diff --git a/src/components/Calendar/hooks/useSwipeCalendar.ts b/src/components/Calendar/hooks/useSwipeCalendar.ts deleted file mode 100644 index ecbf2c2..0000000 --- a/src/components/Calendar/hooks/useSwipeCalendar.ts +++ /dev/null @@ -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 - containerRef: RefObject - 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, - 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 - calendarRef: MutableRefObject - 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(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 } -} diff --git a/src/components/Calendar/hooks/useSwipeNavigation.ts b/src/components/Calendar/hooks/useSwipeNavigation.ts new file mode 100644 index 0000000..3f2de47 --- /dev/null +++ b/src/components/Calendar/hooks/useSwipeNavigation.ts @@ -0,0 +1,250 @@ +import { CalendarApi } from '@fullcalendar/core' +import { + MutableRefObject, + TouchEvent, + useCallback, + useEffect, + useRef +} from 'react' + +const nextFrame = (): Promise => + new Promise(resolve => + requestAnimationFrame(resolve as unknown as FrameRequestCallback) + ) +const delay = (ms: number): Promise => + new Promise((resolve: (value: void | PromiseLike) => 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 { + 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 | null +} + +function handleTouchStart( + e: TouchEvent, + state: GestureState, + wrapperRef: MutableRefObject +): 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, + state: GestureState, + wrapperRef: MutableRefObject +): 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, + paginate: (dir: number) => Promise +): 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, + wrapperRef: MutableRefObject +): (dir: number) => Promise { + const isMountedRef = useRef(true) + + useEffect(() => { + isMountedRef.current = true + return (): void => { + isMountedRef.current = false + } + }, []) + + return useCallback( + async (dir: number): Promise => { + 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, + paginate: (dir: number) => Promise +): { + onTouchStart: (e: TouchEvent) => void + onTouchMove: (e: TouchEvent) => void + onTouchEnd: () => void +} { + const state = useRef({ + startX: 0, + startY: 0, + offset: 0, + decided: false, + swiping: false, + longPress: false, + timer: null + }).current + + return { + onTouchStart: useCallback( + (e: TouchEvent) => handleTouchStart(e, state, wrapperRef), + [state, wrapperRef] + ), + onTouchMove: useCallback( + (e: TouchEvent) => handleTouchMove(e, state, wrapperRef), + [state, wrapperRef] + ), + onTouchEnd: useCallback( + () => handleTouchEnd(state, wrapperRef, paginate), + [state, wrapperRef, paginate] + ) + } +} + +export const useSwipeNavigation = ( + calendarRef: MutableRefObject, + wrapperRef: MutableRefObject +): { + handlers: { + onTouchStart: (e: TouchEvent) => void + onTouchMove: (e: TouchEvent) => void + onTouchEnd: () => void + } +} => { + const paginate = useSwipePagination(calendarRef, wrapperRef) + const handlers = useGestureTracking(wrapperRef, paginate) + + return { handlers } +}