refactor: improve drag and drop UX across tasks, lists, and workspace bullets
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
This commit is contained in:
1 parent
35d4657a25
commit
09725611b5
3 files changed
+142
-138
No files matched your search
@@ -171,7 +171,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
|
||||
// Drag and Drop reordering state for blocks
|
||||
const [draggingBlockId, setDraggingBlockId] = useState<string | null>(null);
|
||||
const [dragOverBlockTarget, setDragOverBlockTarget] = useState<{ blockId: string; position: 'before' | 'after' } | null>(null);
|
||||
const [dragOverBlockTarget, setDragOverBlockTarget] = useState<{ blockId: string; position: 'before' | 'after' | 'inside' } | null>(null);
|
||||
|
||||
// --- GLOBAL TO-DO ROLLUP STATE ---
|
||||
const [todoFilter, setTodoFilter] = useState<'all' | 'open' | 'done'>('open');
|
||||
@@ -1952,7 +1952,17 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
if (!draggingBlockId || draggingBlockId === targetBlockId) return;
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const position = e.clientY - rect.top < rect.height / 2 ? 'before' : 'after';
|
||||
const relY = e.clientY - rect.top;
|
||||
const height = rect.height;
|
||||
|
||||
let position: 'before' | 'after' | 'inside';
|
||||
if (relY < height * 0.25) {
|
||||
position = 'before';
|
||||
} else if (relY > height * 0.75) {
|
||||
position = 'after';
|
||||
} else {
|
||||
position = 'inside';
|
||||
}
|
||||
|
||||
if (
|
||||
!dragOverBlockTarget ||
|
||||
@@ -1970,7 +1980,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlockDrop = (e: React.DragEvent, targetBlockId: string) => {
|
||||
const handleBlockDrop = (e: React.DragEvent, targetBlockId: string, explicitPosition?: 'before' | 'after' | 'inside') => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -1979,7 +1989,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
e.dataTransfer.getData('text/plain') ||
|
||||
draggingBlockId;
|
||||
|
||||
const position = dragOverBlockTarget?.position || 'after';
|
||||
const position = explicitPosition || dragOverBlockTarget?.position || 'after';
|
||||
setDraggingBlockId(null);
|
||||
setDragOverBlockTarget(null);
|
||||
|
||||
@@ -2005,23 +2015,44 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
if (newTargetIdx === -1) return;
|
||||
|
||||
const targetBlock = blocks[newTargetIdx];
|
||||
const targetLevel = targetBlock.level || 0;
|
||||
const oldRootLevel = subtree[0].level || 0;
|
||||
const levelDiff = targetLevel - oldRootLevel;
|
||||
|
||||
// Adjust levels of all blocks in subtree to match target hierarchy
|
||||
for (const b of subtree) {
|
||||
b.level = Math.max(0, (b.level || 0) + levelDiff);
|
||||
if (position === 'inside') {
|
||||
// Nest directly under targetBlock as its sub-bullet
|
||||
const targetLevel = (targetBlock.level || 0) + 1;
|
||||
const oldRootLevel = subtree[0].level || 0;
|
||||
const levelDiff = targetLevel - oldRootLevel;
|
||||
|
||||
for (const b of subtree) {
|
||||
b.level = Math.max(0, (b.level || 0) + levelDiff);
|
||||
}
|
||||
|
||||
// Automatically uncollapse targetBlock so the newly added sub-bullet is visible
|
||||
if (targetBlock.collapsed) {
|
||||
blocks[newTargetIdx] = { ...targetBlock, collapsed: false };
|
||||
}
|
||||
|
||||
// Insert immediately below targetBlock
|
||||
blocks.splice(newTargetIdx + 1, 0, ...subtree);
|
||||
} else {
|
||||
const targetLevel = targetBlock.level || 0;
|
||||
const oldRootLevel = subtree[0].level || 0;
|
||||
const levelDiff = targetLevel - oldRootLevel;
|
||||
|
||||
// Adjust levels of all blocks in subtree to match target hierarchy
|
||||
for (const b of subtree) {
|
||||
b.level = Math.max(0, (b.level || 0) + levelDiff);
|
||||
}
|
||||
|
||||
// Calculate insertion index
|
||||
let insertIdx = newTargetIdx;
|
||||
if (position === 'after') {
|
||||
const targetSubtreeCount = getBlockSubtreeCount(blocks, newTargetIdx);
|
||||
insertIdx = newTargetIdx + targetSubtreeCount;
|
||||
}
|
||||
|
||||
blocks.splice(insertIdx, 0, ...subtree);
|
||||
}
|
||||
|
||||
// Calculate insertion index
|
||||
let insertIdx = newTargetIdx;
|
||||
if (position === 'after') {
|
||||
const targetSubtreeCount = getBlockSubtreeCount(blocks, newTargetIdx);
|
||||
insertIdx = newTargetIdx + targetSubtreeCount;
|
||||
}
|
||||
|
||||
blocks.splice(insertIdx, 0, ...subtree);
|
||||
persistDocument({ ...selectedDoc, blocks }, true);
|
||||
};
|
||||
|
||||
@@ -3505,9 +3536,13 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
onDragOver={(e) => !isReadOnly && handleBlockDragOver(e, block.id)}
|
||||
onDragLeave={(e) => !isReadOnly && handleBlockDragLeave(e, block.id)}
|
||||
onDrop={(e) => !isReadOnly && handleBlockDrop(e, block.id)}
|
||||
style={{ paddingLeft: `${blockLevel * 22}px` }}
|
||||
className={`group flex items-start gap-1 py-0.5 relative transition-all ${
|
||||
isDraggingThis ? 'opacity-30 border border-dashed border-accent/40 rounded' : ''
|
||||
style={{ paddingLeft: `${blockLevel * 20}px` }}
|
||||
className={`group flex items-start gap-0.5 py-0.5 relative transition-all rounded ${
|
||||
isDraggingThis ? 'opacity-30 border border-dashed border-accent/40' : ''
|
||||
} ${
|
||||
isTargetThis && dragOverBlockTarget.position === 'inside'
|
||||
? 'ring-2 ring-accent/60 bg-accent/10 shadow-sm'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{/* Drop indicator lines */}
|
||||
@@ -3517,8 +3552,11 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
{isTargetThis && dragOverBlockTarget.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)]" />
|
||||
)}
|
||||
{isTargetThis && dragOverBlockTarget.position === 'inside' && (
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-accent rounded-l z-20 pointer-events-none" />
|
||||
)}
|
||||
|
||||
{/* Drag Handle on hover */}
|
||||
{/* Drag Handle on hover - tightened spacing to the bullet */}
|
||||
<div
|
||||
draggable={!isReadOnly}
|
||||
onDragStart={(e) => !isReadOnly && handleBlockDragStart(e, block.id)}
|
||||
@@ -3526,7 +3564,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
setDraggingBlockId(null);
|
||||
setDragOverBlockTarget(null);
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 flex items-center pt-1 text-faint hover:text-accent cursor-grab active:cursor-grabbing flex-shrink-0"
|
||||
className="opacity-0 group-hover:opacity-100 flex items-center pt-1 text-faint hover:text-accent cursor-grab active:cursor-grabbing flex-shrink-0 -ml-1 mr-0.5"
|
||||
title="Ziehen zum Verschieben"
|
||||
>
|
||||
<GripVertical className="w-3 h-3" />
|
||||
@@ -3537,20 +3575,20 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleCollapse(block.id)}
|
||||
className="w-4 h-4 mt-0.5 flex items-center justify-center text-muted hover:text-main flex-shrink-0"
|
||||
className="w-3.5 h-3.5 mt-0.5 flex items-center justify-center text-muted hover:text-main flex-shrink-0"
|
||||
title={block.collapsed ? 'Unterpunkte aufklappen' : 'Unterpunkte einklappen'}
|
||||
>
|
||||
{block.collapsed ? (
|
||||
<ChevronRight className="w-3 h-3 text-accent" />
|
||||
<ChevronRight className="w-2.5 h-2.5 text-accent" />
|
||||
) : (
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
<ChevronDown className="w-2.5 h-2.5" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-4 flex-shrink-0" />
|
||||
<div className="w-1.5 flex-shrink-0" />
|
||||
)}
|
||||
|
||||
{/* Interactive Bullet Dot (Click: Zoom-in! Drag: Reorder!) */}
|
||||
{/* Interactive Bullet Dot (Click: Zoom-in! Drag: Reorder! Drop on it: Nest as child!) */}
|
||||
<div
|
||||
draggable={!isReadOnly}
|
||||
onDragStart={(e) => !isReadOnly && handleBlockDragStart(e, block.id)}
|
||||
@@ -3558,13 +3596,31 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
setDraggingBlockId(null);
|
||||
setDragOverBlockTarget(null);
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (!isReadOnly && draggingBlockId && draggingBlockId !== block.id) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverBlockTarget({ blockId: block.id, position: 'inside' });
|
||||
}
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
if (!isReadOnly) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleBlockDrop(e, block.id, 'inside');
|
||||
}
|
||||
}}
|
||||
onClick={() => handleZoomTo(block.id)}
|
||||
className={`w-4 h-4 mt-1 rounded-pill flex items-center justify-center transition-colors flex-shrink-0 ${
|
||||
isReadOnly
|
||||
? 'cursor-pointer text-muted'
|
||||
: 'hover:bg-accent/20 cursor-grab active:cursor-grabbing text-muted hover:text-accent'
|
||||
} ${
|
||||
isTargetThis && dragOverBlockTarget.position === 'inside'
|
||||
? 'scale-125 bg-accent/30 ring-2 ring-accent'
|
||||
: ''
|
||||
}`}
|
||||
title="Klick: In diesen Ast zoomen · Ziehen: Verschieben"
|
||||
title="Klick: Zoomen · Ziehen: Verschieben · Hierher ziehen: Als Unterpunkt einordnen"
|
||||
>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-pill transition-colors ${
|
||||
|
||||
Reference in new issue
Block a user