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

This commit is contained in:
2026-08-21 19:42:50 +09:00
parent 5579e71839
commit 94217d773d
6 changed files with 437 additions and 89 deletions
+109 -43
View File
@@ -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)}