Skip to content

Commit ec86c31

Browse files
committed
fix(tasks): resolve drag & drop lag
1 parent 52762b4 commit ec86c31

File tree

4 files changed

+174
-105
lines changed

4 files changed

+174
-105
lines changed

apps/web/src/app/[locale]/(dashboard)/[wsId]/tasks/boards/[boardId]/_components/list-view.tsx

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { useQuery, useQueryClient } from '@tanstack/react-query';
44
import { createClient } from '@tuturuuu/supabase/next/client';
55
import type { Task, TaskList } from '@tuturuuu/types/primitives/TaskBoard';
6+
import type { Json } from '@tuturuuu/types/supabase';
67
import { Badge } from '@tuturuuu/ui/badge';
78
import { Button } from '@tuturuuu/ui/button';
89
import { Checkbox } from '@tuturuuu/ui/checkbox';
@@ -129,13 +130,7 @@ interface Member {
129130
avatar_url?: string | null;
130131
}
131132

132-
interface TaskBulkUpdate {
133-
id: string;
134-
priority?: number | null;
135-
archived?: boolean;
136-
tags?: string[];
137-
[key: string]: any; // index signature for Json compatibility
138-
}
133+
type TaskBulkUpdate = { id: string } & Record<string, Json>;
139134

140135
// Priority labels constant - defined once outside component for performance
141136
const priorityLabels = {

apps/web/src/app/[locale]/(dashboard)/[wsId]/tasks/boards/[boardId]/kanban.tsx

Lines changed: 94 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
'use client';
22

33
import {
4-
closestCenter,
54
DndContext,
65
type DragEndEvent,
76
type DragOverEvent,
@@ -12,6 +11,7 @@ import {
1211
PointerSensor,
1312
useSensor,
1413
useSensors,
14+
closestCorners,
1515
} from '@dnd-kit/core';
1616
import {
1717
horizontalListSortingStrategy,
@@ -21,10 +21,10 @@ import { useQueryClient } from '@tanstack/react-query';
2121
import { createClient } from '@tuturuuu/supabase/next/client';
2222
import type { Task as TaskType } from '@tuturuuu/types/primitives/TaskBoard';
2323
import { Card, CardContent } from '@tuturuuu/ui/card';
24-
import { useEffect, useMemo, useRef, useState } from 'react';
24+
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
2525
import { getTaskLists, useMoveTask } from '@/lib/task-helper';
2626
import { coordinateGetter } from './keyboard-preset';
27-
import { TaskCard } from './task';
27+
import { LightweightTaskCard } from './task';
2828
import type { Column } from './task-list';
2929
import { BoardColumn, BoardContainer } from './task-list';
3030
import { TaskListForm } from './task-list-form';
@@ -43,12 +43,16 @@ export function KanbanBoard({ boardId, tasks, isLoading }: Props) {
4343
const pickedUpTaskColumn = useRef<string | null>(null);
4444
const queryClient = useQueryClient();
4545
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
4650

47-
const handleTaskCreated = () => {
51+
const handleTaskCreated = useCallback(() => {
4852
// Invalidate the tasks query to trigger a refetch
4953
queryClient.invalidateQueries({ queryKey: ['tasks', boardId] });
5054
queryClient.invalidateQueries({ queryKey: ['task_lists', boardId] });
51-
};
55+
}, [queryClient, boardId]);
5256

5357
useEffect(() => {
5458
let mounted = true;
@@ -94,6 +98,21 @@ export function KanbanBoard({ boardId, tasks, isLoading }: Props) {
9498
};
9599
}, [boardId]);
96100

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+
97116
const columnsId = useMemo(() => columns.map((col) => col.id), [columns]);
98117

99118
const sensors = useSensors(
@@ -107,6 +126,7 @@ export function KanbanBoard({ boardId, tasks, isLoading }: Props) {
107126
})
108127
);
109128

129+
// Capture drag start card left position
110130
function onDragStart(event: DragStartEvent) {
111131
if (!hasDraggableData(event.active)) return;
112132
const { active } = event;
@@ -121,6 +141,12 @@ export function KanbanBoard({ boardId, tasks, isLoading }: Props) {
121141
const task = active.data.current.task;
122142
setActiveTask(task);
123143
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+
}
124150
return;
125151
}
126152
}
@@ -170,64 +196,96 @@ export function KanbanBoard({ boardId, tasks, isLoading }: Props) {
170196
}
171197
}
172198

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+
173220
async function onDragEnd(event: DragEndEvent) {
174221
const { active, over } = event;
175-
222+
// Always reset drag state, even on invalid drop
176223
setActiveColumn(null);
177224
setActiveTask(null);
178-
225+
pickedUpTaskColumn.current = null;
226+
dragStartCardLeft.current = null;
179227
if (!over) {
180228
// Reset the cache if dropped outside
181229
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+
}
183234
return;
184235
}
185-
186236
const activeType = active.data?.current?.type;
187237
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+
}
189242
return;
190243
}
191-
192244
if (activeType === 'Task') {
193245
const activeTask = active.data?.current?.task;
194246
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+
}
196251
return;
197252
}
198-
199253
let targetListId: string;
200254
if (over.data?.current?.type === 'Column') {
201255
targetListId = String(over.id);
202256
} else if (over.data?.current?.type === 'Task') {
203257
targetListId = String(over.data.current.task.list_id);
204258
} 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+
}
206263
return;
207264
}
208-
209-
const originalListId = pickedUpTaskColumn.current;
265+
const originalListId = event.active.data?.current?.task?.list_id || pickedUpTaskColumn.current;
210266
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+
}
212271
return;
213272
}
214-
215273
const sourceListExists = columns.some(
216274
(col) => String(col.id) === originalListId
217275
);
218276
const targetListExists = columns.some(
219277
(col) => String(col.id) === targetListId
220278
);
221-
222279
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+
}
224284
return;
225285
}
226-
227286
// Only move if actually changing lists
228287
if (targetListId !== originalListId) {
229288
try {
230-
// Optimistically update the task in the cache
231289
queryClient.setQueryData(
232290
['tasks', boardId],
233291
(oldData: TaskType[] | undefined) => {
@@ -237,20 +295,19 @@ export function KanbanBoard({ boardId, tasks, isLoading }: Props) {
237295
);
238296
}
239297
);
240-
241298
moveTaskMutation.mutate({
242299
taskId: activeTask.id,
243300
newListId: targetListId,
244301
});
245302
} catch (error) {
246-
// Revert the optimistic update by invalidating the query
247303
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+
}
249308
}
250309
}
251310
}
252-
253-
pickedUpTaskColumn.current = null;
254311
}
255312

