Repository: SwitchbackTech/compass
Pull request #361 (2025-04-17 15:43:49)

✨ Feat: Maintain mouse cursor offset while dragging event
## Description

Closes https://github.com/SwitchbackTech/compass/issues/282

When dragging an event, account for the cursor's initial mouse position when computing position during drag

## Videos

#### Before
https://github.com/user-attachments/assets/073c8637-985d-4e9e-86a3-d97f60be1619

#### After
https://github.com/user-attachments/assets/31c015b8-bbcd-48d8-96c6-b9d71c12eaea





Patch:
diff --git a/packages/web/src/common/types/util.types.ts b/packages/web/src/common/types/util.types.ts
index f87d14cf..d6cfd31f 100644
--- a/packages/web/src/common/types/util.types.ts
+++ b/packages/web/src/common/types/util.types.ts
@@ -45,3 +45,8 @@ export interface WidthPixels {
 }
 
 export type Ref_Callback = (node: HTMLDivElement) => void;
+
+export type PartialMouseEvent = Pick<
+  MouseEvent,
+  "clientX" | "clientY" | "currentTarget"
+>;
diff --git a/packages/web/src/common/types/web.event.types.ts b/packages/web/src/common/types/web.event.types.ts
index 5cad489f..cf06ec55 100644
--- a/packages/web/src/common/types/web.event.types.ts
+++ b/packages/web/src/common/types/web.event.types.ts
@@ -22,6 +22,9 @@ export interface Schema_GridEvent extends Schema_Event {
     isOverlapping: boolean;
     widthMultiplier: number; // EG: 0.5 for half width
     horizontalOrder: number;
+    dragOffset: { y: number };
+    initialX: number | null;
+    initialY: number | null;
   };
 }
 
diff --git a/packages/web/src/common/utils/event.util.ts b/packages/web/src/common/utils/event.util.ts
index 29afa4b9..f76f2d92 100644
--- a/packages/web/src/common/utils/event.util.ts
+++ b/packages/web/src/common/utils/event.util.ts
@@ -10,6 +10,7 @@ import { Status } from "@core/errors/status.codes";
 import { Categories_Event, Schema_Event } from "@core/types/event.types";
 import { validateEvent } from "@core/validators/event.validator";
 import { getUserId } from "@web/auth/auth.util";
