Select on boardview
This commit is contained in:
@@ -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<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) {
|
||||
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<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) => ({
|
||||
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 (
|
||||
<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}>
|
||||
<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 }) => (
|
||||
@@ -85,9 +272,14 @@ export default function BoardView({
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
ref={(el) => {
|
||||
provided.innerRef(el);
|
||||
taskCardRefs.current[task.id] = el;
|
||||
}}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
data-task-card={task.id}
|
||||
onClick={(e) => handleTaskClick(e, task.id)}
|
||||
>
|
||||
<BoardTask
|
||||
setTask={(t) =>
|
||||
@@ -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;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
@@ -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;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="text-destructive" />
|
||||
|
||||
@@ -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<TagId, Tag>;
|
||||
onDelete?: (taskId: string) => void;
|
||||
isSelected?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<TaskEdit task={task} tags={tags} setTask={setTask}>
|
||||
<SheetTrigger asChild>
|
||||
<Card
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
className="bg-background cursor-grab active:cursor-grabbing"
|
||||
className={cn(
|
||||
"bg-background cursor-grab active:cursor-grabbing transition-all duration-150",
|
||||
isSelected &&
|
||||
"ring-2 ring-primary ring-offset-1 bg-primary/5 mt-1"
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
<div className="flex gap-1 ml-auto">
|
||||
<DocDialog task={task} setTask={setTask} tags={tags}>
|
||||
@@ -73,7 +77,7 @@ export default function BoardTask({
|
||||
</CardDescription>
|
||||
)}
|
||||
</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) => (
|
||||
<Badge
|
||||
key={tagId}
|
||||
|
||||
Reference in New Issue
Block a user