256313
if (isLoading) {
@@ -301,7 +358,7 @@ export function KanbanBoard({ boardId, tasks, isLoading }: Props) {
301358
<div className="flex-1 overflow-hidden">
302359
<DndContext
303360
sensors={sensors}
304-
collisionDetection={closestCenter}
361+
collisionDetection={closestCorners}
305362
onDragStart={onDragStart}
306363
onDragOver={onDragOver}
307364
onDragEnd={onDragEnd}
@@ -313,17 +370,14 @@ export function KanbanBoard({ boardId, tasks, isLoading }: Props) {
313370
modifiers={[
314371
(args) => {
315372
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;
324378
return {
325379
...transform,
326-
x: Math.min(Math.max(transform.x, -50), maxX), // Constrain X position
380+
x: Math.max(minX, Math.min(transform.x, maxX)),
327381
};
328382
},
329383
]}
@@ -333,7 +387,7 @@ export function KanbanBoard({ boardId, tasks, isLoading }: Props) {
333387
items={columnsId}
334388
strategy={horizontalListSortingStrategy}
335389
>
336-
<div className="flex h-full gap-4">
390+
<div ref={boardRef} className="flex h-full gap-4">
337391
{columns
338392
.sort((a, b) => {
339393
// First sort by status priority, then by position within status
@@ -367,7 +421,6 @@ export function KanbanBoard({ boardId, tasks, isLoading }: Props) {
367421
</div>
368422
</SortableContext>
369423
</BoardContainer>
370-
371424
<DragOverlay
372425
wrapperElement="div"
373426
style={{
@@ -376,29 +429,8 @@ export function KanbanBoard({ boardId, tasks, isLoading }: Props) {
376429
pointerEvents: 'none',
377430
}}
378431
>
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}
402434
</DragOverlay>
403435
</DndContext>
404436
</div>

0 commit comments

Comments
 (0)