import { computed, ref, onUnmounted, type Ref, type ComputedRef } from 'vue'; import type { Dayjs } from 'dayjs'; import type { TimeEntry } from '@/packages/api/src'; import { getDayJsInstance, getLocalizedDayJs } from '../utils/time'; import { getUserTimezone } from '../utils/settings'; import type { CalendarSettings } from './calendarSettings'; import type { CalendarEvent, DayEvent } from './calendarTypes'; import { SLOT_HEIGHT } from './calendarTypes'; function snapTo(value: number, step: number): number { return Math.round(value / step) * step; } function dayMidnightLocal(dayStr: string): Dayjs { return getDayJsInstance()(`${dayStr}T00:00:00`).tz(getUserTimezone(), true); } export function useEventResize(params: { calendarSettings: Ref; viewDays: ComputedRef; eventsByDay: ComputedRef>; optimisticOverrides: Ref>; updateTimeEntry: (entry: TimeEntry) => Promise; emitRefresh: () => void; minutesToPixels: (minutes: number) => number; pixelsToMinutesFromMidnight: (px: number) => number; getDayFromClientX: (clientX: number) => string | null; clientYToGridPixels: (clientY: number) => number; }) { const isResizing = ref(false); const resizeEventId = ref(null); const resizeEdge = ref<'start' | 'end'>('end'); const resizeCurrentTop = ref(0); const resizeCurrentHeight = ref(0); const resizeCurrentDay = ref(null); // Reactive so resizeLiveDurationSeconds recomputes during cross-day resize const lastResizeClientY = ref(0); let resizeOriginalEvent: CalendarEvent | null = null; let resizeOriginalTop = 0; let resizeOriginalHeight = 0; let resizeOriginalDayStr = ''; function getGridConstants() { const s = params.calendarSettings.value; const startMin = s.startHour * 60; const endMin = s.endHour * 60; const snapPx = (s.snapMinutes / s.slotMinutes) * SLOT_HEIGHT; const totalGridPx = params.minutesToPixels(endMin - startMin); return { s, startMin, endMin, snapPx, totalGridPx }; } /** * Compute the resolved start/end times for the current resize state. * Used by both resizeLiveDurationSeconds and onResizePointerUp to avoid * duplicating the 4-way (edge × cross-day) branching logic. * * For cross-day resizes, the cursor position (clientY) determines the * snapped time on the target day. For same-day resizes, the already-snapped * pixel state (resizeCurrentTop/Height) is used instead. */ function computeResizedTimes(clientY: number): { start: Dayjs; end: Dayjs } | null { if (!resizeOriginalEvent) return null; const { s } = getGridConstants(); const d = getDayJsInstance(); const isCrossDay = resizeCurrentDay.value !== null && resizeCurrentDay.value !== resizeOriginalDayStr; function snappedMinutesFromCursor(): number { return snapTo( params.pixelsToMinutesFromMidnight(params.clientYToGridPixels(clientY)), s.snapMinutes ); } if (resizeEdge.value === 'end') { const start = d(resizeOriginalEvent.timeEntry.start); const endDay = isCrossDay && resizeCurrentDay.value ? resizeCurrentDay.value : resizeOriginalDayStr; const endMinutes = isCrossDay && resizeCurrentDay.value ? snappedMinutesFromCursor() : snapTo( params.pixelsToMinutesFromMidnight( resizeCurrentTop.value + resizeCurrentHeight.value ), s.snapMinutes ); return { start, end: dayMidnightLocal(endDay).add(endMinutes, 'minute') }; } else { const end = resizeOriginalEvent.isRunning ? getLocalizedDayJs() : d(resizeOriginalEvent.timeEntry.end!); const startDay = isCrossDay && resizeCurrentDay.value ? resizeCurrentDay.value : resizeOriginalDayStr; const startMinutes = isCrossDay && resizeCurrentDay.value ? snappedMinutesFromCursor() : snapTo( params.pixelsToMinutesFromMidnight(resizeCurrentTop.value), s.snapMinutes ); return { start: dayMidnightLocal(startDay).add(startMinutes, 'minute'), end }; } } function onResizerPointerDown( e: PointerEvent, ev: CalendarEvent, dayEvent: DayEvent, edge: 'start' | 'end', dayStr: string ) { e.preventDefault(); e.stopPropagation(); resizeOriginalEvent = ev; resizeEdge.value = edge; resizeOriginalTop = dayEvent.top; resizeOriginalHeight = dayEvent.height; resizeEventId.value = ev.id; isResizing.value = true; resizeCurrentTop.value = dayEvent.top; resizeCurrentHeight.value = dayEvent.height; resizeOriginalDayStr = dayStr; resizeCurrentDay.value = dayStr; document.body.classList.add('fc-resizing-active'); document.addEventListener('pointermove', onResizePointerMove); document.addEventListener('pointerup', onResizePointerUp); } function onResizePointerMove(e: PointerEvent) { if (!resizeOriginalEvent) return; const { s, snapPx, totalGridPx } = getGridConstants(); const dayStr = params.getDayFromClientX(e.clientX); if (dayStr) resizeCurrentDay.value = dayStr; lastResizeClientY.value = e.clientY; const isCrossDay = resizeCurrentDay.value !== resizeOriginalDayStr; const cursorSnapped = snapTo(params.clientYToGridPixels(e.clientY), snapPx); if (resizeEdge.value === 'end') { if (isCrossDay && resizeCurrentDay.value) { resizeCurrentHeight.value = resizeCurrentDay.value > resizeOriginalDayStr ? totalGridPx - resizeOriginalTop : snapPx; } else { const snappedEnd = Math.max( resizeOriginalTop + snapPx, Math.min(cursorSnapped, totalGridPx) ); resizeCurrentHeight.value = snappedEnd - resizeOriginalTop; } } else { let maxTopForRunning = Infinity; if (resizeOriginalEvent.isRunning) { const now = getLocalizedDayJs(); const nowMinutes = now.hour() * 60 + now.minute() + now.second() / 60; maxTopForRunning = params.minutesToPixels( Math.min(nowMinutes, s.endHour * 60) - s.startHour * 60 ); } if (isCrossDay && resizeCurrentDay.value) { if (resizeCurrentDay.value < resizeOriginalDayStr) { resizeCurrentTop.value = 0; resizeCurrentHeight.value = resizeOriginalTop + resizeOriginalHeight; } else if (!resizeOriginalEvent.isRunning) { const bottom = resizeOriginalTop + resizeOriginalHeight; resizeCurrentTop.value = bottom - snapPx; resizeCurrentHeight.value = snapPx; } } else { const bottom = resizeOriginalTop + resizeOriginalHeight; const upperLimit = Math.min(bottom - snapPx, maxTopForRunning); const snappedStart = Math.max(0, Math.min(cursorSnapped, upperLimit)); resizeCurrentTop.value = snappedStart; resizeCurrentHeight.value = bottom - snappedStart; } } } const resizeCrossDayPreviewsByDay = computed>>(() => { if (!isResizing.value || !resizeOriginalEvent) return {}; if (resizeCurrentDay.value === resizeOriginalDayStr) return {}; const { startMin, snapPx, totalGridPx } = getGridConstants(); const snappedY = snapTo(params.clientYToGridPixels(lastResizeClientY.value), snapPx); const baseStyle = { position: 'absolute', left: '0', right: '0', backgroundColor: resizeOriginalEvent.backgroundColor, borderColor: resizeOriginalEvent.borderColor, opacity: '0.5', zIndex: '100', borderRadius: 'calc(var(--radius) - 4px)', border: '1px solid var(--border)', }; const result: Record> = {}; const allDays = params.viewDays.value.map((d) => d.format('YYYY-MM-DD')); const originIdx = allDays.indexOf(resizeOriginalDayStr); const cursorIdx = allDays.indexOf(resizeCurrentDay.value!); if (originIdx < 0 || cursorIdx < 0) return {}; const minIdx = Math.min(originIdx, cursorIdx); const maxIdx = Math.max(originIdx, cursorIdx); for (let i = minIdx; i <= maxIdx; i++) { const dayStr = allDays[i]!; if (dayStr === resizeOriginalDayStr) continue; if (dayStr === resizeCurrentDay.value) { if (resizeEdge.value === 'end') { const eventStartDay = resizeOriginalEvent.dayStart.format('YYYY-MM-DD'); let previewTop = 0; if (dayStr === eventStartDay) { const eventStartMinutes = resizeOriginalEvent.dayStart.hour() * 60 + resizeOriginalEvent.dayStart.minute(); previewTop = params.minutesToPixels( Math.max(0, eventStartMinutes - startMin) ); } const clampedY = Math.max(previewTop + snapPx, Math.min(snappedY, totalGridPx)); result[dayStr] = { ...baseStyle, top: `${previewTop}px`, height: `${clampedY - previewTop}px`, }; } else { const eventEndDay = resizeOriginalEvent.dayEnd.format('YYYY-MM-DD'); let previewBottom = totalGridPx; if (dayStr === eventEndDay) { const eventEndMinutes = resizeOriginalEvent.dayEnd.hour() * 60 + resizeOriginalEvent.dayEnd.minute(); previewBottom = params.minutesToPixels( Math.max(0, eventEndMinutes - startMin) ); } const clampedY = Math.max(0, Math.min(snappedY, previewBottom - snapPx)); result[dayStr] = { ...baseStyle, top: `${clampedY}px`, height: `${previewBottom - clampedY}px`, }; } } else { result[dayStr] = { ...baseStyle, top: '0px', height: `${totalGridPx}px` }; } } return result; }); const resizeLiveDurationSeconds = computed(() => { if (!isResizing.value || !resizeOriginalEvent) return null; const times = computeResizedTimes(lastResizeClientY.value); if (!times) return null; const diff = times.end.diff(times.start, 'second'); return diff > 0 ? diff : 0; }); function getResizeOriginalDayStr(): string { return resizeOriginalDayStr; } function resetResizeState() { resizeEventId.value = null; resizeCurrentDay.value = null; } async function onResizePointerUp(e: PointerEvent) { document.removeEventListener('pointermove', onResizePointerMove); document.removeEventListener('pointerup', onResizePointerUp); document.body.classList.remove('fc-resizing-active'); const times = computeResizedTimes(e.clientY); isResizing.value = false; if (!resizeOriginalEvent || !times) { resetResizeState(); return; } const timeEntry = resizeOriginalEvent.timeEntry; const isRunning = resizeOriginalEvent.isRunning; const updatedTimeEntry = { ...timeEntry, start: resizeEdge.value === 'start' ? times.start.utc().format() : timeEntry.start, end: resizeEdge.value === 'end' ? times.end.utc().format() : isRunning ? null : timeEntry.end, } as TimeEntry; const d = getDayJsInstance(); // Prevent end before start if ( updatedTimeEntry.end !== null && !d(updatedTimeEntry.end).isAfter(d(updatedTimeEntry.start)) ) { resetResizeState(); return; } // Prevent start in the future for running entries if (updatedTimeEntry.end === null && d(updatedTimeEntry.start).isAfter(d())) { resetResizeState(); return; } resetResizeState(); params.optimisticOverrides.value = new Map(params.optimisticOverrides.value).set( updatedTimeEntry.id, updatedTimeEntry ); try { await params.updateTimeEntry(updatedTimeEntry); } catch { const reverted = new Map(params.optimisticOverrides.value); reverted.delete(updatedTimeEntry.id); params.optimisticOverrides.value = reverted; } params.emitRefresh(); } onUnmounted(() => { document.removeEventListener('pointermove', onResizePointerMove); document.removeEventListener('pointerup', onResizePointerUp); document.body.classList.remove('fc-resizing-active'); }); return { isResizing, resizeEventId, resizeEdge, resizeCurrentTop, resizeCurrentHeight, resizeCurrentDay, resizeCrossDayPreviewsByDay, resizeLiveDurationSeconds, getResizeOriginalDayStr, onResizerPointerDown, }; }