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 }) => {
|
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
|
||||||
const durMin = Math.max(15, endMinutes - startMinutes);
|
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 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 calId = getPrimaryCalendarId(ev);
|
||||||
const leftPct = (column / totalColumns) * 100;
|
const leftPct = (column / totalColumns) * 100;
|
||||||
const widthPct = (1 / totalColumns) * 100;
|
const widthPct = (1 / totalColumns) * 100;
|
||||||
@@ -271,11 +273,21 @@ export function CalendarDayView({
|
|||||||
onContextMenu={onContextMenuEvent}
|
onContextMenu={onContextMenuEvent}
|
||||||
draggable
|
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
|
<div
|
||||||
data-resize-handle
|
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"
|
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")}
|
aria-label={t("events.resize")}
|
||||||
onPointerDown={(e) => handleResizePointerDown(ev.id, durMin, e)}
|
onPointerDown={(e) => handleResizePointerDown(ev.id, "bottom", startMinutes, durMin, e)}
|
||||||
onPointerMove={handleResizePointerMove}
|
onPointerMove={handleResizePointerMove}
|
||||||
onPointerUp={handleResizePointerUp}
|
onPointerUp={handleResizePointerUp}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -385,9 +385,11 @@ export function CalendarWeekView({
|
|||||||
|
|
||||||
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
|
{layouted.map(({ event: ev, column, totalColumns, startMinutes, endMinutes }) => {
|
||||||
const durMin = Math.max(15, endMinutes - startMinutes);
|
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 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 calId = getPrimaryCalendarId(ev);
|
||||||
const leftPct = (column / totalColumns) * 100;
|
const leftPct = (column / totalColumns) * 100;
|
||||||
const widthPct = (1 / totalColumns) * 100;
|
const widthPct = (1 / totalColumns) * 100;
|
||||||
@@ -409,11 +411,21 @@ export function CalendarWeekView({
|
|||||||
onContextMenu={onContextMenuEvent}
|
onContextMenu={onContextMenuEvent}
|
||||||
draggable
|
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
|
<div
|
||||||
data-resize-handle
|
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"
|
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")}
|
aria-label={t("events.resize")}
|
||||||
onPointerDown={(e) => handleResizePointerDown(ev.id, durMin, e)}
|
onPointerDown={(e) => handleResizePointerDown(ev.id, "bottom", startMinutes, durMin, e)}
|
||||||
onPointerMove={handleResizePointerMove}
|
onPointerMove={handleResizePointerMove}
|
||||||
onPointerUp={handleResizePointerUp}
|
onPointerUp={handleResizePointerUp}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useCallback, useRef, type PointerEvent, type DragEvent } from "react";
|
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 { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useCalendarStore } from "@/stores/calendar-store";
|
import { useCalendarStore } from "@/stores/calendar-store";
|
||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
@@ -13,9 +13,13 @@ interface DragCreateState {
|
|||||||
endMinutes: number;
|
endMinutes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ResizeEdge = "top" | "bottom";
|
||||||
|
|
||||||
interface ResizeState {
|
interface ResizeState {
|
||||||
eventId: string;
|
eventId: string;
|
||||||
|
topPx: number;
|
||||||
heightPx: number;
|
heightPx: number;
|
||||||
|
startMinutes: number;
|
||||||
durationMinutes: number;
|
durationMinutes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,43 +138,65 @@ export function useTimeGridInteractions({
|
|||||||
// --- Resize ---
|
// --- Resize ---
|
||||||
const resizeRef = useRef<{
|
const resizeRef = useRef<{
|
||||||
eventId: string;
|
eventId: string;
|
||||||
|
edge: ResizeEdge;
|
||||||
startY: number;
|
startY: number;
|
||||||
|
originalStartMinutes: number;
|
||||||
originalDurationMinutes: number;
|
originalDurationMinutes: number;
|
||||||
originalHeightPx: number;
|
|
||||||
pointerId: number;
|
pointerId: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const [resizeVisual, setResizeVisual] = useState<ResizeState | 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((
|
const handleResizePointerDown = useCallback((
|
||||||
eventId: string,
|
eventId: string,
|
||||||
|
edge: ResizeEdge,
|
||||||
|
originalStartMinutes: number,
|
||||||
originalDurationMinutes: number,
|
originalDurationMinutes: number,
|
||||||
e: PointerEvent,
|
e: PointerEvent,
|
||||||
) => {
|
) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const originalHeightPx = Math.max(20, (originalDurationMinutes / 60) * hourHeight);
|
|
||||||
resizeRef.current = {
|
resizeRef.current = {
|
||||||
eventId,
|
eventId,
|
||||||
|
edge,
|
||||||
startY: e.clientY,
|
startY: e.clientY,
|
||||||
|
originalStartMinutes,
|
||||||
originalDurationMinutes,
|
originalDurationMinutes,
|
||||||
originalHeightPx,
|
|
||||||
pointerId: e.pointerId,
|
pointerId: e.pointerId,
|
||||||
};
|
};
|
||||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
}, [hourHeight]);
|
}, []);
|
||||||
|
|
||||||
const handleResizePointerMove = useCallback((e: PointerEvent) => {
|
const handleResizePointerMove = useCallback((e: PointerEvent) => {
|
||||||
if (!resizeRef.current) return;
|
if (!resizeRef.current) return;
|
||||||
|
const { startMinutes, durationMinutes } = computeResize(resizeRef.current, e.clientY);
|
||||||
const deltaY = e.clientY - resizeRef.current.startY;
|
setResizeVisual({
|
||||||
const newHeightPx = Math.max(hourHeight / 4, resizeRef.current.originalHeightPx + deltaY);
|
eventId: resizeRef.current.eventId,
|
||||||
const newDurationMinutes = Math.max(15, Math.round((newHeightPx / hourHeight) * 60 / 15) * 15);
|
topPx: (startMinutes / 60) * hourHeight,
|
||||||
const snappedHeight = (newDurationMinutes / 60) * hourHeight;
|
heightPx: (durationMinutes / 60) * hourHeight,
|
||||||
|
startMinutes,
|
||||||
setResizeVisual({ eventId: resizeRef.current.eventId, heightPx: snappedHeight, durationMinutes: newDurationMinutes });
|
durationMinutes,
|
||||||
}, [hourHeight]);
|
});
|
||||||
|
}, [hourHeight, computeResize]);
|
||||||
|
|
||||||
const handleResizePointerUp = useCallback(async (e: PointerEvent) => {
|
const handleResizePointerUp = useCallback(async (e: PointerEvent) => {
|
||||||
const resize = resizeRef.current;
|
const resize = resizeRef.current;
|
||||||
@@ -183,11 +209,11 @@ export function useTimeGridInteractions({
|
|||||||
|
|
||||||
try { (e.target as HTMLElement).releasePointerCapture(resize.pointerId); } catch { /* may already be released */ }
|
try { (e.target as HTMLElement).releasePointerCapture(resize.pointerId); } catch { /* may already be released */ }
|
||||||
|
|
||||||
const deltaY = e.clientY - resize.startY;
|
const { startMinutes: newStartMinutes, durationMinutes: newDurationMinutes } = computeResize(resize, e.clientY);
|
||||||
const newHeightPx = Math.max(hourHeight / 4, resize.originalHeightPx + deltaY);
|
|
||||||
const newDurationMinutes = Math.max(15, Math.round((newHeightPx / hourHeight) * 60 / 15) * 15);
|
|
||||||
|
|
||||||
if (newDurationMinutes === resize.originalDurationMinutes) {
|
const startChanged = newStartMinutes !== resize.originalStartMinutes;
|
||||||
|
const durationChanged = newDurationMinutes !== resize.originalDurationMinutes;
|
||||||
|
if (!startChanged && !durationChanged) {
|
||||||
setResizeVisual(null);
|
setResizeVisual(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -209,14 +235,21 @@ export function useTimeGridInteractions({
|
|||||||
try {
|
try {
|
||||||
const event = useCalendarStore.getState().events.find(ev => ev.id === resize.eventId);
|
const event = useCalendarStore.getState().events.find(ev => ev.id === resize.eventId);
|
||||||
const hasParticipants = event?.participants && Object.keys(event.participants).length > 0;
|
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) {
|
} catch (error) {
|
||||||
debug.error("Failed to resize event:", resize.eventId, error);
|
debug.error("Failed to resize event:", resize.eventId, error);
|
||||||
toast.error(errorMessages.resize);
|
toast.error(errorMessages.resize);
|
||||||
} finally {
|
} finally {
|
||||||
setResizeVisual(null);
|
setResizeVisual(null);
|
||||||
}
|
}
|
||||||
}, [hourHeight, errorMessages.resize]);
|
}, [computeResize, errorMessages.resize]);
|
||||||
|
|
||||||
// --- Click / Double-click / Quick-create ---
|
// --- Click / Double-click / Quick-create ---
|
||||||
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|||||||
Reference in New Issue
Block a user