feat(kanban): smooth drop slot instead of a flickering line; fix reload after reordering
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m39s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m39s
The column works out the slot from the pointer (cards no longer handle drag enter/leave, which
flickered on their child elements); a gap of the card's height opens there with framer-motion,
the dragged card leaves its old place and lights up where it lands.
The realtime handler reloaded with the loadData of its first render ("Alle Aufgaben"), so a
selected list showed other lists' tasks after TASK_REORDERED; it now calls the current one.
v0.38.1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1e3ac9786f
commit
284ef1f723
5 files changed
+129
-46
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useState, useRef, useMemo } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import {
|
||||
Check,
|
||||
Plus,
|
||||
@@ -134,6 +135,12 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
const [draggingTaskId, setDraggingTaskId] = useState<string | null>(null);
|
||||
const [dragOverTaskTarget, setDragOverTaskTarget] = useState<{ taskId: string; position: 'before' | 'after' } | null>(null);
|
||||
const [dragOverKanbanCol, setDragOverKanbanCol] = useState<string | null>(null);
|
||||
// Kanban drag: the column works out the slot from the pointer; a gap of the card's height opens there
|
||||
// (no per-card enter/leave flicker), the dragged card leaves its old place, and lights up where it lands
|
||||
const [kanbanSlot, setKanbanSlot] = useState<{ col: string; index: number } | null>(null);
|
||||
const [kanbanHiddenId, setKanbanHiddenId] = useState<string | null>(null);
|
||||
const [justDroppedId, setJustDroppedId] = useState<string | null>(null);
|
||||
const dragHeightRef = useRef(64);
|
||||
// List tab a dragged task hovers over (dropping moves it into that list)
|
||||
const [dragOverListTab, setDragOverListTab] = useState<string | null>(null);
|
||||
|
||||
@@ -241,6 +248,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
|
||||
const onRefreshStatsRef = useRef(onRefreshStats);
|
||||
onRefreshStatsRef.current = onRefreshStats;
|
||||
// The realtime handler is registered once; it must reload with the current list, not the first one
|
||||
const loadDataRef = useRef(loadData);
|
||||
loadDataRef.current = loadData;
|
||||
|
||||
// Live-Sync WebSocket listener for tasks (registered once on mount)
|
||||
useEffect(() => {
|
||||
@@ -290,7 +300,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
onRefreshStatsRef.current();
|
||||
}
|
||||
} else if (msg.event === 'TASK_REORDERED') {
|
||||
loadData();
|
||||
loadDataRef.current();
|
||||
} else if (msg.event === 'TASK_LIST_CREATED' || msg.event === 'TASK_LIST_UPDATED' || msg.event === 'TASK_LIST_DELETED') {
|
||||
// Payload only carries the list ID; reload so permissions reflect this user's view
|
||||
api.getTaskLists().then((lists) => setTaskLists(Array.isArray(lists) ? lists : [])).catch(() => {});
|
||||
@@ -901,21 +911,89 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleDropOnKanbanCol = async (e: React.DragEvent, colId: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const sourceId =
|
||||
e.dataTransfer.getData('application/mindsift-task-id') ||
|
||||
e.dataTransfer.getData('text/plain') ||
|
||||
draggingTaskId;
|
||||
|
||||
const resetKanbanDrag = () => {
|
||||
setDraggingTaskId(null);
|
||||
setDragOverTaskTarget(null);
|
||||
setDragOverKanbanCol(null);
|
||||
await moveTaskToColumn(sourceId, colId);
|
||||
setDragOverListTab(null);
|
||||
setKanbanSlot(null);
|
||||
setKanbanHiddenId(null);
|
||||
};
|
||||
|
||||
// The gap where the dragged card would land; it opens and closes smoothly, the cards below slide along
|
||||
const renderDropSlot = (colId: string, index: number) => (
|
||||
<motion.div
|
||||
key={`slot-${colId}-${index}`}
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: dragHeightRef.current, opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
className="rounded-card border-2 border-dashed border-accent/50 bg-accent/5 pointer-events-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
|
||||
const handleKanbanColDragOver = (e: React.DragEvent<HTMLDivElement>, colId: string) => {
|
||||
e.preventDefault();
|
||||
if (!draggingTaskId) return;
|
||||
if (dragOverKanbanCol !== colId) setDragOverKanbanCol(colId);
|
||||
// Slot = number of cards (without the dragged one) whose middle lies above the pointer
|
||||
const cards = Array.from(e.currentTarget.querySelectorAll<HTMLElement>('[data-kanban-task]')).filter(
|
||||
(el) => el.dataset.kanbanTask !== draggingTaskId
|
||||
);
|
||||
let index = cards.length;
|
||||
for (let i = 0; i < cards.length; i++) {
|
||||
const r = cards[i].getBoundingClientRect();
|
||||
if (e.clientY < r.top + r.height / 2) {
|
||||
index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!kanbanSlot || kanbanSlot.col !== colId || kanbanSlot.index !== index) setKanbanSlot({ col: colId, index });
|
||||
};
|
||||
|
||||
const handleKanbanColDragLeave = (e: React.DragEvent<HTMLDivElement>, colId: string) => {
|
||||
// Moving onto a child of the column is no leaving
|
||||
if (e.currentTarget.contains(e.relatedTarget as Node)) return;
|
||||
if (dragOverKanbanCol === colId) setDragOverKanbanCol(null);
|
||||
setKanbanSlot((cur) => (cur?.col === colId ? null : cur));
|
||||
};
|
||||
|
||||
const handleKanbanDrop = async (e: React.DragEvent, colId: string, colTasks: Thought[]) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const sourceId =
|
||||
e.dataTransfer.getData('application/mindsift-task-id') || e.dataTransfer.getData('text/plain') || draggingTaskId;
|
||||
const slot = kanbanSlot;
|
||||
resetKanbanDrag();
|
||||
if (!sourceId) return;
|
||||
const others = colTasks.filter((t) => t.id !== sourceId);
|
||||
if (others.length === 0 || !slot || slot.col !== colId) {
|
||||
await moveTaskToColumn(sourceId, colId);
|
||||
} else if (slot.index < others.length) {
|
||||
await moveTaskNextTo(sourceId, others[slot.index], 'before', colId);
|
||||
} else {
|
||||
await moveTaskNextTo(sourceId, others[others.length - 1], 'after', colId);
|
||||
}
|
||||
setJustDroppedId(sourceId);
|
||||
window.setTimeout(() => setJustDroppedId((cur) => (cur === sourceId ? null : cur)), 900);
|
||||
};
|
||||
|
||||
// A drag that ends outside any column (Escape, dropped elsewhere) leaves nothing behind
|
||||
useEffect(() => {
|
||||
if (!draggingTaskId) return;
|
||||
const end = () => {
|
||||
setKanbanSlot(null);
|
||||
setKanbanHiddenId(null);
|
||||
};
|
||||
window.addEventListener('dragend', end);
|
||||
window.addEventListener('drop', end);
|
||||
return () => {
|
||||
window.removeEventListener('dragend', end);
|
||||
window.removeEventListener('drop', end);
|
||||
};
|
||||
}, [draggingTaskId]);
|
||||
|
||||
const moveTaskToColumn = async (sourceId: string | null, colId: string) => {
|
||||
if (!sourceId) return;
|
||||
triggerHaptic(15);
|
||||
@@ -2347,17 +2425,16 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
return col.id === 'todo';
|
||||
});
|
||||
|
||||
// Cards that take part in the slot count (the dragged one has left its place)
|
||||
const visibleIds = colTasks.filter((t) => t.id !== kanbanHiddenId).map((t) => t.id);
|
||||
const slotHere = kanbanSlot && kanbanSlot.col === col.id ? kanbanSlot.index : -1;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={col.id}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
if (draggingTaskId) setDragOverKanbanCol(col.id);
|
||||
}}
|
||||
onDragLeave={() => {
|
||||
if (dragOverKanbanCol === col.id) setDragOverKanbanCol(null);
|
||||
}}
|
||||
onDrop={(e) => handleDropOnKanbanCol(e, col.id)}
|
||||
onDragOver={(e) => handleKanbanColDragOver(e, col.id)}
|
||||
onDragLeave={(e) => handleKanbanColDragLeave(e, col.id)}
|
||||
onDrop={(e) => handleKanbanDrop(e, col.id, colTasks)}
|
||||
data-kanban-col={col.id}
|
||||
className={`bg-canvas border border-border flex flex-col min-h-[420px] overflow-hidden transition-all ${
|
||||
dragOverKanbanCol === col.id ? 'ring-2 ring-accent/60 bg-accent/5' : ''
|
||||
@@ -2376,23 +2453,23 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
<div className="p-2 space-y-2 flex-1 overflow-y-auto max-h-[70vh]">
|
||||
{colTasks.map((task) => {
|
||||
const isDragging = draggingTaskId === task.id;
|
||||
const isTarget = dragOverTaskTarget?.taskId === task.id;
|
||||
const readOnly = isTaskReadOnly(task);
|
||||
const visibleIdx = visibleIds.indexOf(task.id);
|
||||
|
||||
return (
|
||||
<React.Fragment key={task.id}>
|
||||
<AnimatePresence initial={false}>
|
||||
{slotHere >= 0 && slotHere === visibleIdx && renderDropSlot(col.id, visibleIdx)}
|
||||
</AnimatePresence>
|
||||
<div
|
||||
key={task.id}
|
||||
draggable={canDragTasks && !readOnly}
|
||||
onDragStart={(e) => handleTaskDragStart(e, task)}
|
||||
onDragEnd={() => {
|
||||
setDraggingTaskId(null);
|
||||
setDragOverTaskTarget(null);
|
||||
setDragOverKanbanCol(null);
|
||||
setDragOverListTab(null);
|
||||
onDragStart={(e) => {
|
||||
handleTaskDragStart(e, task);
|
||||
dragHeightRef.current = e.currentTarget.getBoundingClientRect().height;
|
||||
// Hidden a tick later: hiding the source right away would cancel the drag
|
||||
window.setTimeout(() => setKanbanHiddenId(task.id), 0);
|
||||
}}
|
||||
onDragOver={(e) => handleTaskDragOver(e, task.id)}
|
||||
onDragLeave={(e) => handleTaskDragLeave(e, task.id)}
|
||||
onDrop={(e) => handleDropOnTask(e, task, col.id)}
|
||||
onDragEnd={resetKanbanDrag}
|
||||
data-kanban-task={task.id}
|
||||
{...kanbanTouch.cardProps(task.id, task.content, !readOnly)}
|
||||
onClick={() => {
|
||||
@@ -2400,19 +2477,12 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
setDetailTaskId(task.id);
|
||||
setShowDetailModal(true);
|
||||
}}
|
||||
className={`panel-surface p-2.5 space-y-2 hover:border-strong transition-all shadow-sm group relative select-none ${
|
||||
className={`panel-surface p-2.5 space-y-2 hover:border-strong transition-colors shadow-sm group relative select-none ${
|
||||
readOnly ? 'cursor-pointer' : 'cursor-grab active:cursor-grabbing'
|
||||
} ${
|
||||
isDragging ? 'opacity-30 border border-dashed border-accent/40' : ''
|
||||
}`}
|
||||
kanbanHiddenId === task.id ? 'hidden' : isDragging ? 'opacity-30 border border-dashed border-accent/40' : ''
|
||||
} ${justDroppedId === task.id ? 'ring-2 ring-accent/60 animate-in fade-in zoom-in-95 duration-300' : ''}`}
|
||||
>
|
||||
{/* Drop insertion line indicator */}
|
||||
{isTarget && dragOverTaskTarget.position === 'before' && (
|
||||
<div className="absolute top-0 left-0 right-0 h-0.5 bg-accent z-20 pointer-events-none rounded-full shadow-[0_0_8px_rgba(var(--accent-rgb),0.6)]" />
|
||||
)}
|
||||
{isTarget && dragOverTaskTarget.position === 'after' && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-accent z-20 pointer-events-none rounded-full shadow-[0_0_8px_rgba(var(--accent-rgb),0.6)]" />
|
||||
)}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p
|
||||
className={`text-xs font-medium break-words flex-1 leading-snug ${
|
||||
@@ -2556,10 +2626,14 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
<AnimatePresence initial={false}>
|
||||
{slotHere >= 0 && slotHere === visibleIds.length && renderDropSlot(col.id, visibleIds.length)}
|
||||
</AnimatePresence>
|
||||
|
||||
{colTasks.length === 0 && (
|
||||
{colTasks.length === 0 && slotHere < 0 && (
|
||||
<div className="p-4 text-center text-faint text-xs italic">
|
||||
Keine Aufgaben in dieser Spalte
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user