397 lines
13 KiB
TypeScript
397 lines
13 KiB
TypeScript
import { useState, useCallback, useRef, type PointerEvent, type DragEvent } from "react";
|
|
import { format, parseISO } from "date-fns";
|
|
import { useAuthStore } from "@/stores/auth-store";
|
|
import { useCalendarStore } from "@/stores/calendar-store";
|
|
import { toast } from "@/stores/toast-store";
|
|
import { debug } from "@/lib/debug";
|
|
import { formatIsoInTimeZone } from "@/lib/calendar-utils";
|
|
import type { Calendar } from "@/lib/jmap/types";
|
|
|
|
interface DragCreateState {
|
|
dayKey: string;
|
|
startMinutes: number;
|
|
endMinutes: number;
|
|
}
|
|
|
|
export type ResizeEdge = "top" | "bottom";
|
|
|
|
interface ResizeState {
|
|
eventId: string;
|
|
topPx: number;
|
|
heightPx: number;
|
|
startMinutes: number;
|
|
durationMinutes: number;
|
|
}
|
|
|
|
export interface QuickCreateState {
|
|
dayKey: string;
|
|
day: Date;
|
|
hour: number;
|
|
top: number;
|
|
}
|
|
|
|
interface DropTargetState {
|
|
dayKey: string;
|
|
minutes: number;
|
|
}
|
|
|
|
interface UseTimeGridInteractionsOptions {
|
|
hourHeight: number;
|
|
calendars: Calendar[];
|
|
onCreateRange: (startDate: Date, endDate?: Date) => void;
|
|
errorMessages: {
|
|
resize: string;
|
|
move: string;
|
|
created: string;
|
|
error: string;
|
|
};
|
|
isMobile?: boolean;
|
|
}
|
|
|
|
export function useTimeGridInteractions({
|
|
hourHeight,
|
|
calendars,
|
|
onCreateRange,
|
|
errorMessages,
|
|
isMobile,
|
|
}: UseTimeGridInteractionsOptions) {
|
|
const snapToMinutes = useCallback((clientY: number, containerTop: number): number => {
|
|
const raw = ((clientY - containerTop) / hourHeight) * 60;
|
|
return Math.max(0, Math.min(1440, Math.round(raw / 15) * 15));
|
|
}, [hourHeight]);
|
|
|
|
const wasDragging = useRef(false);
|
|
|
|
// --- Drag-to-create ---
|
|
const dragRef = useRef<{
|
|
dayKey: string;
|
|
dayDate: Date;
|
|
startMinutes: number;
|
|
pointerId: number;
|
|
startY: number;
|
|
captured: boolean;
|
|
} | null>(null);
|
|
|
|
const [dragCreate, setDragCreate] = useState<DragCreateState | null>(null);
|
|
|
|
const handleGridPointerDown = useCallback((
|
|
e: PointerEvent<HTMLDivElement>,
|
|
dayKey: string,
|
|
dayDate: Date,
|
|
) => {
|
|
if (isMobile) return;
|
|
if (e.button !== 0) return;
|
|
if ((e.target as HTMLElement).closest("[data-calendar-event], [data-resize-handle]")) return;
|
|
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const minutes = snapToMinutes(e.clientY, rect.top);
|
|
|
|
dragRef.current = {
|
|
dayKey, dayDate, startMinutes: minutes,
|
|
pointerId: e.pointerId, startY: e.clientY, captured: false,
|
|
};
|
|
}, [snapToMinutes, isMobile]);
|
|
|
|
const handleGridPointerMove = useCallback((e: PointerEvent<HTMLDivElement>) => {
|
|
if (!dragRef.current) return;
|
|
|
|
if (!dragRef.current.captured) {
|
|
if (Math.abs(e.clientY - dragRef.current.startY) < 5) return;
|
|
dragRef.current.captured = true;
|
|
e.currentTarget.setPointerCapture(dragRef.current.pointerId);
|
|
}
|
|
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const currentMinutes = snapToMinutes(e.clientY, rect.top);
|
|
const start = Math.min(dragRef.current.startMinutes, currentMinutes);
|
|
const end = Math.max(dragRef.current.startMinutes, currentMinutes);
|
|
|
|
setDragCreate(end > start ? { dayKey: dragRef.current.dayKey, startMinutes: start, endMinutes: end } : null);
|
|
}, [snapToMinutes]);
|
|
|
|
const handleGridPointerUp = useCallback((e: PointerEvent<HTMLDivElement>) => {
|
|
const drag = dragRef.current;
|
|
dragRef.current = null;
|
|
setDragCreate(null);
|
|
|
|
if (!drag || !drag.captured) return;
|
|
|
|
wasDragging.current = true;
|
|
requestAnimationFrame(() => { wasDragging.current = false; });
|
|
|
|
try { e.currentTarget.releasePointerCapture(drag.pointerId); } catch { /* may already be released */ }
|
|
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const endMinutes = snapToMinutes(e.clientY, rect.top);
|
|
const start = Math.min(drag.startMinutes, endMinutes);
|
|
const end = Math.max(drag.startMinutes, endMinutes);
|
|
|
|
if (end - start < 15) return;
|
|
|
|
const startDate = new Date(drag.dayDate);
|
|
startDate.setHours(Math.floor(start / 60), start % 60, 0, 0);
|
|
const endDate = new Date(drag.dayDate);
|
|
endDate.setHours(Math.floor(end / 60), end % 60, 0, 0);
|
|
onCreateRange(startDate, endDate);
|
|
}, [snapToMinutes, onCreateRange]);
|
|
|
|
// --- Resize ---
|
|
const resizeRef = useRef<{
|
|
eventId: string;
|
|
edge: ResizeEdge;
|
|
startY: number;
|
|
originalStartMinutes: number;
|
|
originalDurationMinutes: number;
|
|
pointerId: number;
|
|
} | null>(null);
|
|
|
|
const [resizeVisual, setResizeVisual] = useState<ResizeState | null>(null);
|
|
|
|
const computeResize = useCallback((
|
|
ref: NonNullable<typeof resizeRef.current>,
|
|
clientY: number,
|
|
): { startMinutes: number; durationMinutes: number } => {
|
|
const deltaY = clientY - ref.startY;
|
|
const deltaMinutes = Math.round((deltaY / hourHeight) * 60 / 15) * 15;
|
|
const originalEnd = ref.originalStartMinutes + ref.originalDurationMinutes;
|
|
|
|
if (ref.edge === "bottom") {
|
|
const newDuration = Math.max(15, ref.originalDurationMinutes + deltaMinutes);
|
|
return { startMinutes: ref.originalStartMinutes, durationMinutes: newDuration };
|
|
}
|
|
// Top edge: move start, keep end fixed. Clamp so duration stays >= 15 and start >= 0.
|
|
let newStart = ref.originalStartMinutes + deltaMinutes;
|
|
newStart = Math.max(0, Math.min(originalEnd - 15, newStart));
|
|
return { startMinutes: newStart, durationMinutes: originalEnd - newStart };
|
|
}, [hourHeight]);
|
|
|
|
const handleResizePointerDown = useCallback((
|
|
eventId: string,
|
|
edge: ResizeEdge,
|
|
originalStartMinutes: number,
|
|
originalDurationMinutes: number,
|
|
e: PointerEvent,
|
|
) => {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
|
|
resizeRef.current = {
|
|
eventId,
|
|
edge,
|
|
startY: e.clientY,
|
|
originalStartMinutes,
|
|
originalDurationMinutes,
|
|
pointerId: e.pointerId,
|
|
};
|
|
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
|
}, []);
|
|
|
|
const handleResizePointerMove = useCallback((e: PointerEvent) => {
|
|
if (!resizeRef.current) return;
|
|
const { startMinutes, durationMinutes } = computeResize(resizeRef.current, e.clientY);
|
|
setResizeVisual({
|
|
eventId: resizeRef.current.eventId,
|
|
topPx: (startMinutes / 60) * hourHeight,
|
|
heightPx: (durationMinutes / 60) * hourHeight,
|
|
startMinutes,
|
|
durationMinutes,
|
|
});
|
|
}, [hourHeight, computeResize]);
|
|
|
|
const handleResizePointerUp = useCallback(async (e: PointerEvent) => {
|
|
const resize = resizeRef.current;
|
|
resizeRef.current = null;
|
|
|
|
if (!resize) return;
|
|
|
|
wasDragging.current = true;
|
|
requestAnimationFrame(() => { wasDragging.current = false; });
|
|
|
|
try { (e.target as HTMLElement).releasePointerCapture(resize.pointerId); } catch { /* may already be released */ }
|
|
|
|
const { startMinutes: newStartMinutes, durationMinutes: newDurationMinutes } = computeResize(resize, e.clientY);
|
|
|
|
const startChanged = newStartMinutes !== resize.originalStartMinutes;
|
|
const durationChanged = newDurationMinutes !== resize.originalDurationMinutes;
|
|
if (!startChanged && !durationChanged) {
|
|
setResizeVisual(null);
|
|
return;
|
|
}
|
|
|
|
const hours = Math.floor(newDurationMinutes / 60);
|
|
const mins = newDurationMinutes % 60;
|
|
let dur = "PT";
|
|
if (hours > 0) dur += `${hours}H`;
|
|
if (mins > 0) dur += `${mins}M`;
|
|
if (dur === "PT") dur = "PT0M";
|
|
|
|
const client = useAuthStore.getState().client;
|
|
if (!client) {
|
|
setResizeVisual(null);
|
|
toast.error(errorMessages.resize);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const event = useCalendarStore.getState().events.find(ev => ev.id === resize.eventId);
|
|
const hasParticipants = event?.participants && Object.keys(event.participants).length > 0;
|
|
const updates: { start?: string; duration: string } = { duration: dur };
|
|
if (startChanged && event?.start) {
|
|
// Shift the event's floating `start` wall-clock by the delta (preserves event.timeZone).
|
|
const deltaMinutes = newStartMinutes - resize.originalStartMinutes;
|
|
const shifted = new Date(parseISO(event.start).getTime() + deltaMinutes * 60000);
|
|
updates.start = format(shifted, "yyyy-MM-dd'T'HH:mm:ss");
|
|
}
|
|
await useCalendarStore.getState().updateEvent(client, resize.eventId, updates, hasParticipants || undefined);
|
|
} catch (error) {
|
|
debug.error("Failed to resize event:", resize.eventId, error);
|
|
toast.error(errorMessages.resize);
|
|
} finally {
|
|
setResizeVisual(null);
|
|
}
|
|
}, [computeResize, errorMessages.resize]);
|
|
|
|
// --- Click / Double-click / Quick-create ---
|
|
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const [quickCreate, setQuickCreate] = useState<QuickCreateState | null>(null);
|
|
|
|
const handleSlotClick = useCallback((day: Date, hour: number) => {
|
|
if (wasDragging.current) return;
|
|
if (clickTimerRef.current) {
|
|
clearTimeout(clickTimerRef.current);
|
|
clickTimerRef.current = null;
|
|
return;
|
|
}
|
|
clickTimerRef.current = setTimeout(() => {
|
|
clickTimerRef.current = null;
|
|
const d = new Date(day);
|
|
d.setHours(hour, 0, 0, 0);
|
|
onCreateRange(d);
|
|
}, 250);
|
|
}, [onCreateRange]);
|
|
|
|
const handleSlotDoubleClick = useCallback((day: Date, hour: number) => {
|
|
if (wasDragging.current) return;
|
|
if (clickTimerRef.current) {
|
|
clearTimeout(clickTimerRef.current);
|
|
clickTimerRef.current = null;
|
|
}
|
|
const d = new Date(day);
|
|
d.setHours(hour, 0, 0, 0);
|
|
onCreateRange(d);
|
|
}, [onCreateRange]);
|
|
|
|
const handleQuickCreateSubmit = useCallback(async (title: string) => {
|
|
if (!quickCreate) return;
|
|
const client = useAuthStore.getState().client;
|
|
if (!client) {
|
|
setQuickCreate(null);
|
|
toast.error(errorMessages.error);
|
|
return;
|
|
}
|
|
try {
|
|
const startDate = new Date(quickCreate.day);
|
|
startDate.setHours(quickCreate.hour, 0, 0, 0);
|
|
const startStr = format(startDate, "yyyy-MM-dd'T'HH:mm:ss");
|
|
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
const defaultCal = calendars.find(c => c.isDefault) || calendars[0];
|
|
const created = await useCalendarStore.getState().createEvent(client, {
|
|
title,
|
|
start: startStr,
|
|
duration: "PT1H",
|
|
timeZone,
|
|
calendarIds: defaultCal ? { [defaultCal.id]: true } : {},
|
|
status: "confirmed",
|
|
freeBusyStatus: "busy",
|
|
privacy: "public",
|
|
});
|
|
setQuickCreate(null);
|
|
if (created) toast.success(errorMessages.created);
|
|
else toast.error(errorMessages.error);
|
|
} catch (error) {
|
|
debug.error("Failed to quick-create event:", error);
|
|
setQuickCreate(null);
|
|
toast.error(errorMessages.error);
|
|
}
|
|
}, [quickCreate, calendars, errorMessages.created, errorMessages.error]);
|
|
|
|
const handleQuickCreateCancel = useCallback(() => {
|
|
setQuickCreate(null);
|
|
}, []);
|
|
|
|
// --- DnD drop ---
|
|
const [dropTarget, setDropTarget] = useState<DropTargetState | null>(null);
|
|
|
|
const snapDragMinutes = useCallback((e: DragEvent<HTMLDivElement>): number => {
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const y = e.clientY - rect.top;
|
|
const raw = (y / hourHeight) * 60;
|
|
return Math.max(0, Math.min(1425, Math.round(raw / 15) * 15));
|
|
}, [hourHeight]);
|
|
|
|
const handleColumnDragOver = useCallback((e: DragEvent<HTMLDivElement>, dayKey: string) => {
|
|
if (!e.dataTransfer.types.includes("application/x-calendar-event")) return;
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = "move";
|
|
const minutes = snapDragMinutes(e);
|
|
setDropTarget((prev) =>
|
|
prev?.dayKey === dayKey && prev?.minutes === minutes ? prev : { dayKey, minutes }
|
|
);
|
|
}, [snapDragMinutes]);
|
|
|
|
const handleColumnDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
|
|
const related = e.relatedTarget as Node | null;
|
|
if (!e.currentTarget.contains(related)) setDropTarget(null);
|
|
}, []);
|
|
|
|
const handleColumnDrop = useCallback(async (e: DragEvent<HTMLDivElement>, day: Date) => {
|
|
e.preventDefault();
|
|
setDropTarget(null);
|
|
const json = e.dataTransfer.getData("application/x-calendar-event");
|
|
if (!json) return;
|
|
try {
|
|
const data = JSON.parse(json);
|
|
const minutes = snapDragMinutes(e);
|
|
const newStart = new Date(day);
|
|
newStart.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0);
|
|
const event = useCalendarStore.getState().events.find(ev => ev.id === data.eventId);
|
|
// Emit `start` as a floating wall-clock in the event's own timeZone.
|
|
// If we used browser-local, the server would reinterpret it in event.timeZone
|
|
// and shift the event by the offset between the two zones.
|
|
const eventTimeZone = event?.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
const newStartISO = formatIsoInTimeZone(newStart, eventTimeZone);
|
|
if (newStartISO === data.originalStart) return;
|
|
const client = useAuthStore.getState().client;
|
|
if (!client) {
|
|
toast.error(errorMessages.move);
|
|
return;
|
|
}
|
|
const hasParticipants = event?.participants && Object.keys(event.participants).length > 0;
|
|
await useCalendarStore.getState().updateEvent(client, data.eventId, { start: newStartISO }, hasParticipants || undefined);
|
|
} catch {
|
|
toast.error(errorMessages.move);
|
|
}
|
|
}, [snapDragMinutes, errorMessages.move]);
|
|
|
|
return {
|
|
dragCreate,
|
|
handleGridPointerDown,
|
|
handleGridPointerMove,
|
|
handleGridPointerUp,
|
|
resizeVisual,
|
|
handleResizePointerDown,
|
|
handleResizePointerMove,
|
|
handleResizePointerUp,
|
|
wasDragging,
|
|
quickCreate,
|
|
handleSlotClick,
|
|
handleSlotDoubleClick,
|
|
handleQuickCreateSubmit,
|
|
handleQuickCreateCancel,
|
|
dropTarget,
|
|
handleColumnDragOver,
|
|
handleColumnDragLeave,
|
|
handleColumnDrop,
|
|
};
|
|
}
|