+import { PartialMouseEvent } from "@web/common/types/util.types";
 import {
   COLUMN_MONTH,
   COLUMN_WEEK,
@@ -46,6 +47,15 @@ export const assembleBaseEvent = (
   return baseEvent;
 };
 
+const gridEventDefaultPosition = {
+  isOverlapping: false,
+  widthMultiplier: 1,
+  horizontalOrder: 1,
+  initialX: null,
+  initialY: null,
+  dragOffset: { y: 0 },
+};
+
 export const assembleDefaultEvent = async (
   draftType: Categories_Event,
   startDate?: string,
@@ -85,11 +95,7 @@ export const assembleDefaultEvent = async (
         isSomeday: false,
         startDate,
         endDate,
-        position: {
-          isOverlapping: false,
-          widthMultiplier: 1,
-          horizontalOrder: 1,
-        },
+        position: gridEventDefaultPosition,
       };
       return defaultTimed;
     }
@@ -101,15 +107,24 @@ export const assembleDefaultEvent = async (
 export const assembleGridEvent = (event: Schema_Event): Schema_GridEvent => {
   const gridEvent = {
     ...event,
-    position: {
-      isOverlapping: false,
-      widthMultiplier: 1,
-      horizontalOrder: 1,
-    },
+    position: gridEventDefaultPosition,
   };
   return gridEvent;
 };
 
+export const getEventDragOffset = (
+  event?: Schema_GridEvent,
+  e?: PartialMouseEvent,
+): Schema_GridEvent["position"]["dragOffset"] => {
+  if (!event || !e) return { y: 0 };
+
+  const target = e.currentTarget as HTMLElement;
+  const rect = target.getBoundingClientRect();
+  return {
+    y: e.clientY - rect.top,
+  };
+};
+
 export const categorizeSomedayEvents = (
   somedayEvents: Schema_SomedayEventsColumn["events"],
   dates: { startDate: Dayjs; endDate: Dayjs },
diff --git a/packages/web/src/views/Calendar/components/Draft/hooks/actions/useDraftActions.ts b/packages/web/src/views/Calendar/components/Draft/hooks/actions/useDraftActions.ts
index 3236b1d4..85ef3bc8 100644
--- a/packages/web/src/views/Calendar/components/Draft/hooks/actions/useDraftActions.ts
+++ b/packages/web/src/views/Calendar/components/Draft/hooks/actions/useDraftActions.ts
@@ -8,6 +8,7 @@ import { YEAR_MONTH_DAY_FORMAT } from "@core/constants/date.constants";
 import { devAlert } from "@core/util/app.util";
 import { validateEvent } from "@core/validators/event.validator";
 import { getUserId } from "@web/auth/auth.util";
+import { PartialMouseEvent } from "@web/common/types/util.types";
 import { Schema_GridEvent } from "@web/common/types/web.event.types";
 import {
   assembleDefaultEvent,
@@ -174,8 +175,10 @@ export const useDraftActions = (
   }, [dispatch, draft, reduxDraft, reduxDraftType, setDraft]);
 
   const drag = useCallback(
-    (e: MouseEvent) => {
-      const updateTimesDuringDrag = (e: MouseEvent) => {
+    (e: Omit<PartialMouseEvent, "currentTarget">) => {
+      const updateTimesDuringDrag = (
+        e: Omit<PartialMouseEvent, "currentTarget">,
+      ) => {
         if (!draft) return;
 
         const x = getX(e, isSidebarOpen);
@@ -183,7 +186,7 @@ export const useDraftActions = (
 
         let eventStart = dateCalcs.getDateByXY(
           x,
-          e.clientY,
+          e.clientY - draft.position.dragOffset.y,
           weekProps.component.startOfView,
         );
 
@@ -408,9 +411,7 @@ export const useDraftActions = (
     }
   }, [activity, startDragging, startResizing, create, isDrafting]);
 
-  useDraftEffects(draftState, setters, weekProps, isDrafting, handleChange);
-
-  return {
+  const actions = {
     closeForm,
     submit,
     convert,
@@ -431,5 +432,9 @@ export const useDraftActions = (
     stopDragging,
     stopResizing,
   };
+
+  useDraftEffects(draftState, setters, weekProps, isDrafting, handleChange);
+
+  return actions;
 };
 export type Actions_Draft = ReturnType<typeof useDraftActions>;
diff --git a/packages/web/src/views/Calendar/components/Grid/MainGrid/MainGridEvents.tsx b/packages/web/src/views/Calendar/components/Grid/MainGrid/MainGridEvents.tsx
index 0b0235f0..f64644a7 100644
--- a/packages/web/src/views/Calendar/components/Grid/MainGrid/MainGridEvents.tsx
+++ b/packages/web/src/views/Calendar/components/Grid/MainGrid/MainGridEvents.tsx
@@ -1,7 +1,9 @@
 import React from "react";
 import { Categories_Event } from "@core/types/event.types";
 import { ID_GRID_EVENTS_TIMED } from "@web/common/constants/web.constants";
+import { PartialMouseEvent } from "@web/common/types/util.types";
 import { Schema_GridEvent } from "@web/common/types/web.event.types";
+import { getEventDragOffset } from "@web/common/utils/event.util";
 import { adjustOverlappingEvents } from "@web/common/utils/overlap/overlap";
 import { selectDraftId } from "@web/ducks/events/selectors/draft.selectors";
 import { selectGridEvents } from "@web/ducks/events/selectors/event.selectors";
@@ -38,11 +40,22 @@ export const MainGridEvents = ({ measurements, weekProps }: Props) => {
     );
   };
 
-  const handleDrag = (event: Schema_GridEvent) => {
+  const handleDrag = (
+    event: Schema_GridEvent,
+    moveEvent: PartialMouseEvent,
+  ) => {
     dispatch(
       draftSlice.actions.startDragging({
         category,
-        event,
+        event: {
+          ...event,
+          position: {
+            ...event.position,
+            dragOffset: getEventDragOffset(event, moveEvent),
+            initialX: moveEvent.clientX,
+            initialY: moveEvent.clientY,
+          },
+        },
       }),
     );
   };
diff --git a/packages/web/src/views/Calendar/hooks/grid/useGridEventMouseDown.ts b/packages/web/src/views/Calendar/hooks/grid/useGridEventMouseDown.ts
index 805c8989..d0d3bac7 100644
--- a/packages/web/src/views/Calendar/hooks/grid/useGridEventMouseDown.ts
+++ b/packages/web/src/views/Calendar/hooks/grid/useGridEventMouseDown.ts
@@ -4,6 +4,7 @@ import {
   ID_GRID_ALLDAY_ROW,
   ID_GRID_MAIN,
 } from "@web/common/constants/web.constants";
+import { PartialMouseEvent } from "@web/common/types/util.types";
 import { Schema_GridEvent } from "@web/common/types/web.event.types";
 import { isEventFormOpen } from "@web/common/utils";
 import { getElemById } from "@web/common/utils/grid.util";
@@ -16,6 +17,11 @@ const elementEventTypeMap = {
   [Categories_Event.ALLDAY]: ID_GRID_ALLDAY_ROW,
 };
 
+type HandleDragHandlers = {
+  onMouseMove: (e: MouseEvent) => void;
+  onMouseUp: () => void;
+};
+
 /**
  * Hook that differentiates between click and drag actions based on mouse behavior:
  * - Quick press and release triggers a click
@@ -25,12 +31,13 @@ const elementEventTypeMap = {
 export const useGridEventMouseDown = (
   eventType: Categories_Event.TIMED | Categories_Event.ALLDAY,
   onClick: (event: Schema_GridEvent) => void,
-  onDrag: (event: Schema_GridEvent) => void,
+  onDrag: (event: Schema_GridEvent, moveEvent: PartialMouseEvent) => void,
   delay: number = GRID_EVENT_MOUSE_HOLD_DELAY,
 ) => {
   const timeoutId = useRef<NodeJS.Timeout | null>(null);
   const mouseMoved = useRef<boolean>(false);
   const elementId = elementEventTypeMap[eventType];
+  const targetRef = useRef<EventTarget | null>(null);
 
   const hasExceededMoveThreshold = (
     currentX: number,
@@ -61,7 +68,8 @@ export const useGridEventMouseDown = (
   const handleDrag = (
     event: Schema_GridEvent,
     element: HTMLElement,
-    handlers: { onMouseMove: (e: MouseEvent) => void; onMouseUp: () => void },
+    currentEvent: MouseEvent,
+    handlers: HandleDragHandlers,
   ) => {
     // If form is open, only allow drag if mouse is still held
     if (isEventFormOpen()) {
@@ -72,11 +80,17 @@ export const useGridEventMouseDown = (
       }
     }
     mouseMoved.current = true;
-    onDrag(event);
+
+    onDrag(event, {
+      clientX: currentEvent.clientX,
+      clientY: currentEvent.clientY,
+      currentTarget: targetRef.current as EventTarget & Element,
+    });
   };
 
   const onMouseDown = (e: ReactMouseEvent, event: Schema_GridEvent) => {
     e.stopPropagation();
+    targetRef.current = e.currentTarget;
     const element = getElemById(elementId);
     const initialX = e.clientX;
     const initialY = e.clientY;
@@ -95,7 +109,11 @@ export const useGridEventMouseDown = (
         if (timeoutId.current) {
           clearTimeout(timeoutId.current);
         }
-        onDrag(event);
+        onDrag(event, {
+          clientX: moveEvent.clientX,
+          clientY: moveEvent.clientY,
+          currentTarget: targetRef.current as EventTarget & Element,
+        });
         cleanup(element, onMouseMove, onMouseUp);
       }
     };
@@ -111,7 +129,13 @@ export const useGridEventMouseDown = (
     // Start hold timer
     timeoutId.current = setTimeout(() => {
       if (!mouseMoved.current) {
-        handleDrag(event, element, { onMouseMove, onMouseUp });
+        // Need a current MouseEvent for the timeout drag case
+        const currentEvent = new MouseEvent("mousemove", {
+          clientX: initialX,
+          clientY: initialY,
+        });
+
+        handleDrag(event, element, currentEvent, { onMouseMove, onMouseUp });
       }
     }, delay);
 


Test patch:
diff --git a/packages/web/src/views/Calendar/hooks/grid/useGridEventMouseDown.test.ts b/packages/web/src/views/Calendar/hooks/grid/useGridEventMouseDown.test.ts
index 8b22dcb7..6c954e10 100644
--- a/packages/web/src/views/Calendar/hooks/grid/useGridEventMouseDown.test.ts
+++ b/packages/web/src/views/Calendar/hooks/grid/useGridEventMouseDown.test.ts
@@ -31,6 +31,13 @@ describe("useGridEventMouseDown", () => {
     stopPropagation,
   } as unknown as React.MouseEvent;
 
+  // Create partial mouse event object for assertion matching
+  const partialMouseEvent = {
+    clientX: 0,
+    clientY: 0,
+    currentTarget: undefined,
+  };
+
   beforeEach(() => {
     jest.useFakeTimers();
     onClick.mockClear();
@@ -72,7 +79,7 @@ describe("useGridEventMouseDown", () => {
     result.current.onMouseDown(mockMouseEvent, mockEvent);
     jest.advanceTimersByTime(GRID_EVENT_MOUSE_HOLD_DELAY + 1);
 
-    expect(onDrag).toHaveBeenCalledWith(mockEvent);
+    expect(onDrag).toHaveBeenCalledWith(mockEvent, partialMouseEvent);
     expect(onClick).not.toHaveBeenCalled();
   });
 
@@ -93,7 +100,14 @@ describe("useGridEventMouseDown", () => {
     });
     mockGridElement.dispatchEvent(mousemoveEvent);
 
-    expect(onDrag).toHaveBeenCalledWith(mockEvent);
+    // expect.objectContaining to match the mousemoveEvent properties
+    expect(onDrag).toHaveBeenCalledWith(
+      mockEvent,
+      expect.objectContaining({
+        clientX: GRID_EVENT_MOUSE_HOLD_MOVE_THRESHOLD + 1,
+        clientY: GRID_EVENT_MOUSE_HOLD_MOVE_THRESHOLD + 1,
+      }),
+    );
     expect(onClick).not.toHaveBeenCalled();
   });
 
@@ -115,7 +129,7 @@ describe("useGridEventMouseDown", () => {
       result.current.onMouseDown(mockMouseEvent, mockEvent);
       jest.advanceTimersByTime(GRID_EVENT_MOUSE_HOLD_DELAY + 1);
 
-      expect(onDrag).toHaveBeenCalledWith(mockEvent);
+      expect(onDrag).toHaveBeenCalledWith(mockEvent, partialMouseEvent);
       expect(onClick).not.toHaveBeenCalled();
     });