mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-10 05:37:53 +00:00
fix: cross-column drag-and-drop + suppress tooltip during drag
This commit is contained in:
parent
0c0f5b344d
commit
3cbcb20ecd
4 changed files with 101 additions and 20 deletions
|
|
@ -5,7 +5,7 @@ import { BoardLoadingSkeleton } from './BoardLoadingSkeleton';
|
|||
import { TaskDetailPanel } from '@/components/task/TaskDetailPanel';
|
||||
import type { TaskStatus, Task } from '@veritas-kanban/shared';
|
||||
import { useFeatureSettings } from '@/hooks/useFeatureSettings';
|
||||
import { DndContext, DragOverlay, closestCorners } from '@dnd-kit/core';
|
||||
import { DndContext, DragOverlay } from '@dnd-kit/core';
|
||||
import { useState, useEffect, useCallback, useMemo, lazy, Suspense } from 'react';
|
||||
import { TaskCard } from '@/components/task/TaskCard';
|
||||
import { useKeyboard } from '@/hooks/useKeyboard';
|
||||
|
|
@ -99,7 +99,15 @@ export function KanbanBoard() {
|
|||
setOnMoveTask(handleMoveTask);
|
||||
|
||||
// Drag and drop logic
|
||||
const { activeTask, sensors, handleDragStart, handleDragOver, handleDragEnd } = useBoardDragDrop({
|
||||
const {
|
||||
activeTask,
|
||||
isDragActive,
|
||||
sensors,
|
||||
collisionDetection,
|
||||
handleDragStart,
|
||||
handleDragOver,
|
||||
handleDragEnd,
|
||||
} = useBoardDragDrop({
|
||||
tasks: filteredTasks,
|
||||
tasksByStatus,
|
||||
columns: COLUMNS,
|
||||
|
|
@ -152,7 +160,7 @@ export function KanbanBoard() {
|
|||
{featureSettings.board.enableDragAndDrop ? (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCorners}
|
||||
collisionDetection={collisionDetection}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
|
|
@ -167,6 +175,7 @@ export function KanbanBoard() {
|
|||
allTasks={filteredTasks}
|
||||
onTaskClick={handleTaskClick}
|
||||
selectedTaskId={selectedTaskId}
|
||||
isDragActive={isDragActive}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ interface KanbanColumnProps {
|
|||
allTasks: Task[];
|
||||
onTaskClick?: (task: Task) => void;
|
||||
selectedTaskId?: string | null;
|
||||
isDragActive?: boolean;
|
||||
}
|
||||
|
||||
const columnColors: Record<TaskStatus, string> = {
|
||||
|
|
@ -32,6 +33,7 @@ export function KanbanColumn({
|
|||
allTasks,
|
||||
onTaskClick,
|
||||
selectedTaskId,
|
||||
isDragActive,
|
||||
}: KanbanColumnProps) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id });
|
||||
const { settings: featureSettings } = useFeatureSettings();
|
||||
|
|
@ -97,6 +99,7 @@ export function KanbanColumn({
|
|||
isBlocked={blocked}
|
||||
blockerTitles={blockers.map((b) => b.title)}
|
||||
cardMetrics={taskMetrics}
|
||||
isDragActive={isDragActive}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ const blockedCategoryInfo: Record<
|
|||
interface TaskCardProps {
|
||||
task: Task;
|
||||
isDragging?: boolean;
|
||||
isDragActive?: boolean;
|
||||
onClick?: () => void;
|
||||
isSelected?: boolean;
|
||||
isBlocked?: boolean;
|
||||
|
|
@ -72,6 +73,7 @@ interface TaskCardProps {
|
|||
function areTaskCardPropsEqual(prev: TaskCardProps, next: TaskCardProps): boolean {
|
||||
// Simple scalar/boolean props
|
||||
if (prev.isDragging !== next.isDragging) return false;
|
||||
if (prev.isDragActive !== next.isDragActive) return false;
|
||||
if (prev.isSelected !== next.isSelected) return false;
|
||||
if (prev.isBlocked !== next.isBlocked) return false;
|
||||
// onClick is intentionally skipped — always a new closure but functionally equivalent
|
||||
|
|
@ -140,6 +142,7 @@ const priorityColors: Record<TaskPriority, string> = {
|
|||
export const TaskCard = memo(function TaskCard({
|
||||
task,
|
||||
isDragging,
|
||||
isDragActive,
|
||||
onClick,
|
||||
isSelected,
|
||||
isBlocked,
|
||||
|
|
@ -223,9 +226,12 @@ export const TaskCard = memo(function TaskCard({
|
|||
};
|
||||
}, [task.subtasks]);
|
||||
|
||||
// Suppress the outer card tooltip entirely during any drag operation
|
||||
const suppressCardTooltip = isDragActive || isDragging || isCurrentlyDragging;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={500}>
|
||||
<Tooltip delayDuration={500} open={suppressCardTooltip ? false : undefined}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import {
|
||||
CollisionDetection,
|
||||
DragEndEvent,
|
||||
DragOverEvent,
|
||||
DragStartEvent,
|
||||
PointerSensor,
|
||||
pointerWithin,
|
||||
rectIntersection,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
|
|
@ -20,7 +23,9 @@ interface UseBoardDragDropOptions {
|
|||
|
||||
interface UseBoardDragDropReturn {
|
||||
activeTask: Task | null;
|
||||
isDragActive: boolean;
|
||||
sensors: ReturnType<typeof useSensors>;
|
||||
collisionDetection: CollisionDetection;
|
||||
handleDragStart: (event: DragStartEvent) => void;
|
||||
handleDragOver: (event: DragOverEvent) => void;
|
||||
handleDragEnd: (event: DragEndEvent) => void;
|
||||
|
|
@ -34,6 +39,7 @@ export function useBoardDragDrop({
|
|||
onReorder,
|
||||
}: UseBoardDragDropOptions): UseBoardDragDropReturn {
|
||||
const [activeTask, setActiveTask] = useState<Task | null>(null);
|
||||
const lastOverColumnRef = useRef<TaskStatus | null>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
|
|
@ -43,6 +49,41 @@ export function useBoardDragDrop({
|
|||
})
|
||||
);
|
||||
|
||||
const columnIds = columns.map((c) => c.id);
|
||||
|
||||
// Custom collision detection for kanban cross-column support.
|
||||
// pointerWithin alone misses when the pointer is between cards inside a column,
|
||||
// so we fall back to rectIntersection which catches overlapping rects.
|
||||
// We always prefer column droppables over task droppables for cross-container moves.
|
||||
const collisionDetection: CollisionDetection = useCallback(
|
||||
(args) => {
|
||||
// First try pointerWithin — most accurate when pointer is directly over a droppable
|
||||
const pointerCollisions = pointerWithin(args);
|
||||
|
||||
if (pointerCollisions.length > 0) {
|
||||
// If we hit a column droppable, prefer it for cross-column detection
|
||||
const columnCollision = pointerCollisions.find((c) =>
|
||||
columnIds.includes(c.id as TaskStatus)
|
||||
);
|
||||
// Also check for task collisions within the column
|
||||
const taskCollision = pointerCollisions.find(
|
||||
(c) => !columnIds.includes(c.id as TaskStatus)
|
||||
);
|
||||
|
||||
// If we found a task inside the target column, prefer it (for precise positioning)
|
||||
if (taskCollision) return [taskCollision];
|
||||
// Otherwise use the column (for drops into empty areas or between tasks)
|
||||
if (columnCollision) return [columnCollision];
|
||||
|
||||
return pointerCollisions;
|
||||
}
|
||||
|
||||
// Fallback to rect intersection when pointer isn't directly within any droppable
|
||||
return rectIntersection(args);
|
||||
},
|
||||
[columnIds]
|
||||
);
|
||||
|
||||
// Find which column a task belongs to
|
||||
const findColumnForTask = useCallback(
|
||||
(taskId: string): TaskStatus | null => {
|
||||
|
|
@ -61,32 +102,52 @@ export function useBoardDragDrop({
|
|||
const task = tasks?.find((t) => t.id === event.active.id);
|
||||
if (task) {
|
||||
setActiveTask(task);
|
||||
lastOverColumnRef.current = null;
|
||||
}
|
||||
},
|
||||
[tasks]
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback((_event: DragOverEvent) => {
|
||||
// We don't need real-time container switching since our columns
|
||||
// are droppable targets and tasks are sortable within them.
|
||||
// The visual reordering within a column is handled by SortableContext.
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
setActiveTask(null);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(event: DragOverEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
|
||||
const activeId = active.id as string;
|
||||
const overId = over.id as string;
|
||||
|
||||
// Determine which column the active task is currently in
|
||||
const activeColumn = findColumnForTask(activeId);
|
||||
if (!activeColumn) return;
|
||||
|
||||
// Determine the target column — either the column directly, or the column a task belongs to
|
||||
const isOverColumn = columnIds.includes(overId as TaskStatus);
|
||||
const overColumn = isOverColumn ? (overId as TaskStatus) : findColumnForTask(overId);
|
||||
|
||||
if (!overColumn || activeColumn === overColumn) return;
|
||||
|
||||
// Track which column we're over for handleDragEnd
|
||||
lastOverColumnRef.current = overColumn;
|
||||
},
|
||||
[columnIds, findColumnForTask]
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
setActiveTask(null);
|
||||
lastOverColumnRef.current = null;
|
||||
|
||||
if (!over) return;
|
||||
|
||||
const activeId = active.id as string;
|
||||
const overId = over.id as string;
|
||||
|
||||
// Check if dropped on a column (status) directly
|
||||
const isOverColumn = columns.some((c) => c.id === overId);
|
||||
const isOverColumn = columnIds.includes(overId as TaskStatus);
|
||||
|
||||
if (isOverColumn) {
|
||||
// Dropped on empty column area — change status only
|
||||
// Dropped on column area — change status
|
||||
const newStatus = overId as TaskStatus;
|
||||
const task = tasks?.find((t) => t.id === activeId);
|
||||
if (task && task.status !== newStatus) {
|
||||
|
|
@ -116,21 +177,23 @@ export function useBoardDragDrop({
|
|||
const destTasks = [...tasksByStatus[overColumn]];
|
||||
const overIndex = destTasks.findIndex((t: Task) => t.id === overId);
|
||||
|
||||
// First update the task's status
|
||||
// Update the task's status to the destination column
|
||||
onStatusChange(activeId, overColumn);
|
||||
|
||||
|
||||
// Build the new order for the destination column including the moved task
|
||||
const newOrder = destTasks.map((t: Task) => t.id);
|
||||
newOrder.splice(overIndex, 0, activeId);
|
||||
onReorder(newOrder);
|
||||
}
|
||||
},
|
||||
[columns, findColumnForTask, onReorder, onStatusChange, tasks, tasksByStatus]
|
||||
[columnIds, findColumnForTask, onReorder, onStatusChange, tasks, tasksByStatus]
|
||||
);
|
||||
|
||||
return {
|
||||
activeTask,
|
||||
isDragActive: activeTask !== null,
|
||||
sensors,
|
||||
collisionDetection,
|
||||
handleDragStart,
|
||||
handleDragOver,
|
||||
handleDragEnd,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue