feat(workspace): add drag and drop support for folders
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 52s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 52s
This commit is contained in:
1 parent
9b21fd92e1
commit
d338c4cb8a
1 file changed
+139
-7
@@ -113,8 +113,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
const [newFolderParent, setNewFolderParent] = useState<string | null>(null);
|
const [newFolderParent, setNewFolderParent] = useState<string | null>(null);
|
||||||
const [newFolderName, setNewFolderName] = useState('');
|
const [newFolderName, setNewFolderName] = useState('');
|
||||||
|
|
||||||
// Sidebar Document Drag and Drop state
|
// Sidebar Document & Folder Drag and Drop state
|
||||||
const [draggingDocId, setDraggingDocId] = useState<string | null>(null);
|
const [draggingDocId, setDraggingDocId] = useState<string | null>(null);
|
||||||
|
const [draggingFolderPath, setDraggingFolderPath] = useState<string | null>(null);
|
||||||
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
|
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
|
||||||
|
|
||||||
// Active focused block ref
|
// Active focused block ref
|
||||||
@@ -583,6 +584,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleGlobalDragEnd = () => {
|
const handleGlobalDragEnd = () => {
|
||||||
setDraggingDocId(null);
|
setDraggingDocId(null);
|
||||||
|
setDraggingFolderPath(null);
|
||||||
setDragOverFolder(null);
|
setDragOverFolder(null);
|
||||||
setDraggingBlockId(null);
|
setDraggingBlockId(null);
|
||||||
};
|
};
|
||||||
@@ -594,6 +596,100 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleMoveFolder = async (sourcePath: string, targetParentPath: string) => {
|
||||||
|
const cleanSource = sourcePath.trim();
|
||||||
|
const cleanTarget = targetParentPath.trim();
|
||||||
|
|
||||||
|
// Cannot move to self or into own subfolder
|
||||||
|
if (!cleanSource || cleanSource === cleanTarget || cleanTarget.startsWith(cleanSource + '/')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name of the folder being moved
|
||||||
|
const folderName = cleanSource.split('/').pop() || cleanSource;
|
||||||
|
const newFolderPath = cleanTarget ? `${cleanTarget}/${folderName}` : folderName;
|
||||||
|
|
||||||
|
if (newFolderPath === cleanSource) return;
|
||||||
|
|
||||||
|
triggerHaptic(15);
|
||||||
|
|
||||||
|
// 1. Update all documents that are in cleanSource or any subfolder of cleanSource
|
||||||
|
const docsToUpdate = docs.filter((d) => {
|
||||||
|
const f = getDocFolder(d);
|
||||||
|
return f === cleanSource || f.startsWith(cleanSource + '/');
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedDocsList = docs.map((d) => {
|
||||||
|
const f = getDocFolder(d);
|
||||||
|
if (f === cleanSource) {
|
||||||
|
const nextFolder = newFolderPath;
|
||||||
|
const updatedTags = (d.tags || []).filter((t) => !t.startsWith('folder:'));
|
||||||
|
if (nextFolder) updatedTags.push(`folder:${nextFolder}`);
|
||||||
|
return { ...d, folderPath: nextFolder, tags: updatedTags };
|
||||||
|
} else if (f.startsWith(cleanSource + '/')) {
|
||||||
|
const suffix = f.slice(cleanSource.length);
|
||||||
|
const nextFolder = `${newFolderPath}${suffix}`;
|
||||||
|
const updatedTags = (d.tags || []).filter((t) => !t.startsWith('folder:'));
|
||||||
|
if (nextFolder) updatedTags.push(`folder:${nextFolder}`);
|
||||||
|
return { ...d, folderPath: nextFolder, tags: updatedTags };
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
});
|
||||||
|
|
||||||
|
setDocs(updatedDocsList);
|
||||||
|
if (selectedDoc) {
|
||||||
|
const found = updatedDocsList.find((d) => d.id === selectedDoc.id);
|
||||||
|
if (found) setSelectedDoc(found);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist all affected docs to backend
|
||||||
|
for (const d of docsToUpdate) {
|
||||||
|
const f = getDocFolder(d);
|
||||||
|
let nextFolder = newFolderPath;
|
||||||
|
if (f.startsWith(cleanSource + '/')) {
|
||||||
|
const suffix = f.slice(cleanSource.length);
|
||||||
|
nextFolder = `${newFolderPath}${suffix}`;
|
||||||
|
}
|
||||||
|
const updatedTags = (d.tags || []).filter((t) => !t.startsWith('folder:'));
|
||||||
|
if (nextFolder) updatedTags.push(`folder:${nextFolder}`);
|
||||||
|
|
||||||
|
api
|
||||||
|
.updateDocument(d.id, {
|
||||||
|
folderPath: nextFolder,
|
||||||
|
tags: updatedTags,
|
||||||
|
})
|
||||||
|
.catch((err) => console.error('Failed to update doc folder', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Update customFolders in state & localStorage
|
||||||
|
const updatedCustomFolders = customFolders.map((p) => {
|
||||||
|
if (p === cleanSource) return newFolderPath;
|
||||||
|
if (p.startsWith(cleanSource + '/')) {
|
||||||
|
return `${newFolderPath}${p.slice(cleanSource.length)}`;
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updatedCustomFolders.includes(newFolderPath)) {
|
||||||
|
updatedCustomFolders.push(newFolderPath);
|
||||||
|
}
|
||||||
|
const sortedCustom = Array.from(new Set(updatedCustomFolders)).sort((a, b) =>
|
||||||
|
a.localeCompare(b)
|
||||||
|
);
|
||||||
|
setCustomFolders(sortedCustom);
|
||||||
|
try {
|
||||||
|
localStorage.setItem('mindsift_workspace_folders', JSON.stringify(sortedCustom));
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Expand the new folder path and its parent
|
||||||
|
setExpandedFolders((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(newFolderPath);
|
||||||
|
if (cleanTarget) next.add(cleanTarget);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleDropDocToFolder = async (folderPath: string, e: React.DragEvent) => {
|
const handleDropDocToFolder = async (folderPath: string, e: React.DragEvent) => {
|
||||||
const docId = e.dataTransfer.getData('application/mindsift-doc-id') || draggingDocId;
|
const docId = e.dataTransfer.getData('application/mindsift-doc-id') || draggingDocId;
|
||||||
setDraggingDocId(null);
|
setDraggingDocId(null);
|
||||||
@@ -1239,6 +1335,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
const isExpanded = expandedFolders.has(node.path);
|
const isExpanded = expandedFolders.has(node.path);
|
||||||
const isCreatingSubfolder = isCreatingFolder && newFolderParent === node.path;
|
const isCreatingSubfolder = isCreatingFolder && newFolderParent === node.path;
|
||||||
const isDragOver = dragOverFolder === node.path;
|
const isDragOver = dragOverFolder === node.path;
|
||||||
|
const isDraggingThisFolder = draggingFolderPath === node.path;
|
||||||
const totalItems = node.docs.length + node.subfolders.length;
|
const totalItems = node.docs.length + node.subfolders.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1246,7 +1343,12 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
key={node.path}
|
key={node.path}
|
||||||
className="select-none"
|
className="select-none"
|
||||||
onDragOver={(e) => {
|
onDragOver={(e) => {
|
||||||
if (draggingDocId) {
|
if (
|
||||||
|
draggingDocId ||
|
||||||
|
(draggingFolderPath &&
|
||||||
|
draggingFolderPath !== node.path &&
|
||||||
|
!node.path.startsWith(draggingFolderPath + '/'))
|
||||||
|
) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setDragOverFolder(node.path);
|
setDragOverFolder(node.path);
|
||||||
@@ -1261,19 +1363,38 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
onDrop={(e) => {
|
onDrop={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
const droppedFolder =
|
||||||
|
e.dataTransfer.getData('application/mindsift-folder-path') || draggingFolderPath;
|
||||||
setDragOverFolder(null);
|
setDragOverFolder(null);
|
||||||
setDraggingDocId(null);
|
setDraggingDocId(null);
|
||||||
handleDropDocToFolder(node.path, e);
|
setDraggingFolderPath(null);
|
||||||
|
|
||||||
|
if (droppedFolder) {
|
||||||
|
handleMoveFolder(droppedFolder, node.path);
|
||||||
|
} else {
|
||||||
|
handleDropDocToFolder(node.path, e);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.dataTransfer.setData('application/mindsift-folder-path', node.path);
|
||||||
|
e.dataTransfer.setData('text/plain', node.path);
|
||||||
|
setDraggingFolderPath(node.path);
|
||||||
|
}}
|
||||||
|
onDragEnd={() => setDraggingFolderPath(null)}
|
||||||
onClick={() => toggleFolder(node.path)}
|
onClick={() => toggleFolder(node.path)}
|
||||||
style={{ paddingLeft: `${node.depth * 14 + 8}px` }}
|
style={{ paddingLeft: `${node.depth * 14 + 8}px` }}
|
||||||
className={`group flex items-center justify-between py-1.5 pr-2 cursor-pointer transition-colors text-xs ${
|
className={`group flex items-center justify-between py-1.5 pr-2 cursor-pointer transition-colors text-xs ${
|
||||||
|
isDraggingThisFolder ? 'opacity-40 scale-[0.98]' : ''
|
||||||
|
} ${
|
||||||
isDragOver
|
isDragOver
|
||||||
? 'bg-accent/20 border-l-2 border-accent text-main font-medium'
|
? 'bg-accent/20 border-l-2 border-accent text-main font-medium'
|
||||||
: 'hover:bg-panel-elevated text-muted'
|
: 'hover:bg-panel-elevated text-muted'
|
||||||
}`}
|
}`}
|
||||||
|
title={`${node.path} (Klick: Öffnen/Schließen · Ziehen: In anderen Ordner verschieben)`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5 min-w-0">
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
<span className="text-faint hover:text-muted">
|
<span className="text-faint hover:text-muted">
|
||||||
@@ -1535,8 +1656,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="py-1 max-h-[calc(100vh-240px)] overflow-y-auto">
|
<div className="py-1 max-h-[calc(100vh-240px)] overflow-y-auto">
|
||||||
{/* Drag target for Root when dragging a note */}
|
{/* Drag target for Root when dragging a note or a folder */}
|
||||||
{draggingDocId && (
|
{(draggingDocId || draggingFolderPath) && (
|
||||||
<div
|
<div
|
||||||
onDragOver={(e) => {
|
onDragOver={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -1550,9 +1671,18 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
onDrop={(e) => {
|
onDrop={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
const droppedFolder =
|
||||||
|
e.dataTransfer.getData('application/mindsift-folder-path') ||
|
||||||
|
draggingFolderPath;
|
||||||
setDragOverFolder(null);
|
setDragOverFolder(null);
|
||||||
setDraggingDocId(null);
|
setDraggingDocId(null);
|
||||||
handleDropDocToFolder('', e);
|
setDraggingFolderPath(null);
|
||||||
|
|
||||||
|
if (droppedFolder) {
|
||||||
|
handleMoveFolder(droppedFolder, '');
|
||||||
|
} else {
|
||||||
|
handleDropDocToFolder('', e);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className={`mx-2 my-1.5 p-2 border-2 border-dashed rounded-btn text-center text-xs transition-colors ${
|
className={`mx-2 my-1.5 p-2 border-2 border-dashed rounded-btn text-center text-xs transition-colors ${
|
||||||
dragOverFolder === '__root__'
|
dragOverFolder === '__root__'
|
||||||
@@ -1560,7 +1690,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
: 'border-border text-muted bg-canvas'
|
: 'border-border text-muted bg-canvas'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
📁 In Hauptverzeichnis verschieben (ohne Ordner)
|
{draggingFolderPath
|
||||||
|
? '📁 Ordner in Hauptverzeichnis verschieben'
|
||||||
|
: '📁 In Hauptverzeichnis verschieben (ohne Ordner)'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user