1
1
'use client' ;
2
2
3
3
import {
4
- closestCenter ,
5
4
DndContext ,
6
5
type DragEndEvent ,
7
6
type DragOverEvent ,
@@ -12,6 +11,7 @@ import {
12
11
PointerSensor ,
13
12
useSensor ,
14
13
useSensors ,
14
+ closestCorners ,
15
15
} from '@dnd-kit/core' ;
16
16
import {
17
17
horizontalListSortingStrategy ,
@@ -21,10 +21,10 @@ import { useQueryClient } from '@tanstack/react-query';
21
21
import { createClient } from '@tuturuuu/supabase/next/client' ;
22
22
import type { Task as TaskType } from '@tuturuuu/types/primitives/TaskBoard' ;
23
23
import { Card , CardContent } from '@tuturuuu/ui/card' ;
24
- import { useEffect , useMemo , useRef , useState } from 'react' ;
24
+ import { useEffect , useMemo , useRef , useState , useCallback } from 'react' ;
25
25
import { getTaskLists , useMoveTask } from '@/lib/task-helper' ;
26
26
import { coordinateGetter } from './keyboard-preset' ;
27
- import { TaskCard } from './task' ;
27
+ import { LightweightTaskCard } from './task' ;
28
28
import type { Column } from './task-list' ;
29
29
import { BoardColumn , BoardContainer } from './task-list' ;
30
30
import { TaskListForm } from './task-list-form' ;
@@ -43,12 +43,16 @@ export function KanbanBoard({ boardId, tasks, isLoading }: Props) {
43
43
const pickedUpTaskColumn = useRef < string | null > ( null ) ;
44
44
const queryClient = useQueryClient ( ) ;
45
45
const moveTaskMutation = useMoveTask ( boardId ) ;
46
+ // Ref for the Kanban board container
47
+ const boardRef = useRef < HTMLDivElement > ( null ) ;
48
+ const dragStartCardLeft = useRef < number | null > ( null ) ;
49
+ const overlayWidth = 350 ; // Column width
46
50
47
- const handleTaskCreated = ( ) => {
51
+ const handleTaskCreated = useCallback ( ( ) => {
48
52
// Invalidate the tasks query to trigger a refetch
49
53
queryClient . invalidateQueries ( { queryKey : [ 'tasks' , boardId ] } ) ;
50
54
queryClient . invalidateQueries ( { queryKey : [ 'task_lists' , boardId ] } ) ;
51
- } ;
55
+ } , [ queryClient , boardId ] ) ;
52
56
53
57
useEffect ( ( ) => {
54
58
let mounted = true ;
@@ -94,6 +98,21 @@ export function KanbanBoard({ boardId, tasks, isLoading }: Props) {
94
98
} ;
95
99
} , [ boardId ] ) ;
96
100
101
+ // Global drag state reset on mouseup/touchend
102
+ useEffect ( ( ) => {
103
+ function handleGlobalPointerUp ( ) {
104
+ setActiveColumn ( null ) ;
105
+ setActiveTask ( null ) ;
106
+ pickedUpTaskColumn . current = null ;
107
+ }
108
+ window . addEventListener ( 'mouseup' , handleGlobalPointerUp ) ;
109
+ window . addEventListener ( 'touchend' , handleGlobalPointerUp ) ;
110
+ return ( ) => {
111
+ window . removeEventListener ( 'mouseup' , handleGlobalPointerUp ) ;
112
+ window . removeEventListener ( 'touchend' , handleGlobalPointerUp ) ;
113
+ } ;
114
+ } , [ ] ) ;
115
+
97
116
const columnsId = useMemo ( ( ) => columns . map ( ( col ) => col . id ) , [ columns ] ) ;
98
117
99
118
const sensors = useSensors (
@@ -107,6 +126,7 @@ export function KanbanBoard({ boardId, tasks, isLoading }: Props) {
107
126
} )
108
127
) ;
109
128
129
+ // Capture drag start card left position
110
130
function onDragStart ( event : DragStartEvent ) {
111
131
if ( ! hasDraggableData ( event . active ) ) return ;
112
132
const { active } = event ;
@@ -121,6 +141,12 @@ export function KanbanBoard({ boardId, tasks, isLoading }: Props) {
121
141
const task = active . data . current . task ;
122
142
setActiveTask ( task ) ;
123
143
pickedUpTaskColumn . current = String ( task . list_id ) ;
144
+ // Get the DOM node of the dragged card
145
+ const cardNode = document . querySelector ( `[data-id="${ task . id } "]` ) ;
146
+ if ( cardNode ) {
147
+ const cardRect = cardNode . getBoundingClientRect ( ) ;
148
+ dragStartCardLeft . current = cardRect . left ;
149
+ }
124
150
return ;
125
151
}
126
152
}
@@ -170,64 +196,96 @@ export function KanbanBoard({ boardId, tasks, isLoading }: Props) {
170
196
}
171
197
}
172
198
199
+ // Memoized DragOverlay content to minimize re-renders
200
+ const MemoizedTaskOverlay = useMemo ( ( ) =>
201
+ activeTask ? (
202
+ < LightweightTaskCard task = { activeTask } />
203
+ ) : null ,
204
+ [ activeTask ]
205
+ ) ;
206
+ const MemoizedColumnOverlay = useMemo ( ( ) =>
207
+ activeColumn ? (
208
+ < BoardColumn
209
+ column = { activeColumn }
210
+ boardId = { boardId }
211
+ tasks = { tasks . filter ( ( task ) => task . list_id === activeColumn . id ) }
212
+ isOverlay
213
+ onTaskCreated = { handleTaskCreated }
214
+ onListUpdated = { handleTaskCreated }
215
+ />
216
+ ) : null ,
217
+ [ activeColumn , tasks , boardId , handleTaskCreated ]
218
+ ) ;
219
+
173
220
async function onDragEnd ( event : DragEndEvent ) {
174
221
const { active, over } = event ;
175
-
222
+ // Always reset drag state, even on invalid drop
176
223
setActiveColumn ( null ) ;
177
224
setActiveTask ( null ) ;
178
-
225
+ pickedUpTaskColumn . current = null ;
226
+ dragStartCardLeft . current = null ;
179
227
if ( ! over ) {
180
228
// Reset the cache if dropped outside
181
229
queryClient . invalidateQueries ( { queryKey : [ 'tasks' , boardId ] } ) ;
182
- pickedUpTaskColumn . current = null ;
230
+ if ( process . env . NODE_ENV === 'development' ) {
231
+ // eslint-disable-next-line no-console
232
+ console . debug ( 'DragEnd: No drop target, state reset.' ) ;
233
+ }
183
234
return ;
184
235
}
185
-
186
236
const activeType = active . data ?. current ?. type ;
187
237
if ( ! activeType ) {
188
- pickedUpTaskColumn . current = null ;
238
+ if ( process . env . NODE_ENV === 'development' ) {
239
+ // eslint-disable-next-line no-console
240
+ console . debug ( 'DragEnd: No activeType, state reset.' ) ;
241
+ }
189
242
return ;
190
243
}
191
-
192
244
if ( activeType === 'Task' ) {
193
245
const activeTask = active . data ?. current ?. task ;
194
246
if ( ! activeTask ) {
195
- pickedUpTaskColumn . current = null ;
247
+ if ( process . env . NODE_ENV === 'development' ) {
248
+ // eslint-disable-next-line no-console
249
+ console . debug ( 'DragEnd: No activeTask, state reset.' ) ;
250
+ }
196
251
return ;
197
252
}
198
-
199
253
let targetListId : string ;
200
254
if ( over . data ?. current ?. type === 'Column' ) {
201
255
targetListId = String ( over . id ) ;
202
256
} else if ( over . data ?. current ?. type === 'Task' ) {
203
257
targetListId = String ( over . data . current . task . list_id ) ;
204
258
} else {
205
- pickedUpTaskColumn . current = null ;
259
+ if ( process . env . NODE_ENV === 'development' ) {
260
+ // eslint-disable-next-line no-console
261
+ console . debug ( 'DragEnd: Invalid drop type, state reset.' ) ;
262
+ }
206
263
return ;
207
264
}
208
-
209
- const originalListId = pickedUpTaskColumn . current ;
265
+ const originalListId = event . active . data ?. current ?. task ?. list_id || pickedUpTaskColumn . current ;
210
266
if ( ! originalListId ) {
211
- pickedUpTaskColumn . current = null ;
267
+ if ( process . env . NODE_ENV === 'development' ) {
268
+ // eslint-disable-next-line no-console
269
+ console . debug ( 'DragEnd: No originalListId, state reset.' ) ;
270
+ }
212
271
return ;
213
272
}
214
-
215
273
const sourceListExists = columns . some (
216
274
( col ) => String ( col . id ) === originalListId
217
275
) ;
218
276
const targetListExists = columns . some (
219
277
( col ) => String ( col . id ) === targetListId
220
278
) ;
221
-
222
279
if ( ! sourceListExists || ! targetListExists ) {
223
- pickedUpTaskColumn . current = null ;
280
+ if ( process . env . NODE_ENV === 'development' ) {
281
+ // eslint-disable-next-line no-console
282
+ console . debug ( 'DragEnd: Source or target list missing, state reset.' ) ;
283
+ }
224
284
return ;
225
285
}
226
-
227
286
// Only move if actually changing lists
228
287
if ( targetListId !== originalListId ) {
229
288
try {
230
- // Optimistically update the task in the cache
231
289
queryClient . setQueryData (
232
290
[ 'tasks' , boardId ] ,
233
291
( oldData : TaskType [ ] | undefined ) => {
@@ -237,20 +295,19 @@ export function KanbanBoard({ boardId, tasks, isLoading }: Props) {
237
295
) ;
238
296
}
239
297
) ;
240
-
241
298
moveTaskMutation . mutate ( {
242
299
taskId : activeTask . id ,
243
300
newListId : targetListId ,
244
301
} ) ;
245
302
} catch ( error ) {
246
- // Revert the optimistic update by invalidating the query
247
303
queryClient . invalidateQueries ( { queryKey : [ 'tasks' , boardId ] } ) ;
248
- console . error ( 'Failed to move task:' , error ) ;
304
+ if ( process . env . NODE_ENV === 'development' ) {
305
+ // eslint-disable-next-line no-console
306
+ console . error ( 'Failed to move task:' , error ) ;
307
+ }
249
308
}
250
309
}
251
310
}
252
-
253
- pickedUpTaskColumn . current = null ;
254
311
}
255
312
256
313
if ( isLoading ) {
@@ -301,7 +358,7 @@ export function KanbanBoard({ boardId, tasks, isLoading }: Props) {
301
358
< div className = "flex-1 overflow-hidden" >
302
359
< DndContext
303
360
sensors = { sensors }
304
- collisionDetection = { closestCenter }
361
+ collisionDetection = { closestCorners }
305
362
onDragStart = { onDragStart }
306
363
onDragOver = { onDragOver }
307
364
onDragEnd = { onDragEnd }
@@ -313,17 +370,14 @@ export function KanbanBoard({ boardId, tasks, isLoading }: Props) {
313
370
modifiers = { [
314
371
( args ) => {
315
372
const { transform } = args ;
316
-
317
- // Get viewport bounds - only access window in browser environment
318
- // Use responsive fallback based on common breakpoints for better mobile handling
319
- const viewportWidth =
320
- typeof window !== 'undefined' ? window . innerWidth : 1024 ; // Default to tablet landscape width for SSR (better than desktop 1200px)
321
-
322
- const maxX = viewportWidth - 350 ; // Account for card width
323
-
373
+ if ( ! boardRef . current || dragStartCardLeft . current === null ) return transform ;
374
+ const boardRect = boardRef . current . getBoundingClientRect ( ) ;
375
+ // Clamp overlay within board
376
+ const minX = boardRect . left - dragStartCardLeft . current ;
377
+ const maxX = boardRect . right - dragStartCardLeft . current - overlayWidth ;
324
378
return {
325
379
...transform ,
326
- x : Math . min ( Math . max ( transform . x , - 50 ) , maxX ) , // Constrain X position
380
+ x : Math . max ( minX , Math . min ( transform . x , maxX ) ) ,
327
381
} ;
328
382
} ,
329
383
] }
@@ -333,7 +387,7 @@ export function KanbanBoard({ boardId, tasks, isLoading }: Props) {
333
387
items = { columnsId }
334
388
strategy = { horizontalListSortingStrategy }
335
389
>
336
- < div className = "flex h-full gap-4" >
390
+ < div ref = { boardRef } className = "flex h-full gap-4" >
337
391
{ columns
338
392
. sort ( ( a , b ) => {
339
393
// First sort by status priority, then by position within status
@@ -367,7 +421,6 @@ export function KanbanBoard({ boardId, tasks, isLoading }: Props) {
367
421
</ div >
368
422
</ SortableContext >
369
423
</ BoardContainer >
370
-
371
424
< DragOverlay
372
425
wrapperElement = "div"
373
426
style = { {
@@ -376,29 +429,8 @@ export function KanbanBoard({ boardId, tasks, isLoading }: Props) {
376
429
pointerEvents : 'none' ,
377
430
} }
378
431
>
379
- { activeColumn && (
380
- < BoardColumn
381
- column = { activeColumn }
382
- boardId = { boardId }
383
- tasks = { tasks . filter ( ( task ) => task . list_id === activeColumn . id ) }
384
- isOverlay
385
- onTaskCreated = { handleTaskCreated }
386
- onListUpdated = { handleTaskCreated }
387
- />
388
- ) }
389
- { activeTask && (
390
- < div className = "w-full max-w-[350px]" >
391
- < TaskCard
392
- task = { activeTask }
393
- taskList = { columns . find (
394
- ( col ) => col . id === activeTask . list_id
395
- ) }
396
- boardId = { boardId }
397
- isOverlay
398
- onUpdate = { handleTaskCreated }
399
- />
400
- </ div >
401
- ) }
432
+ { MemoizedColumnOverlay }
433
+ { MemoizedTaskOverlay }
402
434
</ DragOverlay >
403
435
</ DndContext >
404
436
</ div >
0 commit comments