feat: add drag-and-drop for tasks, workspace bullets, and list items
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s
This commit is contained in:
1 parent
c0661cddae
commit
35d4657a25
9 files changed
+814
-51
No files matched your search
@@ -171,6 +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);
|
||||
|
||||
// --- GLOBAL TO-DO ROLLUP STATE ---
|
||||
const [todoFilter, setTodoFilter] = useState<'all' | 'open' | 'done'>('open');
|
||||
@@ -1911,35 +1912,117 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
return childTodos;
|
||||
};
|
||||
|
||||
// Drag & Drop Handlers on the Bullet Dot
|
||||
const handleDragStart = (e: React.DragEvent, blockId: string) => {
|
||||
// Helper: Get subtree count (root + all indented children)
|
||||
const getBlockSubtreeCount = (blocks: DocumentBlock[], startIndex: number): number => {
|
||||
if (startIndex < 0 || startIndex >= blocks.length) return 0;
|
||||
const rootLevel = blocks[startIndex].level || 0;
|
||||
let count = 1;
|
||||
for (let i = startIndex + 1; i < blocks.length; i++) {
|
||||
if ((blocks[i].level || 0) > rootLevel) {
|
||||
count++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
};
|
||||
|
||||
// Helper: Check if target block is a descendant of source block
|
||||
const isDescendantBlock = (blocks: DocumentBlock[], sourceIdx: number, targetBlockId: string): boolean => {
|
||||
if (sourceIdx < 0 || sourceIdx >= blocks.length) return false;
|
||||
const subtreeCount = getBlockSubtreeCount(blocks, sourceIdx);
|
||||
for (let i = sourceIdx; i < sourceIdx + subtreeCount; i++) {
|
||||
if (blocks[i]?.id === targetBlockId) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Drag & Drop Handlers on the Bullet row / Grip / Dot
|
||||
const handleBlockDragStart = (e: React.DragEvent, blockId: string) => {
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.setData('application/mindsift-block-id', blockId);
|
||||
e.dataTransfer.setData('text/plain', blockId);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
setDraggingBlockId(blockId);
|
||||
};
|
||||
|
||||
const handleDropOnBlock = (e: React.DragEvent, targetBlockId: string) => {
|
||||
const handleBlockDragOver = (e: React.DragEvent, targetBlockId: string) => {
|
||||
e.preventDefault();
|
||||
const sourceId = e.dataTransfer.getData('text/plain') || draggingBlockId;
|
||||
e.stopPropagation();
|
||||
if (!draggingBlockId || draggingBlockId === targetBlockId) return;
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const position = e.clientY - rect.top < rect.height / 2 ? 'before' : 'after';
|
||||
|
||||
if (
|
||||
!dragOverBlockTarget ||
|
||||
dragOverBlockTarget.blockId !== targetBlockId ||
|
||||
dragOverBlockTarget.position !== position
|
||||
) {
|
||||
setDragOverBlockTarget({ blockId: targetBlockId, position });
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlockDragLeave = (e: React.DragEvent, targetBlockId: string) => {
|
||||
e.stopPropagation();
|
||||
if (dragOverBlockTarget?.blockId === targetBlockId) {
|
||||
setDragOverBlockTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlockDrop = (e: React.DragEvent, targetBlockId: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const sourceId =
|
||||
e.dataTransfer.getData('application/mindsift-block-id') ||
|
||||
e.dataTransfer.getData('text/plain') ||
|
||||
draggingBlockId;
|
||||
|
||||
const position = dragOverBlockTarget?.position || 'after';
|
||||
setDraggingBlockId(null);
|
||||
setDragOverBlockTarget(null);
|
||||
|
||||
if (!selectedDoc || !sourceId || sourceId === targetBlockId) {
|
||||
setDraggingBlockId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const blocks = [...selectedDoc.blocks];
|
||||
const sourceIdx = blocks.findIndex((b) => b.id === sourceId);
|
||||
const targetIdx = blocks.findIndex((b) => b.id === targetBlockId);
|
||||
if (sourceIdx === -1 || targetIdx === -1) {
|
||||
setDraggingBlockId(null);
|
||||
if (sourceIdx === -1) return;
|
||||
|
||||
// Prevent circular drag into one's own children
|
||||
if (isDescendantBlock(blocks, sourceIdx, targetBlockId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [moved] = blocks.splice(sourceIdx, 1);
|
||||
// Align moved block's level to target
|
||||
moved.level = blocks[targetIdx]?.level || 0;
|
||||
blocks.splice(targetIdx, 0, moved);
|
||||
// Extract the entire subtree (parent + all indented children)
|
||||
const subtreeCount = getBlockSubtreeCount(blocks, sourceIdx);
|
||||
const subtree = blocks.splice(sourceIdx, subtreeCount);
|
||||
|
||||
// Find new index of target block after splice
|
||||
const newTargetIdx = blocks.findIndex((b) => b.id === targetBlockId);
|
||||
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);
|
||||
}
|
||||
|
||||
// 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);
|
||||
setDraggingBlockId(null);
|
||||
};
|
||||
|
||||
// Export full document as portable Logseq-indented Markdown
|
||||
@@ -3413,16 +3496,37 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
{visibleBlocks.map(({ block, originalIndex }) => {
|
||||
const blockLevel = Math.max(0, (block.level || 0) - (zoomedBlock ? (zoomedBlock.level || 0) + 1 : 0));
|
||||
const isChildrenPresent = hasChildren(selectedDoc.blocks, originalIndex);
|
||||
const isDraggingThis = draggingBlockId === block.id;
|
||||
const isTargetThis = dragOverBlockTarget?.blockId === block.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={block.id}
|
||||
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"
|
||||
className={`group flex items-start gap-1 py-0.5 relative transition-all ${
|
||||
isDraggingThis ? 'opacity-30 border border-dashed border-accent/40 rounded' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Drop indicator lines */}
|
||||
{isTargetThis && dragOverBlockTarget.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)]" />
|
||||
)}
|
||||
{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)]" />
|
||||
)}
|
||||
|
||||
{/* Drag Handle on hover */}
|
||||
<div
|
||||
className="opacity-0 group-hover:opacity-100 flex items-center pt-1 text-faint hover:text-muted cursor-grab flex-shrink-0"
|
||||
draggable={!isReadOnly}
|
||||
onDragStart={(e) => !isReadOnly && handleBlockDragStart(e, block.id)}
|
||||
onDragEnd={() => {
|
||||
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"
|
||||
title="Ziehen zum Verschieben"
|
||||
>
|
||||
<GripVertical className="w-3 h-3" />
|
||||
@@ -3449,14 +3553,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
{/* Interactive Bullet Dot (Click: Zoom-in! Drag: Reorder!) */}
|
||||
<div
|
||||
draggable={!isReadOnly}
|
||||
onDragStart={(e) => !isReadOnly && handleDragStart(e, block.id)}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => !isReadOnly && handleDropOnBlock(e, block.id)}
|
||||
onDragStart={(e) => !isReadOnly && handleBlockDragStart(e, block.id)}
|
||||
onDragEnd={() => {
|
||||
setDraggingBlockId(null);
|
||||
setDragOverBlockTarget(null);
|
||||
}}
|
||||
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-pointer text-muted hover:text-accent'
|
||||
: 'hover:bg-accent/20 cursor-grab active:cursor-grabbing text-muted hover:text-accent'
|
||||
}`}
|
||||
title="Klick: In diesen Ast zoomen · Ziehen: Verschieben"
|
||||
>
|
||||
|
||||
Reference in new issue
Block a user