import React, { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { closestCorners, DndContext, DragEndEvent, DragOverEvent, DragOverlay, DragStartEvent, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; import { arrayMove, SortableContext } from "@dnd-kit/sortable"; import { BoardColumn } from "./BoardColumn"; import { placeDroppedCard } from "./placement"; import { BoardItem, BoardItemMap, BoardProps } from "./board_types"; import { cls } from "../../util"; export function Board({ data, columns: columnsProp, columnLabels, columnColors, className, assignColumn, allowColumnReorder = false, onColumnReorder, onItemsReorder, ItemComponent, columnLoadingState, onLoadMoreColumn, onAddItemToColumn, AddColumnComponent }: BoardProps) { const [activeItem, setActiveItem] = useState | null>(null); const [isDragging, setIsDragging] = useState(false); const [dragOverColumnId, setDragOverColumnId] = useState(null); /** * Which column the card was picked up from. * * `handleDragOver` moves the card between columns in `itemMapState` while * the pointer is still down — that is what makes the gap open up under the * cursor. So by the time the drop is handled, looking the card up by id * finds it in its *destination*, and the board was reporting the move as * source === target: a cross-column drop looked like a plain reorder and * the column property was never written. The card snapped back on the next * fetch. */ const dragSourceColumnRef = useRef(null); const [itemMapState, setItemMapState] = useState>(() => { const dataColumnMap: Record = data.reduce((prev, item: BoardItem) => ({ ...prev, [item.id]: assignColumn(item) }), {}); return columnsProp.reduce( (previous: BoardItemMap, column: COLUMN) => ({ ...previous, [column]: data.filter((item: BoardItem) => dataColumnMap[item.id] === column) }), {} as BoardItemMap ); }); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }) ); useEffect(() => { if (isDragging) return; const dataColumnMap: Record = data.reduce((prev, item) => ({ ...prev, [item.id]: assignColumn(item) }), {}); const newItemMap = columnsProp.reduce( (previous: BoardItemMap, column: COLUMN) => ({ ...previous, [column]: data.filter((item: BoardItem) => dataColumnMap[item.id] === column) }), {} as BoardItemMap ); setItemMapState(prevMap => { let changed = false; for (const col of columnsProp) { const prevItems = prevMap[col] ?? []; const newItems = newItemMap[col] ?? []; if (prevItems.length !== newItems.length) { changed = true; continue; } for (let i = 0; i < prevItems.length; i++) { if (prevItems[i].id !== newItems[i].id || prevItems[i].data !== newItems[i].data) { changed = true; break; } } } if (!changed) { return prevMap; } const updated: BoardItemMap = {}; for (const col of columnsProp) { const prevItems = prevMap[col] ?? []; const newItems = newItemMap[col] ?? []; const prevById = new Map>(); for (const item of prevItems) { prevById.set(item.id, item); } updated[col] = newItems.map(newItem => { const prev = prevById.get(newItem.id); if (prev && prev.data === newItem.data) { return prev; } return newItem; }); } return updated; }); }, [data, columnsProp, assignColumn]); const findColumnByItemId = (id: string): string | undefined => { return Object.keys(itemMapState).find(col => itemMapState[col]?.some(i => i.id === id)); }; const handleDragStart = (event: DragStartEvent) => { setIsDragging(true); setDragOverColumnId(null); const { active } = event; if (active.data.current?.type === "ITEM") { const columnId = findColumnByItemId(active.id as string); dragSourceColumnRef.current = (columnId as COLUMN | undefined) ?? null; if (columnId) { const item = itemMapState[columnId]?.find(i => i.id === active.id); setActiveItem(item || null); } } }; const handleDragOver = (event: DragOverEvent) => { const { active, over } = event; if (!over) { setDragOverColumnId(null); return; } let currentHoveredColumnId: string | null = null; const overId = over.id as string; const overDataType = over.data.current?.type as string | undefined; if (overDataType === "ITEM-LIST" || overDataType === "COLUMN") { currentHoveredColumnId = overId; } else if (overDataType === "ITEM") { currentHoveredColumnId = findColumnByItemId(overId) || null; } else if (columnsProp.includes(overId as COLUMN)) { currentHoveredColumnId = overId; } setDragOverColumnId(currentHoveredColumnId); if (active.data.current?.type !== "ITEM") { return; } const activeId = active.id as string; const activeItemColumn = findColumnByItemId(activeId); let overColumnForMove = findColumnByItemId(overId); if (!overColumnForMove && overDataType === "ITEM-LIST") { overColumnForMove = overId; } if (!overColumnForMove && columnsProp.includes(overId as COLUMN)) { overColumnForMove = overId; } if (!activeItemColumn || !overColumnForMove) return; if (activeItemColumn === overColumnForMove) return; if (itemMapState[overColumnForMove]?.some(i => i.id === activeId)) { return; } setItemMapState(currentMap => { const activeItems = [...(currentMap[activeItemColumn] || [])]; const overItems = [...(currentMap[overColumnForMove!] || [])]; const activeIndex = activeItems.findIndex(i => i.id === activeId); if (activeIndex === -1) return currentMap; let overIndex; if (overDataType === "ITEM-LIST" || (columnsProp.includes(overId as COLUMN) && !findColumnByItemId(overId))) { overIndex = overItems.length; } else { overIndex = overItems.findIndex(i => i.id === overId); if (overIndex !== -1) { const activeTop = active.rect.current.translated?.top ?? 0; const activeHeight = active.rect.current.translated?.height ?? 0; const activeCenter = activeTop + activeHeight / 2; const overTop = over?.rect.top ?? 0; const overHeight = over?.rect.height ?? 0; const overCenter = overTop + overHeight / 2; const isBelowOverItem = activeCenter > overCenter; const modifier = isBelowOverItem ? 1 : 0; overIndex = overIndex >= 0 ? overIndex + modifier : overItems.length; } else { overIndex = overItems.length; } } const newItemMap = { ...currentMap }; const [moved] = activeItems.splice(activeIndex, 1); overItems.splice(overIndex, 0, moved); newItemMap[activeItemColumn] = activeItems; newItemMap[overColumnForMove!] = overItems; return newItemMap; }); }; // Escape, or a drop outside the board. `handleDragOver` may already have // moved the card between columns, so the next `data` effect has to be // allowed to put it back. const handleDragCancel = () => { setIsDragging(false); setActiveItem(null); setDragOverColumnId(null); dragSourceColumnRef.current = null; }; const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; const sourceColumn = dragSourceColumnRef.current; setIsDragging(false); setActiveItem(null); setDragOverColumnId(null); dragSourceColumnRef.current = null; if (!over) return; const activeId = active.id as string; const overId = over.id as string; if (active.data.current?.type === "COLUMN") { if (activeId !== overId) { const oldIndex = columnsProp.indexOf(activeId as COLUMN); const newIndex = columnsProp.indexOf(overId as COLUMN); if (oldIndex !== -1 && newIndex !== -1) { const reordered = arrayMove(columnsProp, oldIndex, newIndex); onColumnReorder?.(reordered); } } return; } // Where the card sits right now — `handleDragOver` has already put it in // the column being hovered, so this is the destination, not the origin. const currentCol = findColumnByItemId(activeId) as COLUMN | undefined; let targetCol = findColumnByItemId(overId) as COLUMN | undefined; if (!targetCol) { const overDataType = over.data.current?.type; if (overDataType === "ITEM-LIST" || columnsProp.includes(overId as COLUMN)) { targetCol = overId as COLUMN; } } targetCol = targetCol ?? currentCol; if (!currentCol || !targetCol) return; const targetItems = itemMapState[targetCol] || []; const movedItem = (itemMapState[currentCol] || []).find(i => i.id === activeId); if (!movedItem) return; const finalTargetItems = placeDroppedCard({ targetItems, movedItem, overId, changedColumn: (sourceColumn ?? currentCol) !== targetCol }); onItemsReorder?.(finalTargetItems, { itemId: activeId, // The column the pointer went down in. `currentCol` would report a // cross-column move as a same-column reorder. sourceColumn: (sourceColumn ?? currentCol), targetColumn: targetCol }); }; return (
{columnsProp.map((col, index) => { const colItems = itemMapState[col] || []; const loadingState = columnLoadingState?.[col]; return ( onLoadMoreColumn(col) : undefined} onAddItem={onAddItemToColumn ? () => onAddItemToColumn(col) : undefined} /> ); })} {AddColumnComponent}
{/* The overlay used to be positioned by hand, from `mousemove` listeners on `window` and a grab offset measured off whatever `[role='button']` the pointer went down on. dnd-kit already tracks the pointer and already knows the grab offset — the hand-rolled copy drifted from the position collision detection was actually using, so the card you saw and the slot you got were two different things. */} {typeof document !== "undefined" && createPortal( {activeItem && ( )} , document.body )}
); }