Select on boardview

This commit is contained in:
2026-02-28 02:20:11 +01:00
parent b3d4558671
commit f20fd1d65a
2 changed files with 225 additions and 13 deletions
+213 -5
View File
@@ -12,6 +12,7 @@ import {
ContextMenuItem, ContextMenuItem,
ContextMenuTrigger, ContextMenuTrigger,
} from "@/components/ui/context-menu"; } from "@/components/ui/context-menu";
import { useState, useEffect, useRef, useCallback } from "react";
export default function BoardView({ export default function BoardView({
tags, tags,
@@ -20,6 +21,18 @@ export default function BoardView({
createTask, createTask,
statuses, statuses,
}: TaskViewProps) { }: TaskViewProps) {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [isDraggingSelection, setIsDraggingSelection] = useState(false);
const [selectionBox, setSelectionBox] = useState<{
startX: number;
startY: number;
endX: number;
endY: number;
} | null>(null);
const boardRef = useRef<HTMLDivElement>(null);
const taskCardRefs = useRef<Record<string, HTMLDivElement | null>>({});
const mouseDownPos = useRef<{ x: number; y: number } | null>(null);
function onDragEnd(result: any) { function onDragEnd(result: any) {
const { draggableId, source, destination } = result; const { draggableId, source, destination } = result;
if (!destination) return; if (!destination) return;
@@ -37,10 +50,130 @@ export default function BoardView({
}); });
} }
// Group tasks by status, using statuses from first available todo entity const deleteSelected = useCallback(() => {
const tasksArray = Object.values(tasks); 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<string>();
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) => ({ const tasksByStatus = statuses.map((status) => ({
status, status,
tasks: tasksArray tasks: tasksArray
@@ -48,8 +181,62 @@ export default function BoardView({
.sort((a, b) => a.createdAt - b.createdAt), .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 ( return (
<div className="w-full h-full flex flex-col gap-6 overflow-hidden flex-1 min-h-0"> <div
ref={boardRef}
className="w-full h-full flex flex-col gap-6 overflow-hidden flex-1 min-h-0 relative"
>
{/* Selection box overlay */}
{isDraggingSelection && selRect && (
<div
className="fixed pointer-events-none z-50 border border-primary bg-primary/10 rounded"
style={{
left: selRect.left,
top: selRect.top,
width: selRect.width,
height: selRect.height,
}}
/>
)}
{/* Delete bar when items selected */}
{selectedIds.size > 0 && (
<div className="flex items-center gap-2 px-3 py-2 bg-destructive/10 border border-destructive/30 rounded-md text-sm text-destructive">
<span className="font-medium">
{selectedIds.size} task{selectedIds.size !== 1 ? "s" : ""} selected
</span>
<span className="text-muted-foreground">
— press Backspace to delete
</span>
<Button
size="sm"
variant="destructive"
className="ml-auto h-7"
onClick={deleteSelected}
>
<Trash2 className="h-3 w-3 mr-1" />
Delete
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 text-muted-foreground"
onClick={() => setSelectedIds(new Set())}
>
Clear
</Button>
</div>
)}
<DragDropContext onDragEnd={onDragEnd}> <DragDropContext onDragEnd={onDragEnd}>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 items-start flex-1 h-full min-h-0"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6 items-start flex-1 h-full min-h-0">
{tasksByStatus.map(({ status, tasks: statusTasks }) => ( {tasksByStatus.map(({ status, tasks: statusTasks }) => (
@@ -85,9 +272,14 @@ export default function BoardView({
<ContextMenu> <ContextMenu>
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
<div <div
ref={provided.innerRef} ref={(el) => {
provided.innerRef(el);
taskCardRefs.current[task.id] = el;
}}
{...provided.draggableProps} {...provided.draggableProps}
{...provided.dragHandleProps} {...provided.dragHandleProps}
data-task-card={task.id}
onClick={(e) => handleTaskClick(e, task.id)}
> >
<BoardTask <BoardTask
setTask={(t) => setTask={(t) =>
@@ -99,6 +291,17 @@ export default function BoardView({
provided={provided} provided={provided}
task={task} task={task}
tags={tags} 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;
});
}}
/> />
</div> </div>
</ContextMenuTrigger> </ContextMenuTrigger>
@@ -110,6 +313,11 @@ export default function BoardView({
const updated = { ...tasks }; const updated = { ...tasks };
delete updated[task.id]; delete updated[task.id];
setTasks(updated); setTasks(updated);
setSelectedIds((prev) => {
const next = new Set(prev);
next.delete(task.id);
return next;
});
}} }}
> >
<Trash2 className="text-destructive" /> <Trash2 className="text-destructive" />
+10 -6
View File
@@ -14,6 +14,7 @@ import TaskEdit from "../edit/TaskEdit";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { NotepadText, Trash2 } from "lucide-react"; import { NotepadText, Trash2 } from "lucide-react";
import DocDialog from "../edit/DocDialog"; import DocDialog from "../edit/DocDialog";
import { cn } from "@/lib/utils";
export default function BoardTask({ export default function BoardTask({
task, task,
@@ -21,24 +22,27 @@ export default function BoardTask({
setTask, setTask,
tags, tags,
onDelete, onDelete,
isSelected,
}: { }: {
task: Task; task: Task;
setTask: (t: Task) => void; setTask: (t: Task) => void;
provided: DraggableProvided; provided: DraggableProvided;
tags: Record<TagId, Tag>; tags: Record<TagId, Tag>;
onDelete?: (taskId: string) => void; onDelete?: (taskId: string) => void;
isSelected?: boolean;
}) { }) {
return ( return (
<TaskEdit task={task} tags={tags} setTask={setTask}> <TaskEdit task={task} tags={tags} setTask={setTask}>
<SheetTrigger asChild> <SheetTrigger asChild>
<Card <Card
ref={provided.innerRef} className={cn(
{...provided.draggableProps} "bg-background cursor-grab active:cursor-grabbing transition-all duration-150",
{...provided.dragHandleProps} isSelected &&
className="bg-background cursor-grab active:cursor-grabbing" "ring-2 ring-primary ring-offset-1 bg-primary/5 mt-1"
)}
> >
<CardHeader> <CardHeader>
<CardTitle className="flex items-center justify-between"> <CardTitle className="flex items-center justify-between select-none">
<span className="text-sm font-semibold">{task.title}</span> <span className="text-sm font-semibold">{task.title}</span>
<div className="flex gap-1 ml-auto"> <div className="flex gap-1 ml-auto">
<DocDialog task={task} setTask={setTask} tags={tags}> <DocDialog task={task} setTask={setTask} tags={tags}>
@@ -73,7 +77,7 @@ export default function BoardTask({
</CardDescription> </CardDescription>
)} )}
</CardHeader> </CardHeader>
<CardContent className="flex flex-wrap gap-1.5 overflow-hidden"> <CardContent className="flex flex-wrap gap-1.5 overflow-hidden select-none">
{task.tagIds?.map((tagId) => ( {task.tagIds?.map((tagId) => (
<Badge <Badge
key={tagId} key={tagId}