diff --git a/src/components/view/board/BoardView.tsx b/src/components/view/board/BoardView.tsx index f4ecabf..1afe933 100644 --- a/src/components/view/board/BoardView.tsx +++ b/src/components/view/board/BoardView.tsx @@ -12,6 +12,7 @@ import { ContextMenuItem, ContextMenuTrigger, } from "@/components/ui/context-menu"; +import { useState, useEffect, useRef, useCallback } from "react"; export default function BoardView({ tags, @@ -20,6 +21,18 @@ export default function BoardView({ createTask, statuses, }: TaskViewProps) { + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [isDraggingSelection, setIsDraggingSelection] = useState(false); + const [selectionBox, setSelectionBox] = useState<{ + startX: number; + startY: number; + endX: number; + endY: number; + } | null>(null); + const boardRef = useRef(null); + const taskCardRefs = useRef>({}); + const mouseDownPos = useRef<{ x: number; y: number } | null>(null); + function onDragEnd(result: any) { const { draggableId, source, destination } = result; if (!destination) return; @@ -37,10 +50,130 @@ export default function BoardView({ }); } - // Group tasks by status, using statuses from first available todo entity - const tasksArray = Object.values(tasks); + const deleteSelected = useCallback(() => { + if (selectedIds.size === 0) return; + const updated = { ...tasks }; + selectedIds.forEach((id) => delete updated[id]); + setTasks(updated); + setSelectedIds(new Set()); + }, [selectedIds, tasks, setTasks]); - // Sort tasks by creation date within each status + // Ctrl+A to select all, Backspace/Delete to delete selected + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "a" && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + setSelectedIds(new Set(Object.keys(tasks))); + } + if (e.key === "Backspace" || e.key === "Delete") { + // Don't fire if user is typing in an input/textarea + const target = e.target as HTMLElement; + if ( + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable + ) + return; + deleteSelected(); + } + } + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [tasks, deleteSelected]); + + // Mouse drag selection + useEffect(() => { + function handleMouseDown(e: MouseEvent) { + // Only start selection if clicking on the board background (not a card) + const target = e.target as HTMLElement; + const isCard = target.closest("[data-task-card]"); + if (isCard) return; + if (e.button !== 0) return; + + mouseDownPos.current = { x: e.clientX, y: e.clientY }; + } + + function handleMouseMove(e: MouseEvent) { + if (!mouseDownPos.current) return; + const dx = Math.abs(e.clientX - mouseDownPos.current.x); + const dy = Math.abs(e.clientY - mouseDownPos.current.y); + if (dx > 4 || dy > 4) { + setIsDraggingSelection(true); + setSelectionBox({ + startX: mouseDownPos.current.x, + startY: mouseDownPos.current.y, + endX: e.clientX, + endY: e.clientY, + }); + } + } + + function handleMouseUp(e: MouseEvent) { + if (isDraggingSelection && selectionBox) { + // Find all task cards that intersect with the selection box + const minX = Math.min(selectionBox.startX, selectionBox.endX); + const maxX = Math.max(selectionBox.startX, selectionBox.endX); + const minY = Math.min(selectionBox.startY, selectionBox.endY); + const maxY = Math.max(selectionBox.startY, selectionBox.endY); + + const newSelected = new Set(); + Object.entries(taskCardRefs.current).forEach(([id, el]) => { + if (!el) return; + const rect = el.getBoundingClientRect(); + if ( + rect.left < maxX && + rect.right > minX && + rect.top < maxY && + rect.bottom > minY + ) { + newSelected.add(id); + } + }); + + if (e.shiftKey) { + setSelectedIds((prev) => new Set([...prev, ...newSelected])); + } else { + setSelectedIds(newSelected); + } + } else if (!isDraggingSelection) { + // Click on empty area deselects + const target = e.target as HTMLElement; + const isCard = target.closest("[data-task-card]"); + if (!isCard) { + setSelectedIds(new Set()); + } + } + + mouseDownPos.current = null; + setIsDraggingSelection(false); + setSelectionBox(null); + } + + window.addEventListener("mousedown", handleMouseDown); + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + return () => { + window.removeEventListener("mousedown", handleMouseDown); + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + }, [isDraggingSelection, selectionBox]); + + function handleTaskClick(e: React.MouseEvent, taskId: string) { + if (e.ctrlKey || e.metaKey) { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(taskId)) next.delete(taskId); + else next.add(taskId); + return next; + }); + } else if (e.shiftKey) { + setSelectedIds((prev) => new Set([...prev, taskId])); + } + // Normal click handled by SheetTrigger (task edit) if not multi-select + } + + const tasksArray = Object.values(tasks); const tasksByStatus = statuses.map((status) => ({ status, tasks: tasksArray @@ -48,8 +181,62 @@ export default function BoardView({ .sort((a, b) => a.createdAt - b.createdAt), })); + const selRect = selectionBox + ? { + left: Math.min(selectionBox.startX, selectionBox.endX), + top: Math.min(selectionBox.startY, selectionBox.endY), + width: Math.abs(selectionBox.endX - selectionBox.startX), + height: Math.abs(selectionBox.endY - selectionBox.startY), + } + : null; + return ( -
+
+ {/* Selection box overlay */} + {isDraggingSelection && selRect && ( +
+ )} + + {/* Delete bar when items selected */} + {selectedIds.size > 0 && ( +
+ + {selectedIds.size} task{selectedIds.size !== 1 ? "s" : ""} selected + + + — press Backspace to delete + + + +
+ )} +
{tasksByStatus.map(({ status, tasks: statusTasks }) => ( @@ -85,9 +272,14 @@ export default function BoardView({
{ + provided.innerRef(el); + taskCardRefs.current[task.id] = el; + }} {...provided.draggableProps} {...provided.dragHandleProps} + data-task-card={task.id} + onClick={(e) => handleTaskClick(e, task.id)} > @@ -99,6 +291,17 @@ export default function BoardView({ provided={provided} task={task} tags={tags} + isSelected={selectedIds.has(task.id)} + onDelete={(id) => { + const updated = { ...tasks }; + delete updated[id]; + setTasks(updated); + setSelectedIds((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); + }} />
@@ -110,6 +313,11 @@ export default function BoardView({ const updated = { ...tasks }; delete updated[task.id]; setTasks(updated); + setSelectedIds((prev) => { + const next = new Set(prev); + next.delete(task.id); + return next; + }); }} > @@ -136,4 +344,4 @@ export default function BoardView({
); -} +} \ No newline at end of file diff --git a/src/components/view/board/Task.tsx b/src/components/view/board/Task.tsx index 5f58544..d662510 100644 --- a/src/components/view/board/Task.tsx +++ b/src/components/view/board/Task.tsx @@ -14,6 +14,7 @@ import TaskEdit from "../edit/TaskEdit"; import { Button } from "@/components/ui/button"; import { NotepadText, Trash2 } from "lucide-react"; import DocDialog from "../edit/DocDialog"; +import { cn } from "@/lib/utils"; export default function BoardTask({ task, @@ -21,24 +22,27 @@ export default function BoardTask({ setTask, tags, onDelete, + isSelected, }: { task: Task; setTask: (t: Task) => void; provided: DraggableProvided; tags: Record; onDelete?: (taskId: string) => void; + isSelected?: boolean; }) { return ( - + {task.title}
@@ -73,7 +77,7 @@ export default function BoardTask({ )} - + {task.tagIds?.map((tagId) => ( ); -} +} \ No newline at end of file