feat: enhance task item UX with Notion drag handle, F2 edit, and floating undo toast (v0.6.3)
Build and Push Docker Image / build-and-push (push) Canceled after 23s
Build and Push Docker Image / build-and-push (push) Canceled after 23s
This commit is contained in:
@@ -43,6 +43,7 @@ interface TaskItemProps {
|
||||
isTrashMode?: boolean;
|
||||
onRestore?: (id: string) => void;
|
||||
onPermanentDelete?: (id: string) => void;
|
||||
onDragTask?: (draggedId: string, targetId: string, position: "before" | "after") => void;
|
||||
}
|
||||
|
||||
// Recursive Task Tree Item (Supports 1st, 2nd, 3rd, N-level sub-tasks seamlessly)
|
||||
@@ -58,6 +59,7 @@ function RecursiveTaskItem({
|
||||
isTrashMode = false,
|
||||
onRestore,
|
||||
onPermanentDelete,
|
||||
onDragTask,
|
||||
}: TaskItemProps) {
|
||||
const { t, lang } = useI18n();
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
@@ -154,11 +156,21 @@ function RecursiveTaskItem({
|
||||
touchStartX.current = null;
|
||||
};
|
||||
|
||||
const [dragOverPos, setDragOverPos] = useState<"top" | "bottom" | null>(null);
|
||||
|
||||
return (
|
||||
<div style={{ paddingLeft: depth > 0 ? 24 : 0, position: "relative" }}>
|
||||
<div
|
||||
className={`task-item${task.completed ? " completed" : ""}${isSelected ? " selected" : ""}`}
|
||||
className={`task-item${task.completed ? " completed" : ""}${isSelected ? " selected" : ""}${dragOverPos ? ` drag-over-${dragOverPos}` : ""}`}
|
||||
onClick={() => onSelect(task)}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "F2" && !isTrashMode) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setEditingTitle(true);
|
||||
}
|
||||
}}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
@@ -167,6 +179,24 @@ function RecursiveTaskItem({
|
||||
e.stopPropagation();
|
||||
onContextMenu(e.clientX, e.clientY, task);
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (isTrashMode) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const mid = rect.top + rect.height / 2;
|
||||
setDragOverPos(e.clientY < mid ? "top" : "bottom");
|
||||
}}
|
||||
onDragLeave={() => setDragOverPos(null)}
|
||||
onDrop={(e) => {
|
||||
if (isTrashMode) return;
|
||||
e.preventDefault();
|
||||
const draggedId = e.dataTransfer.getData("text/plain");
|
||||
if (draggedId && draggedId !== task.id && onDragTask) {
|
||||
onDragTask(draggedId, task.id, dragOverPos === "top" ? "before" : "after");
|
||||
}
|
||||
setDragOverPos(null);
|
||||
}}
|
||||
id={`task-${task.id}`}
|
||||
style={{
|
||||
borderLeft: depth > 0 ? "2px solid var(--border)" : "none",
|
||||
@@ -175,6 +205,30 @@ function RecursiveTaskItem({
|
||||
transition: swipeOffset === 0 ? "transform 0.2s cubic-bezier(0.16, 1, 0.3, 1)" : "none",
|
||||
}}
|
||||
>
|
||||
{/* Notion-style 6-dot Drag Handle */}
|
||||
{!isTrashMode && (
|
||||
<div
|
||||
className="task-drag-handle"
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.setData("text/plain", task.id);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
title="Drag to reorder"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor">
|
||||
<circle cx="5" cy="3" r="1.5" />
|
||||
<circle cx="11" cy="3" r="1.5" />
|
||||
<circle cx="5" cy="8" r="1.5" />
|
||||
<circle cx="11" cy="8" r="1.5" />
|
||||
<circle cx="5" cy="13" r="1.5" />
|
||||
<circle cx="11" cy="13" r="1.5" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toggle Expand Arrow if has children */}
|
||||
{totalChildren > 0 ? (
|
||||
<button
|
||||
@@ -246,7 +300,7 @@ function RecursiveTaskItem({
|
||||
e.stopPropagation();
|
||||
setEditingTitle(true);
|
||||
}}
|
||||
title="Double click to edit"
|
||||
title="Double click or press F2 to edit"
|
||||
style={{ fontSize: depth > 0 ? 13 : 14, fontWeight: depth === 0 ? 600 : 500 }}
|
||||
>
|
||||
{task.title}
|
||||
@@ -310,23 +364,6 @@ function RecursiveTaskItem({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Inline Add Subtask button */}
|
||||
{!isTrashMode && (
|
||||
<button
|
||||
className="badge badge-neutral"
|
||||
style={{ cursor: "pointer", fontSize: 10, padding: "1px 6px", border: "1px solid var(--border)", background: "transparent" }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setAddingSubtask(true);
|
||||
setExpanded(true);
|
||||
}}
|
||||
title="Add subtask"
|
||||
type="button"
|
||||
>
|
||||
+ {t("subtasks")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{task.note && !task.completed && depth === 0 && (
|
||||
@@ -334,9 +371,9 @@ function RecursiveTaskItem({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Trash Mode Actions or Edit Icon */}
|
||||
{/* Right Actions: Subtask Add Button & Trash Mode Controls */}
|
||||
{isTrashMode ? (
|
||||
<div style={{ display: "flex", gap: 6 }}>
|
||||
<div style={{ display: "flex", gap: 6, marginLeft: "auto" }}>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
style={{ fontSize: 11, padding: "2px 8px" }}
|
||||
@@ -363,20 +400,27 @@ function RecursiveTaskItem({
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="icon-btn"
|
||||
style={{ width: 22, height: 22, opacity: 0.35, flexShrink: 0 }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingTitle(true);
|
||||
}}
|
||||
title="Edit title"
|
||||
type="button"
|
||||
>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 20h9" /><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="task-actions-right">
|
||||
<button
|
||||
className="badge badge-neutral"
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
fontSize: 11,
|
||||
padding: "2px 8px",
|
||||
border: "1px solid var(--border)",
|
||||
background: "var(--bg-secondary)",
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setAddingSubtask(true);
|
||||
setExpanded(true);
|
||||
}}
|
||||
title="Add subtask"
|
||||
type="button"
|
||||
>
|
||||
+ {t("subtasks")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -397,6 +441,7 @@ function RecursiveTaskItem({
|
||||
isTrashMode={isTrashMode}
|
||||
onRestore={onRestore}
|
||||
onPermanentDelete={onPermanentDelete}
|
||||
onDragTask={onDragTask}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -460,6 +505,7 @@ interface Props {
|
||||
onDemoToggleTask?: (id: string, completed: boolean) => void;
|
||||
onUpdateTaskTitle?: (id: string, title: string) => void;
|
||||
onUpdateListName?: (id: string, name: string) => void;
|
||||
onDeleteTaskWithUndo?: (task: Task) => void;
|
||||
onDeleteTask?: (id: string) => void;
|
||||
}
|
||||
|
||||
@@ -485,6 +531,7 @@ export function TaskList({
|
||||
onDemoToggleTask,
|
||||
onUpdateTaskTitle,
|
||||
onUpdateListName,
|
||||
onDeleteTaskWithUndo,
|
||||
onDeleteTask,
|
||||
}: Props) {
|
||||
const { t } = useI18n();
|
||||
@@ -640,6 +687,30 @@ export function TaskList({
|
||||
setEditingHeader(false);
|
||||
};
|
||||
|
||||
const handleReorderTasks = useCallback((draggedId: string, targetId: string, position: "before" | "after") => {
|
||||
setTasks((prev) => {
|
||||
const draggedIndex = prev.findIndex((t) => t.id === draggedId);
|
||||
const targetIndex = prev.findIndex((t) => t.id === targetId);
|
||||
if (draggedIndex === -1 || targetIndex === -1) return prev;
|
||||
|
||||
const newArr = [...prev];
|
||||
const [draggedItem] = newArr.splice(draggedIndex, 1);
|
||||
const insertAt = position === "before"
|
||||
? (draggedIndex < targetIndex ? targetIndex - 1 : targetIndex)
|
||||
: (draggedIndex < targetIndex ? targetIndex : targetIndex + 1);
|
||||
newArr.splice(Math.max(0, insertAt), 0, draggedItem);
|
||||
return newArr;
|
||||
});
|
||||
}, [setTasks]);
|
||||
|
||||
const handleDeleteTask = useCallback((task: Task) => {
|
||||
if (onDeleteTaskWithUndo) {
|
||||
onDeleteTaskWithUndo(task);
|
||||
} else if (onDeleteTask) {
|
||||
onDeleteTask(task.id);
|
||||
}
|
||||
}, [onDeleteTaskWithUndo, onDeleteTask]);
|
||||
|
||||
if (!listId && !isTrashActive && !selectedTag) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", height: "100%", color: "var(--text-tertiary)" }}>
|
||||
@@ -830,6 +901,7 @@ export function TaskList({
|
||||
isTrashMode={isTrashActive}
|
||||
onRestore={onRestoreTask}
|
||||
onPermanentDelete={onPermanentDeleteTask}
|
||||
onDragTask={handleReorderTasks}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -862,6 +934,7 @@ export function TaskList({
|
||||
isTrashMode={isTrashActive}
|
||||
onRestore={onRestoreTask}
|
||||
onPermanentDelete={onPermanentDeleteTask}
|
||||
onDragTask={handleReorderTasks}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -897,14 +970,7 @@ export function TaskList({
|
||||
label: t("deleteTaskConfirm").split("?")[0],
|
||||
icon: "🗑️",
|
||||
danger: true,
|
||||
onClick: async () => {
|
||||
if (onDeleteTask) {
|
||||
onDeleteTask(contextMenu.task.id);
|
||||
} else if (!isDemo) {
|
||||
await fetch(`/api/tasks/${contextMenu.task.id}`, { method: "DELETE" });
|
||||
onRefresh();
|
||||
}
|
||||
},
|
||||
onClick: () => handleDeleteTask(contextMenu.task),
|
||||
},
|
||||
]}
|
||||
onClose={() => setContextMenu(null)}
|
||||
|
||||
Reference in New Issue
Block a user