feat: support resizing events from the top edge
This commit is contained in:
@@ -247,9 +247,11 @@ export function CalendarDayView({
|
||||
|
||||
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
|
||||
const durMin = Math.max(15, endMinutes - startMinutes);
|
||||
const top = (startMinutes / 60) * HOUR_HEIGHT;
|
||||
const baseTop = (startMinutes / 60) * HOUR_HEIGHT;
|
||||
const baseHeight = Math.max(24, (durMin / 60) * HOUR_HEIGHT);
|
||||
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
||||
const isResizing = resizeVisual?.eventId === ev.id;
|
||||
const top = isResizing ? resizeVisual!.topPx : baseTop;
|
||||
const height = isResizing ? resizeVisual!.heightPx : baseHeight;
|
||||
const calId = getPrimaryCalendarId(ev);
|
||||
const leftPct = (column / totalColumns) * 100;
|
||||
const widthPct = (1 / totalColumns) * 100;
|
||||
@@ -271,11 +273,21 @@ export function CalendarDayView({
|
||||
onContextMenu={onContextMenuEvent}
|
||||
draggable
|
||||
/>
|
||||
<div
|
||||
data-resize-handle
|
||||
className="absolute top-0 left-1 right-1 h-3 cursor-n-resize z-20 flex items-start justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
|
||||
aria-label={t("events.resize")}
|
||||
onPointerDown={(e) => handleResizePointerDown(ev.id, "top", startMinutes, durMin, e)}
|
||||
onPointerMove={handleResizePointerMove}
|
||||
onPointerUp={handleResizePointerUp}
|
||||
>
|
||||
<div className="w-8 h-1 rounded-full bg-foreground/30 mt-0.5" />
|
||||
</div>
|
||||
<div
|
||||
data-resize-handle
|
||||
className="absolute bottom-0 left-1 right-1 h-3 cursor-s-resize z-20 flex items-end justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
|
||||
aria-label={t("events.resize")}
|
||||
onPointerDown={(e) => handleResizePointerDown(ev.id, durMin, e)}
|
||||
onPointerDown={(e) => handleResizePointerDown(ev.id, "bottom", startMinutes, durMin, e)}
|
||||
onPointerMove={handleResizePointerMove}
|
||||
onPointerUp={handleResizePointerUp}
|
||||
>
|
||||
|
||||
@@ -385,9 +385,11 @@ export function CalendarWeekView({
|
||||
|
||||
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
|
||||
const durMin = Math.max(15, endMinutes - startMinutes);
|
||||
const top = (startMinutes / 60) * HOUR_HEIGHT;
|
||||
const baseTop = (startMinutes / 60) * HOUR_HEIGHT;
|
||||
const baseHeight = Math.max(20, (durMin / 60) * HOUR_HEIGHT);
|
||||
const height = resizeVisual?.eventId === ev.id ? resizeVisual.heightPx : baseHeight;
|
||||
const isResizing = resizeVisual?.eventId === ev.id;
|
||||
const top = isResizing ? resizeVisual!.topPx : baseTop;
|
||||
const height = isResizing ? resizeVisual!.heightPx : baseHeight;
|
||||
const calId = getPrimaryCalendarId(ev);
|
||||
const leftPct = (column / totalColumns) * 100;
|
||||
const widthPct = (1 / totalColumns) * 100;
|
||||
@@ -409,11 +411,21 @@ export function CalendarWeekView({
|
||||
onContextMenu={onContextMenuEvent}
|
||||
draggable
|
||||
/>
|
||||
<div
|
||||
data-resize-handle
|
||||
className="absolute top-0 left-1 right-1 h-3 cursor-n-resize z-20 flex items-start justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
|
||||
aria-label={t("events.resize")}
|
||||
onPointerDown={(e) => handleResizePointerDown(ev.id, "top", startMinutes, durMin, e)}
|
||||
onPointerMove={handleResizePointerMove}
|
||||
onPointerUp={handleResizePointerUp}
|
||||
>
|
||||
<div className="w-8 h-1 rounded-full bg-foreground/30 mt-0.5" />
|
||||
</div>
|
||||
<div
|
||||
data-resize-handle
|
||||
className="absolute bottom-0 left-1 right-1 h-3 cursor-s-resize z-20 flex items-end justify-center opacity-0 group-hover/event:opacity-100 transition-opacity"
|
||||
aria-label={t("events.resize")}
|
||||
onPointerDown={(e) => handleResizePointerDown(ev.id, durMin, e)}
|
||||
onPointerDown={(e) => handleResizePointerDown(ev.id, "bottom", startMinutes, durMin, e)}
|
||||
onPointerMove={handleResizePointerMove}
|
||||
onPointerUp={handleResizePointerUp}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback, useRef, type PointerEvent, type DragEvent } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
@@ -13,9 +13,13 @@ interface DragCreateState {
|
||||
endMinutes: number;
|
||||
}
|
||||
|
||||
export type ResizeEdge = "top" | "bottom";
|
||||
|
||||
interface ResizeState {
|
||||
eventId: string;
|
||||
topPx: number;
|
||||
heightPx: number;
|
||||
startMinutes: number;
|
||||
durationMinutes: number;
|
||||
}
|
||||
|
||||
@@ -134,43 +138,65 @@ export function useTimeGridInteractions({
|
||||
// --- Resize ---
|
||||
const resizeRef = useRef<{
|
||||
eventId: string;
|
||||
edge: ResizeEdge;
|
||||
startY: number;
|
||||
originalStartMinutes: number;
|
||||
originalDurationMinutes: number;
|
||||
originalHeightPx: 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();
|
||||
|
||||
const originalHeightPx = Math.max(20, (originalDurationMinutes / 60) * hourHeight);
|
||||
resizeRef.current = {
|
||||
eventId,
|
||||
edge,
|
||||
startY: e.clientY,
|
||||
originalStartMinutes,
|
||||
originalDurationMinutes,
|
||||
originalHeightPx,
|
||||
pointerId: e.pointerId,
|
||||
};
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}, [hourHeight]);
|
||||
}, []);
|
||||
|
||||
const handleResizePointerMove = useCallback((e: PointerEvent) => {
|
||||
if (!resizeRef.current) return;
|
||||
|
||||
const deltaY = e.clientY - resizeRef.current.startY;
|
||||
const newHeightPx = Math.max(hourHeight / 4, resizeRef.current.originalHeightPx + deltaY);
|
||||
const newDurationMinutes = Math.max(15, Math.round((newHeightPx / hourHeight) * 60 / 15) * 15);
|
||||
const snappedHeight = (newDurationMinutes / 60) * hourHeight;
|
||||
|
||||
setResizeVisual({ eventId: resizeRef.current.eventId, heightPx: snappedHeight, durationMinutes: newDurationMinutes });
|
||||
}, [hourHeight]);
|
||||
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;
|
||||
@@ -183,11 +209,11 @@ export function useTimeGridInteractions({
|
||||
|
||||
try { (e.target as HTMLElement).releasePointerCapture(resize.pointerId); } catch { /* may already be released */ }
|
||||
|
||||
const deltaY = e.clientY - resize.startY;
|
||||
const newHeightPx = Math.max(hourHeight / 4, resize.originalHeightPx + deltaY);
|
||||
const newDurationMinutes = Math.max(15, Math.round((newHeightPx / hourHeight) * 60 / 15) * 15);
|
||||
const { startMinutes: newStartMinutes, durationMinutes: newDurationMinutes } = computeResize(resize, e.clientY);
|
||||
|
||||
if (newDurationMinutes === resize.originalDurationMinutes) {
|
||||
const startChanged = newStartMinutes !== resize.originalStartMinutes;
|
||||
const durationChanged = newDurationMinutes !== resize.originalDurationMinutes;
|
||||
if (!startChanged && !durationChanged) {
|
||||
setResizeVisual(null);
|
||||
return;
|
||||
}
|
||||
@@ -209,14 +235,21 @@ export function useTimeGridInteractions({
|
||||
try {
|
||||
const event = useCalendarStore.getState().events.find(ev => ev.id === resize.eventId);
|
||||
const hasParticipants = event?.participants && Object.keys(event.participants).length > 0;
|
||||
await useCalendarStore.getState().updateEvent(client, resize.eventId, { duration: dur }, hasParticipants || undefined);
|
||||
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);
|
||||
}
|
||||
}, [hourHeight, errorMessages.resize]);
|
||||
}, [computeResize, errorMessages.resize]);
|
||||
|
||||
// --- Click / Double-click / Quick-create ---
|
||||
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
Reference in New Issue
Block a user