jettypod 4.4.82 → 4.4.84

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { createContext, useContext, useState, useCallback, useRef, ReactNode } from 'react';
3
+ import { createContext, useContext, useState, useCallback, useRef, useEffect, ReactNode } from 'react';
4
4
  import { createPortal } from 'react-dom';
5
5
  import type { WorkItem } from '@/lib/db';
6
6
 
@@ -22,14 +22,26 @@ interface EpicDropZoneInfo {
22
22
  onReorder?: ReorderHandler;
23
23
  }
24
24
 
25
+ interface CardPosition {
26
+ id: number;
27
+ rect: DOMRect;
28
+ }
29
+
30
+ interface InsertionPreview {
31
+ afterCardId: number | null; // null means insert at beginning
32
+ zoneId: string; // epic zone or drop zone id
33
+ }
34
+
25
35
  interface DragContextType {
26
36
  isDragging: boolean;
27
37
  draggedItem: WorkItem | null;
28
38
  activeDropZone: string | null;
29
39
  activeEpicZone: string | null;
40
+ insertionPreview: InsertionPreview | null;
30
41
  dragPosition: { x: number; y: number };
31
42
  dragOffset: { x: number; y: number };
32
43
  draggedCardWidth: number;
44
+ draggedCardHeight: number;
33
45
  setDraggedItem: (item: WorkItem | null) => void;
34
46
  registerDropZone: (id: string, info: DropZoneInfo) => void;
35
47
  unregisterDropZone: (id: string) => void;
@@ -38,6 +50,9 @@ interface DragContextType {
38
50
  updatePointerPosition: (x: number, y: number) => void;
39
51
  startDrag: (item: WorkItem, cardRect: DOMRect, pointerX: number, pointerY: number) => void;
40
52
  handleDrop: () => Promise<void>;
53
+ registerCardPosition: (id: number, rect: DOMRect) => void;
54
+ unregisterCard: (id: number) => void;
55
+ getCardPositions: () => CardPosition[];
41
56
  }
42
57
 
43
58
  const DragContext = createContext<DragContextType>({
@@ -45,9 +60,11 @@ const DragContext = createContext<DragContextType>({
45
60
  draggedItem: null,
46
61
  activeDropZone: null,
47
62
  activeEpicZone: null,
63
+ insertionPreview: null,
48
64
  dragPosition: { x: 0, y: 0 },
49
65
  dragOffset: { x: 0, y: 0 },
50
66
  draggedCardWidth: 0,
67
+ draggedCardHeight: 0,
51
68
  setDraggedItem: () => {},
52
69
  registerDropZone: () => {},
53
70
  unregisterDropZone: () => {},
@@ -56,23 +73,31 @@ const DragContext = createContext<DragContextType>({
56
73
  updatePointerPosition: () => {},
57
74
  startDrag: () => {},
58
75
  handleDrop: async () => {},
76
+ registerCardPosition: () => {},
77
+ unregisterCard: () => {},
78
+ getCardPositions: () => [],
59
79
  });
60
80
 
61
81
  interface DragProviderProps {
62
82
  children: ReactNode;
63
83
  renderDragOverlay?: (item: WorkItem) => ReactNode;
84
+ onRemoveFromEpic?: EpicAssignHandler;
64
85
  }
65
86
 
66
- export function DragProvider({ children, renderDragOverlay }: DragProviderProps) {
87
+ export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic }: DragProviderProps) {
67
88
  const [draggedItem, setDraggedItem] = useState<WorkItem | null>(null);
68
89
  const [activeDropZone, setActiveDropZone] = useState<string | null>(null);
69
90
  const [activeEpicZone, setActiveEpicZone] = useState<string | null>(null);
91
+ const [insertionPreview, setInsertionPreview] = useState<InsertionPreview | null>(null);
70
92
  const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
71
93
  const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
72
94
  const [draggedCardWidth, setDraggedCardWidth] = useState(0);
95
+ const [draggedCardHeight, setDraggedCardHeight] = useState(0);
73
96
  const dropZonesRef = useRef<Map<string, DropZoneInfo>>(new Map());
74
97
  const epicDropZonesRef = useRef<Map<string, EpicDropZoneInfo>>(new Map());
98
+ const cardPositionsRef = useRef<Map<number, DOMRect>>(new Map());
75
99
  const pointerPositionRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
100
+ const draggedItemRef = useRef<WorkItem | null>(null);
76
101
 
77
102
  const registerDropZone = useCallback((id: string, info: DropZoneInfo) => {
78
103
  dropZonesRef.current.set(id, info);
@@ -90,6 +115,25 @@ export function DragProvider({ children, renderDragOverlay }: DragProviderProps)
90
115
  epicDropZonesRef.current.delete(id);
91
116
  }, []);
92
117
 
118
+ const registerCardPosition = useCallback((id: number, rect: DOMRect) => {
119
+ console.log('[DragContext] registerCardPosition:', id, 'rect:', { top: rect.top, bottom: rect.bottom });
120
+ cardPositionsRef.current.set(id, rect);
121
+ }, []);
122
+
123
+ const unregisterCard = useCallback((id: number) => {
124
+ console.log('[DragContext] unregisterCard:', id);
125
+ cardPositionsRef.current.delete(id);
126
+ }, []);
127
+
128
+ const getCardPositions = useCallback((): CardPosition[] => {
129
+ const positions: CardPosition[] = [];
130
+ cardPositionsRef.current.forEach((rect, id) => {
131
+ positions.push({ id, rect });
132
+ });
133
+ console.log('[DragContext] getCardPositions:', positions.length, 'positions', positions.map(p => ({ id: p.id, midY: (p.rect.top + p.rect.bottom) / 2 })));
134
+ return positions;
135
+ }, []);
136
+
93
137
  const updatePointerPosition = useCallback((x: number, y: number) => {
94
138
  pointerPositionRef.current = { x, y };
95
139
  setDragPosition({ x, y });
@@ -114,6 +158,32 @@ export function DragProvider({ children, renderDragOverlay }: DragProviderProps)
114
158
 
115
159
  setActiveDropZone(foundZone);
116
160
  setActiveEpicZone(foundEpicZone);
161
+
162
+ // Calculate insertion preview based on pointer position
163
+ const zoneId = foundEpicZone || foundZone;
164
+ if (zoneId && draggedItemRef.current) {
165
+ const allPositions = Array.from(cardPositionsRef.current.entries())
166
+ .filter(([id]) => id !== draggedItemRef.current?.id)
167
+ .map(([id, rect]) => ({
168
+ id,
169
+ midY: (rect.top + rect.bottom) / 2,
170
+ }))
171
+ .sort((a, b) => a.midY - b.midY);
172
+
173
+ // Find which card the pointer is after
174
+ let afterCardId: number | null = null;
175
+ for (const pos of allPositions) {
176
+ if (y > pos.midY) {
177
+ afterCardId = pos.id;
178
+ } else {
179
+ break;
180
+ }
181
+ }
182
+
183
+ setInsertionPreview({ afterCardId, zoneId });
184
+ } else {
185
+ setInsertionPreview(null);
186
+ }
117
187
  }, []);
118
188
 
119
189
  const startDrag = useCallback((item: WorkItem, cardRect: DOMRect, pointerX: number, pointerY: number) => {
@@ -123,52 +193,106 @@ export function DragProvider({ children, renderDragOverlay }: DragProviderProps)
123
193
  setDragOffset({ x: offsetX, y: offsetY });
124
194
  setDragPosition({ x: pointerX, y: pointerY });
125
195
  setDraggedCardWidth(cardRect.width);
196
+ setDraggedCardHeight(cardRect.height);
126
197
  setDraggedItem(item);
198
+ draggedItemRef.current = item;
127
199
  }, []);
128
200
 
129
201
  const handleDrop = useCallback(async () => {
202
+ console.log('[DragContext] handleDrop called:', {
203
+ draggedItem: draggedItem ? { id: draggedItem.id, parent_id: draggedItem.parent_id, epic_id: draggedItem.epic_id } : null,
204
+ activeEpicZone,
205
+ activeDropZone
206
+ });
130
207
  if (!draggedItem) return;
131
208
 
132
209
  // Check for epic drop zone first (more specific drop target)
133
210
  if (activeEpicZone) {
134
211
  const epicZoneInfo = epicDropZonesRef.current.get(activeEpicZone);
212
+ console.log('[DragContext] epicZoneInfo:', epicZoneInfo ? { epicId: epicZoneInfo.epicId, hasOnReorder: !!epicZoneInfo.onReorder } : null);
135
213
  if (epicZoneInfo) {
136
214
  const currentEpicId = draggedItem.parent_id || draggedItem.epic_id;
215
+ console.log('[DragContext] currentEpicId:', currentEpicId, 'targetEpicId:', epicZoneInfo.epicId);
137
216
 
138
217
  // Same epic - reorder within epic
139
218
  if (currentEpicId === epicZoneInfo.epicId && epicZoneInfo.onReorder) {
219
+ console.log('[DragContext] Same epic - calling onReorder');
140
220
  await epicZoneInfo.onReorder(draggedItem.id, pointerPositionRef.current.y);
141
221
  return;
142
222
  }
143
223
 
144
224
  // Different epic - assign to new epic
145
225
  if (currentEpicId !== epicZoneInfo.epicId) {
226
+ console.log('[DragContext] Different epic - calling onEpicAssign');
146
227
  await epicZoneInfo.onEpicAssign(draggedItem.id, epicZoneInfo.epicId);
147
228
  return;
148
229
  }
149
230
  }
150
231
  }
151
232
 
152
- // Otherwise handle status drop zone
153
- if (!activeDropZone) return;
233
+ // Check if card is being removed from an epic (dropped outside all epic zones)
234
+ const currentEpicId = draggedItem.parent_id || draggedItem.epic_id;
235
+ if (!activeEpicZone && currentEpicId && onRemoveFromEpic) {
236
+ console.log('[DragContext] Removing from epic - card has epic_id but dropped outside epic zones');
237
+ await onRemoveFromEpic(draggedItem.id, null);
238
+ // Continue to also handle status change if needed
239
+ }
240
+
241
+ // Handle status drop zone
242
+ if (!activeDropZone) {
243
+ console.log('[DragContext] No activeDropZone, returning');
244
+ return;
245
+ }
154
246
 
155
247
  const zoneInfo = dropZonesRef.current.get(activeDropZone);
156
- if (!zoneInfo) return;
248
+ if (!zoneInfo) {
249
+ console.log('[DragContext] No zoneInfo for activeDropZone:', activeDropZone);
250
+ return;
251
+ }
252
+
253
+ console.log('[DragContext] Status drop zone:', { targetStatus: zoneInfo.targetStatus, itemStatus: draggedItem.status, hasOnReorder: !!zoneInfo.onReorder });
157
254
 
158
255
  // Check if this is a status change or a reorder
159
256
  if (draggedItem.status !== zoneInfo.targetStatus) {
160
257
  // Status change
258
+ console.log('[DragContext] Status change - calling onDrop');
161
259
  await zoneInfo.onDrop(draggedItem.id, zoneInfo.targetStatus);
162
260
  } else if (zoneInfo.onReorder) {
163
261
  // Reorder within same status
262
+ console.log('[DragContext] Same status - calling onReorder');
164
263
  await zoneInfo.onReorder(draggedItem.id, pointerPositionRef.current.y);
264
+ } else {
265
+ console.log('[DragContext] Same status but no onReorder handler');
165
266
  }
166
- }, [activeDropZone, activeEpicZone, draggedItem]);
267
+ }, [activeDropZone, activeEpicZone, draggedItem, onRemoveFromEpic]);
167
268
 
168
269
  // Calculate overlay position (pointer position minus offset = card top-left)
169
270
  const overlayX = dragPosition.x - dragOffset.x;
170
271
  const overlayY = dragPosition.y - dragOffset.y;
171
272
 
273
+ // Wrap setDraggedItem to also clear ref and preview
274
+ const handleSetDraggedItem = useCallback((item: WorkItem | null) => {
275
+ setDraggedItem(item);
276
+ draggedItemRef.current = item;
277
+ if (!item) {
278
+ setInsertionPreview(null);
279
+ }
280
+ }, []);
281
+
282
+ // Cancel drag on Escape key
283
+ useEffect(() => {
284
+ if (!draggedItem) return;
285
+
286
+ const handleKeyDown = (e: KeyboardEvent) => {
287
+ if (e.key === 'Escape') {
288
+ handleSetDraggedItem(null);
289
+ }
290
+ };
291
+
292
+ window.addEventListener('keydown', handleKeyDown);
293
+ return () => window.removeEventListener('keydown', handleKeyDown);
294
+ }, [draggedItem, handleSetDraggedItem]);
295
+
172
296
  return (
173
297
  <DragContext.Provider
174
298
  value={{
@@ -176,10 +300,12 @@ export function DragProvider({ children, renderDragOverlay }: DragProviderProps)
176
300
  draggedItem,
177
301
  activeDropZone,
178
302
  activeEpicZone,
303
+ insertionPreview,
179
304
  dragPosition,
180
305
  dragOffset,
181
306
  draggedCardWidth,
182
- setDraggedItem,
307
+ draggedCardHeight,
308
+ setDraggedItem: handleSetDraggedItem,
183
309
  registerDropZone,
184
310
  unregisterDropZone,
185
311
  registerEpicDropZone,
@@ -187,6 +313,9 @@ export function DragProvider({ children, renderDragOverlay }: DragProviderProps)
187
313
  updatePointerPosition,
188
314
  startDrag,
189
315
  handleDrop,
316
+ registerCardPosition,
317
+ unregisterCard,
318
+ getCardPositions,
190
319
  }}
191
320
  >
192
321
  {children}
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useRef } from 'react';
3
+ import { useRef, useEffect } from 'react';
4
4
  import { motion, PanInfo } from 'framer-motion';
5
5
  import type { WorkItem } from '@/lib/db';
6
6
  import { useDragContext } from './DragContext';
@@ -12,10 +12,38 @@ interface DraggableCardProps {
12
12
  }
13
13
 
14
14
  export function DraggableCard({ item, children, disabled = false }: DraggableCardProps) {
15
- const { startDrag, setDraggedItem, updatePointerPosition, handleDrop } = useDragContext();
15
+ const { startDrag, setDraggedItem, updatePointerPosition, handleDrop, registerCardPosition, unregisterCard } = useDragContext();
16
16
  const wasDraggingRef = useRef(false);
17
17
  const cardRef = useRef<HTMLDivElement>(null);
18
18
 
19
+ // Register card position for optimized reorder calculations
20
+ useEffect(() => {
21
+ if (disabled || !cardRef.current) return;
22
+
23
+ const updatePosition = () => {
24
+ if (cardRef.current) {
25
+ registerCardPosition(item.id, cardRef.current.getBoundingClientRect());
26
+ }
27
+ };
28
+
29
+ // Initial registration
30
+ updatePosition();
31
+
32
+ // Update on resize/layout changes
33
+ const resizeObserver = new ResizeObserver(updatePosition);
34
+ resizeObserver.observe(cardRef.current);
35
+
36
+ // Update on scroll (positions are viewport-relative)
37
+ const scrollHandler = () => updatePosition();
38
+ window.addEventListener('scroll', scrollHandler, true);
39
+
40
+ return () => {
41
+ unregisterCard(item.id);
42
+ resizeObserver.disconnect();
43
+ window.removeEventListener('scroll', scrollHandler, true);
44
+ };
45
+ }, [item.id, disabled, registerCardPosition, unregisterCard]);
46
+
19
47
  if (disabled) {
20
48
  return <>{children}</>;
21
49
  }
@@ -55,6 +83,10 @@ export function DraggableCard({ item, children, disabled = false }: DraggableCar
55
83
  whileDrag={{
56
84
  cursor: 'grabbing',
57
85
  opacity: 0,
86
+ height: 0,
87
+ marginBottom: 0,
88
+ padding: 0,
89
+ overflow: 'hidden',
58
90
  }}
59
91
  onDragStart={handleDragStart}
60
92
  onDrag={handleDrag}