Select on boardview
This commit is contained in:
@@ -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" />
|
||||||
@@ -136,4 +344,4 @@ export default function BoardView({
|
|||||||
</DragDropContext>
|
</DragDropContext>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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}
|
||||||
@@ -92,4 +96,4 @@ export default function BoardTask({
|
|||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
</TaskEdit>
|
</TaskEdit>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user