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

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:
sandrodachandClaude Opus 5.5 committed 2026-11-01 13:40:47 +01:00
1 parent 1e3ac9786f
commit 284ef1f723
5 files changed
+129 -46

No files matched your search

+115 -41
View File
@@ -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>