* [#786] fully disable long press * [#786] added hook to handle tap separatly from slide
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { useAppDispatch, useAppSelector } from '@/app/hooks'
|
import { useAppDispatch, useAppSelector } from '@/app/hooks'
|
||||||
|
import { setIsMobileSearchOpen } from '@/features/Calendars/CalendarSlice'
|
||||||
import EventPopover from '@/features/Events/EventModal'
|
import EventPopover from '@/features/Events/EventModal'
|
||||||
import EventPreviewModal from '@/features/Events/EventPreview'
|
import EventPreviewModal from '@/features/Events/EventPreview'
|
||||||
import { CalendarEvent } from '@/features/Events/EventsTypes'
|
import { CalendarEvent } from '@/features/Events/EventsTypes'
|
||||||
@@ -32,20 +33,20 @@ import { EventErrorSnackbar } from '../Error/ErrorSnackbar'
|
|||||||
import { EventErrorHandler } from '../Error/EventErrorHandler'
|
import { EventErrorHandler } from '../Error/EventErrorHandler'
|
||||||
import { EditModeDialog } from '../Event/EditModeDialog'
|
import { EditModeDialog } from '../Event/EditModeDialog'
|
||||||
import { Menubar, MenubarProps } from '../Menubar/Menubar'
|
import { Menubar, MenubarProps } from '../Menubar/Menubar'
|
||||||
|
import { TimezoneSelector } from '../Timezone/TimezoneSelector'
|
||||||
import './Calendar.styl'
|
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 { TimezoneSelector } from '../Timezone/TimezoneSelector'
|
import Sidebar from './Sidebar/SideBar'
|
||||||
|
import TempSearchDialog from './TempSearchDialog'
|
||||||
|
import { useTouchListener } from './useTouchListener'
|
||||||
import {
|
import {
|
||||||
eventToFullCalendarFormat,
|
eventToFullCalendarFormat,
|
||||||
extractEvents,
|
extractEvents,
|
||||||
updateSlotLabelVisibility
|
updateSlotLabelVisibility
|
||||||
} from './utils/calendarUtils'
|
} from './utils/calendarUtils'
|
||||||
import { CALENDAR_VIEWS } from './utils/constants'
|
import { CALENDAR_VIEWS } from './utils/constants'
|
||||||
import Sidebar from './Sidebar/SideBar'
|
|
||||||
import TempSearchDialog from './TempSearchDialog'
|
|
||||||
import { setIsMobileSearchOpen } from '@/features/Calendars/CalendarSlice'
|
|
||||||
import ViewMoreEvents from './ViewMoreEvents'
|
import ViewMoreEvents from './ViewMoreEvents'
|
||||||
|
|
||||||
const localeMap: Record<string, LocaleInput | undefined> = {
|
const localeMap: Record<string, LocaleInput | undefined> = {
|
||||||
@@ -381,6 +382,13 @@ const CalendarApp: React.FC<CalendarAppProps> = ({
|
|||||||
}, [calendarRef])
|
}, [calendarRef])
|
||||||
|
|
||||||
const { t, lang } = useI18n()
|
const { t, lang } = useI18n()
|
||||||
|
const calendarWrapperRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useTouchListener(
|
||||||
|
eventHandlers.handleDateSelect,
|
||||||
|
isTablet || isMobile,
|
||||||
|
calendarWrapperRef
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main
|
<main
|
||||||
@@ -405,7 +413,7 @@ const CalendarApp: React.FC<CalendarAppProps> = ({
|
|||||||
<div className="calendar">
|
<div className="calendar">
|
||||||
<ImportAlert />
|
<ImportAlert />
|
||||||
{menubarProps?.isIframe && <Menubar {...menubarProps} />}
|
{menubarProps?.isIframe && <Menubar {...menubarProps} />}
|
||||||
{isTablet && (
|
{(isTablet || isMobile) && (
|
||||||
<Fab
|
<Fab
|
||||||
color="primary"
|
color="primary"
|
||||||
aria-label={t('event.createEvent')}
|
aria-label={t('event.createEvent')}
|
||||||
@@ -422,178 +430,184 @@ const CalendarApp: React.FC<CalendarAppProps> = ({
|
|||||||
</Fab>
|
</Fab>
|
||||||
)}
|
)}
|
||||||
{view === 'calendar' && (
|
{view === 'calendar' && (
|
||||||
<FullCalendar
|
<div ref={calendarWrapperRef} style={{ height: '100%' }}>
|
||||||
key={hiddenDays.join(',')}
|
<FullCalendar
|
||||||
ref={ref => {
|
key={hiddenDays.join(',')}
|
||||||
if (ref) {
|
ref={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}
|
||||||
plugins={[
|
editable={true}
|
||||||
dayGridPlugin,
|
locale={localeMap[lang]}
|
||||||
timeGridPlugin,
|
hiddenDays={hiddenDays}
|
||||||
interactionPlugin,
|
selectable={true}
|
||||||
momentTimezonePlugin
|
timeZone={timezone}
|
||||||
]}
|
height="100%"
|
||||||
initialView={
|
select={eventHandlers.handleDateSelect}
|
||||||
currentView ||
|
nowIndicator
|
||||||
(isTablet
|
slotLabelClassNames={arg => [
|
||||||
? CALENDAR_VIEWS.timeGridDay
|
updateSlotLabelVisibility(new Date(), arg, timezone)
|
||||||
: CALENDAR_VIEWS.timeGridWeek)
|
]}
|
||||||
}
|
nowIndicatorContent={viewHandlers.handleNowIndicatorContent}
|
||||||
firstDay={1}
|
headerToolbar={false}
|
||||||
editable={true}
|
views={{
|
||||||
locale={localeMap[lang]}
|
timeGridWeek: {
|
||||||
hiddenDays={hiddenDays}
|
titleFormat: { month: 'long', year: 'numeric' }
|
||||||
selectable={true}
|
}
|
||||||
timeZone={timezone}
|
}}
|
||||||
height="100%"
|
dayMaxEvents={true}
|
||||||
select={eventHandlers.handleDateSelect}
|
moreLinkClick={handleMoreLinkClick}
|
||||||
longPressDelay={100}
|
events={eventToFullCalendarFormat(
|
||||||
nowIndicator
|
filteredEvents,
|
||||||
slotLabelClassNames={arg => [
|
filteredTempEvents,
|
||||||
updateSlotLabelVisibility(new Date(), arg, timezone)
|
userId,
|
||||||
]}
|
userData?.email,
|
||||||
nowIndicatorContent={viewHandlers.handleNowIndicatorContent}
|
isPending,
|
||||||
headerToolbar={false}
|
calendars
|
||||||
views={{
|
)}
|
||||||
timeGridWeek: { titleFormat: { month: 'long', year: 'numeric' } }
|
eventOrder={(a: EventApi, b: EventApi) =>
|
||||||
}}
|
a.extendedProps.priority - b.extendedProps.priority
|
||||||
dayMaxEvents={true}
|
}
|
||||||
moreLinkClick={handleMoreLinkClick}
|
weekNumbers={
|
||||||
events={eventToFullCalendarFormat(
|
currentView === CALENDAR_VIEWS.timeGridWeek ||
|
||||||
filteredEvents,
|
currentView === CALENDAR_VIEWS.timeGridDay
|
||||||
filteredTempEvents,
|
}
|
||||||
userId,
|
weekNumberFormat={{ week: 'long' }}
|
||||||
userData?.email,
|
weekNumberContent={arg => {
|
||||||
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={calendarRef.current?.getDate() ?? new Date()}
|
|
||||||
onChange={(newTimezone: string) =>
|
|
||||||
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 (
|
return (
|
||||||
<span
|
<div className="weekSelector">
|
||||||
className={`fc-daygrid-day-number ${
|
{displayWeekNumbers && (
|
||||||
arg.isToday ? 'current-date' : ''
|
<div>
|
||||||
}`}
|
{t('menubar.views.week')} {arg.num}
|
||||||
>
|
</div>
|
||||||
{arg.dayNumberText === '1' ? month : ''} {arg.dayNumberText}
|
)}
|
||||||
</span>
|
<TimezoneSelector
|
||||||
|
value={timezone}
|
||||||
|
referenceDate={
|
||||||
|
calendarRef.current?.getDate() ?? new Date()
|
||||||
|
}
|
||||||
|
onChange={(newTimezone: string) =>
|
||||||
|
dispatch(setTimeZone(newTimezone))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}}
|
||||||
}}
|
dayCellContent={arg => {
|
||||||
slotDuration="00:30:00"
|
const month = arg.date.toLocaleDateString(t('locale'), {
|
||||||
slotLabelInterval="01:00:00"
|
month: 'short',
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always use the calendar's current date for consistency
|
|
||||||
if (onDateChange) {
|
|
||||||
onDateChange(calendarCurrentDate)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
|
|
||||||
const date = m.date()
|
|
||||||
const weekDay = m
|
|
||||||
.toDate()
|
|
||||||
.toLocaleDateString(t('locale'), {
|
|
||||||
weekday: 'short',
|
|
||||||
timeZone: timezone
|
timeZone: timezone
|
||||||
})
|
})
|
||||||
.toUpperCase()
|
if (arg.view.type === CALENDAR_VIEWS.dayGridMonth) {
|
||||||
|
return (
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`fc-daygrid-day-top ${isTablet || isMobile ? 'fc-daygrid-day-top--mobile' : ''}`}
|
|
||||||
>
|
|
||||||
<small>{weekDay}</small>
|
|
||||||
{arg.view.type !== CALENDAR_VIEWS.dayGridMonth && (
|
|
||||||
<span
|
<span
|
||||||
className={`fc-daygrid-day-number ${arg.isToday ? 'current-date' : ''}`}
|
className={`fc-daygrid-day-number ${
|
||||||
|
arg.isToday ? 'current-date' : ''
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{date}
|
{arg.dayNumberText === '1' ? month : ''}{' '}
|
||||||
|
{arg.dayNumberText}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)
|
||||||
</div>
|
}
|
||||||
)
|
}}
|
||||||
}}
|
slotDuration="00:30:00"
|
||||||
dayHeaderDidMount={viewHandlers.handleDayHeaderDidMount}
|
slotLabelInterval="01:00:00"
|
||||||
dayHeaderWillUnmount={viewHandlers.handleDayHeaderWillUnmount}
|
scrollTime="12:00:00"
|
||||||
viewDidMount={viewHandlers.handleViewDidMount}
|
unselectAuto={false}
|
||||||
viewWillUnmount={viewHandlers.handleViewWillUnmount}
|
allDayText=""
|
||||||
eventClick={eventHandlers.handleEventClick}
|
slotLabelFormat={{
|
||||||
eventAllow={eventHandlers.handleEventAllow}
|
hour: '2-digit',
|
||||||
eventDrop={arg => {
|
minute: '2-digit',
|
||||||
void eventHandlers.handleEventDrop(arg)
|
hour12: false
|
||||||
}}
|
}}
|
||||||
eventResize={arg => {
|
datesSet={arg => {
|
||||||
void eventHandlers.handleEventResize(arg)
|
onViewChange?.(arg.view.type)
|
||||||
}}
|
const calendarCurrentDate =
|
||||||
eventContent={viewHandlers.handleEventContent}
|
calendarRef.current?.getDate() || new Date(arg.start)
|
||||||
eventDidMount={viewHandlers.handleEventDidMount}
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always use the calendar's current date for consistency
|
||||||
|
if (onDateChange) {
|
||||||
|
onDateChange(calendarCurrentDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
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' : ''}`}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
)}
|
)}
|
||||||
{view === 'search' && <SearchResultsPage />}
|
{view === 'search' && <SearchResultsPage />}
|
||||||
<EventPopover
|
<EventPopover
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { DateSelectArg } from '@fullcalendar/core'
|
||||||
|
import { RefObject, useEffect } from 'react'
|
||||||
|
|
||||||
|
const MOVE_THRESHOLD = 10
|
||||||
|
const TAP_DURATION = 200
|
||||||
|
|
||||||
|
function isTapGesture(dx: number, dy: number, duration: number): boolean {
|
||||||
|
return dx < MOVE_THRESHOLD && dy < MOVE_THRESHOLD && duration < TAP_DURATION
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTimeAtPoint(x: number, y: number): string | null {
|
||||||
|
const el = document.elementFromPoint(x, y)
|
||||||
|
return (
|
||||||
|
(el as HTMLElement)?.closest('[data-time]')?.getAttribute('data-time') ??
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDateAtPoint(x: number): string | null {
|
||||||
|
const col = Array.from(document.querySelectorAll('[data-date]')).find(el => {
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
return x >= rect.left && x <= rect.right
|
||||||
|
})
|
||||||
|
return col?.getAttribute('data-date') ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSelectArg(date: string, time: string): DateSelectArg {
|
||||||
|
const start = new Date(`${date}T${time}`)
|
||||||
|
const end = new Date(start.getTime() + 30 * 60 * 1000)
|
||||||
|
return { start, end, allDay: false } as DateSelectArg
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTouchListener(
|
||||||
|
handleDateSelect: (selectInfo: DateSelectArg | null) => void,
|
||||||
|
isTouch: boolean,
|
||||||
|
wrapperRef: RefObject<HTMLDivElement>
|
||||||
|
): void {
|
||||||
|
useEffect(() => {
|
||||||
|
const el = wrapperRef.current
|
||||||
|
if (!el || !isTouch) return
|
||||||
|
|
||||||
|
let startX = 0,
|
||||||
|
startY = 0,
|
||||||
|
startTime = 0
|
||||||
|
|
||||||
|
const onTouchStart = (e: TouchEvent): void => {
|
||||||
|
startX = e.touches[0].clientX
|
||||||
|
startY = e.touches[0].clientY
|
||||||
|
startTime = Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onTouchEnd = (e: TouchEvent): void => {
|
||||||
|
const touch = e.changedTouches[0]
|
||||||
|
const dx = Math.abs(touch.clientX - startX)
|
||||||
|
const dy = Math.abs(touch.clientY - startY)
|
||||||
|
|
||||||
|
if (!isTapGesture(dx, dy, Date.now() - startTime)) return
|
||||||
|
|
||||||
|
const time = getTimeAtPoint(touch.clientX, touch.clientY)
|
||||||
|
const date = getDateAtPoint(touch.clientX)
|
||||||
|
|
||||||
|
if (time && date) {
|
||||||
|
handleDateSelect(buildSelectArg(date, time))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
el.addEventListener('touchstart', onTouchStart, { passive: true })
|
||||||
|
el.addEventListener('touchend', onTouchEnd, { passive: true })
|
||||||
|
|
||||||
|
return (): void => {
|
||||||
|
el.removeEventListener('touchstart', onTouchStart)
|
||||||
|
el.removeEventListener('touchend', onTouchEnd)
|
||||||
|
}
|
||||||
|
}, [handleDateSelect, isTouch, wrapperRef])
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user