feat: support resizing events from the top edge

This commit is contained in:
Linus Rath
2026-04-22 21:14:28 +02:00
parent cab57f6cd7
commit 6503482b55
3 changed files with 82 additions and 25 deletions
+15 -3
View File
@@ -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}
>
+15 -3
View File
@@ -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}
>
+52 -19
View File
@@ -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);