feat(workspace): mark several notes (Ctrl/Cmd, Shift) and drag them into a folder or onto the trash
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m43s

Ctrl/Cmd+click toggles, Shift+click marks a range in the order shown; dragging a marked note takes
all marked ones along (no merge then). The trash at the bottom of the sidebar becomes a drop target,
also for a single note and on phones (native long-press drag); a toast offers undo. Escape, a plain
click or a click on empty space clears the marks. v0.37.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-11-01 12:38:03 +01:00
1 parent 0d34308601
commit 3fbe015c06
6 files changed
+205 -26

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mindsift-frontend",
"version": "0.36.0",
"version": "0.37.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mindsift-frontend",
"version": "0.36.0",
"version": "0.37.0",
"license": "Unlicense",
"dependencies": {
"@fontsource-variable/inter": "5.3.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "mindsift-frontend",
"private": true,
"version": "0.36.0",
"version": "0.37.0",
"license": "Unlicense",
"type": "module",
"scripts": {
+191 -21
View File
@@ -99,6 +99,9 @@ interface FolderTreeItemProps {
// Dropping a note onto another one offers to merge them
docDropProps: (targetId: string) => React.HTMLAttributes<HTMLDivElement>;
mergeHoverId: string | null;
// Several notes marked with Ctrl/Cmd or Shift; a plain click opens the note
markedDocIds: Set<string>;
onDocRowClick: (e: React.MouseEvent, id: string, open: () => void) => void;
}
const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
@@ -120,6 +123,8 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
onSelectDoc,
docDropProps,
mergeHoverId,
markedDocIds,
onDocRowClick,
}) => {
const hasContent = node.subfolders.length > 0 || node.docs.length > 0;
const isExpanded = Boolean(hasContent && expandedFolders[node.fullPath]);
@@ -250,6 +255,8 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
onSelectDoc={onSelectDoc}
docDropProps={docDropProps}
mergeHoverId={mergeHoverId}
markedDocIds={markedDocIds}
onDocRowClick={onDocRowClick}
/>
))}
@@ -258,13 +265,17 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
<div
key={d.id}
draggable
data-doc-row={d.id}
onDragStart={(e) => handleDocDragStart(e, d.id)}
{...docDropProps(d.id)}
onClick={() => onSelectDoc(d.id)}
onMouseDown={(e) => e.shiftKey && e.preventDefault()}
onClick={(e) => onDocRowClick(e, d.id, () => onSelectDoc(d.id))}
style={{ paddingLeft: `${(depth + 1) * 12 + 16}px` }}
className={`w-full flex items-center gap-2 pr-2 py-1.5 min-h-11 sm:min-h-0 rounded-btn text-left text-sm sm:text-xs cursor-pointer border transition-colors ${
mergeHoverId === d.id
? 'border-accent bg-accent/15 ring-2 ring-accent/30 text-main'
: markedDocIds.has(d.id)
? 'border-accent/40 bg-accent/10 text-main'
: selectedDocId === d.id && subView === 'editor'
? 'bg-panel-elevated text-main font-semibold border-transparent'
: 'border-transparent text-main hover:bg-panel-elevated'
@@ -438,6 +449,12 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
// Drag and Drop state
const [draggingDocId, setDraggingDocId] = useState<string | null>(null);
// Marked notes (Ctrl/Cmd+click, Shift+click) are moved or trashed together by dragging one of them
const [markedDocIds, setMarkedDocIds] = useState<Set<string>>(() => new Set());
const [draggingDocIds, setDraggingDocIds] = useState<string[]>([]);
const [trashDragOver, setTrashDragOver] = useState(false);
const lastMarkedRef = useRef<string | null>(null);
const treeRef = useRef<HTMLDivElement>(null);
// Merging notes: dialog (picker/confirmation) and a toast with undo afterwards
const [mergeDialog, setMergeDialog] = useState<{ sourceId: string; targetId?: string } | null>(null);
@@ -773,13 +790,116 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}
};
// HTML5 Drag & Drop handlers for moving documents into folders
// Ctrl/Cmd+click toggles a note, Shift+click marks the range in the order shown (from the last marked
// or the open note); a plain click clears the marks and opens the note
const handleDocRowClick = (e: React.MouseEvent, id: string, open: () => void) => {
if (e.metaKey || e.ctrlKey) {
setMarkedDocIds((prev) => {
const next = new Set(prev);
if (next.size === 0 && selectedDocId && selectedDocId !== id) next.add(selectedDocId);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
lastMarkedRef.current = id;
return;
}
if (e.shiftKey) {
const order: string[] = [];
treeRef.current?.querySelectorAll<HTMLElement>('[data-doc-row]').forEach((el) => {
const rowId = el.dataset.docRow;
if (rowId && !order.includes(rowId)) order.push(rowId);
});
const anchor = lastMarkedRef.current || selectedDocId || id;
const a = order.indexOf(anchor);
const b = order.indexOf(id);
const range = a < 0 || b < 0 ? [id] : order.slice(Math.min(a, b), Math.max(a, b) + 1);
setMarkedDocIds((prev) => new Set([...prev, ...range]));
return;
}
setMarkedDocIds(new Set());
lastMarkedRef.current = id;
open();
};
useEffect(() => {
if (markedDocIds.size === 0) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setMarkedDocIds(new Set());
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [markedDocIds.size]);
// HTML5 Drag & Drop handlers for moving documents into folders (a marked note takes the others along)
const handleDocDragStart = (e: React.DragEvent, docId: string) => {
e.stopPropagation();
const ids = markedDocIds.has(docId) && markedDocIds.size > 1 ? Array.from(markedDocIds) : [docId];
e.dataTransfer.setData('application/mindsift-doc-id', docId);
e.dataTransfer.setData('application/mindsift-doc-ids', JSON.stringify(ids));
e.dataTransfer.setData('text/plain', docId);
e.dataTransfer.effectAllowed = 'move';
setDraggingDocId(docId);
setDraggingDocIds(ids);
};
const draggedDocIds = (e: React.DragEvent): string[] => {
try {
const raw = e.dataTransfer.getData('application/mindsift-doc-ids');
const ids = raw ? (JSON.parse(raw) as string[]) : [];
if (ids.length > 0) return ids;
} catch {
// fall back below
}
const single = e.dataTransfer.getData('application/mindsift-doc-id') || draggingDocId;
return draggingDocIds.length > 0 ? draggingDocIds : single ? [single] : [];
};
// Dropping notes on the trash: they can be restored (toast or the trash itself)
const handleDropOnTrash = async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setTrashDragOver(false);
const ids = draggedDocIds(e);
setDraggingDocId(null);
setDraggingDocIds([]);
setMergeHoverId(null);
if (ids.length === 0) return;
triggerHaptic(20);
const trashed: string[] = [];
for (const id of ids) {
try {
await api.deleteDocument(id);
trashed.push(id);
} catch {
// e.g. a note shared with me: only its owner can delete it
}
}
if (trashed.length === 0) {
showToast('Nur eigene Notizen lassen sich löschen');
return;
}
setDocs((prev) => prev.filter((d) => !trashed.includes(d.id)));
if (selectedDocId && trashed.includes(selectedDocId)) {
setSelectedDocId(null);
setMobileShowDetail(false);
}
setMarkedDocIds(new Set());
onStatsChange();
reloadTrash();
showToast(trashed.length === 1 ? 'Notiz im Papierkorb' : `${trashed.length} Notizen im Papierkorb`, [
{
label: 'Rückgängig',
run: async () => {
for (const id of trashed) {
await api.restoreDocument(id).catch(() => {});
}
await loadWorkspaceData();
reloadTrash();
onStatsChange();
},
},
]);
};
const requestMerge = (sourceId: string, targetId?: string) => {
@@ -794,7 +914,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
// Dropping a dragged note onto another note (instead of a folder) offers to merge them
const docDropProps = (targetId: string): React.HTMLAttributes<HTMLDivElement> => ({
onDragOver: (e) => {
if (!draggingDocId || draggingDocId === targetId) return;
if (!draggingDocId || draggingDocId === targetId || draggingDocIds.length > 1) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'move';
@@ -803,7 +923,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
onDragLeave: () => setMergeHoverId((cur) => (cur === targetId ? null : cur)),
onDrop: (e) => {
const sourceId = e.dataTransfer.getData('application/mindsift-doc-id') || draggingDocId;
if (!sourceId || sourceId === targetId) return;
if (!sourceId || sourceId === targetId || draggedDocIds(e).length > 1) return;
e.preventDefault();
e.stopPropagation();
setDraggingDocId(null);
@@ -855,19 +975,24 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
e.stopPropagation();
setDragOverFolder(null);
const docId = e.dataTransfer.getData('application/mindsift-doc-id') || draggingDocId;
const docIds = draggedDocIds(e);
const docId = docIds[0];
const folderPath = e.dataTransfer.getData('application/mindsift-folder-path') || draggingFolderPath;
setDraggingDocId(null);
setDraggingDocIds([]);
setDraggingFolderPath(null);
if (docId) {
triggerHaptic(15);
try {
const updatedDoc = await api.moveDocument(docId, targetFolder);
setDocs((prev) =>
prev.map((d) => (d.id === docId ? { ...d, folderPath: updatedDoc.folderPath } : d))
);
for (const id of docIds) {
const updatedDoc = await api.moveDocument(id, targetFolder).catch(() => null);
if (updatedDoc) {
setDocs((prev) => prev.map((d) => (d.id === id ? { ...d, folderPath: updatedDoc.folderPath } : d)));
}
}
if (docIds.length > 1) setMarkedDocIds(new Set());
if (targetFolder) {
const parts = targetFolder.split('/');
let acc = '';
@@ -1758,7 +1883,27 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
</div>
{/* Scrollable Navigation Tree */}
<div className="flex-1 overflow-y-auto p-2 space-y-3">
<div
ref={treeRef}
className="flex-1 overflow-y-auto p-2 space-y-3"
onClick={(e) => {
if (e.target === e.currentTarget && markedDocIds.size > 0) setMarkedDocIds(new Set());
}}
>
{markedDocIds.size > 0 && (
<div className="sticky top-0 z-10 flex items-center gap-2 px-2 py-1.5 rounded-btn bg-accent/10 border border-accent/30 text-[11px] text-main">
<span className="flex-1">
{markedDocIds.size} markiert · in einen Ordner oder auf den Papierkorb ziehen
</span>
<button
type="button"
onClick={() => setMarkedDocIds(new Set())}
className="text-accent hover:underline shrink-0"
>
Aufheben
</button>
</div>
)}
{/* Journal on top; open, it becomes a panel with the calendar (click the heading to close).
Panel and calendar animate open (grid rows 0fr → 1fr) instead of jumping. Only from lg on:
below, the journal is a view of its own and the button stays a plain entry like the folders
@@ -1825,16 +1970,22 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
<div
key={`pin-${d.id}`}
draggable
data-doc-row={d.id}
onDragStart={(e) => handleDocDragStart(e, d.id)}
{...docDropProps(d.id)}
onClick={() => {
setSelectedDocId(d.id);
setSubView('editor');
setMobileShowDetail(true);
}}
onMouseDown={(e) => e.shiftKey && e.preventDefault()}
onClick={(e) =>
handleDocRowClick(e, d.id, () => {
setSelectedDocId(d.id);
setSubView('editor');
setMobileShowDetail(true);
})
}
className={`w-full flex items-center gap-2 px-2 py-1.5 min-h-11 sm:min-h-0 rounded-btn text-left text-sm sm:text-xs cursor-pointer border transition-colors ${
mergeHoverId === d.id
? 'border-accent bg-accent/15 ring-2 ring-accent/30 text-main'
: markedDocIds.has(d.id)
? 'border-accent/40 bg-accent/10 text-main'
: selectedDocId === d.id && subView === 'editor'
? 'bg-panel-elevated text-main font-semibold border-transparent'
: 'border-transparent text-main hover:bg-panel-elevated'
@@ -1881,6 +2032,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}}
docDropProps={docDropProps}
mergeHoverId={mergeHoverId}
markedDocIds={markedDocIds}
onDocRowClick={handleDocRowClick}
/>
))}
@@ -1888,16 +2041,22 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
<div
key={d.id}
draggable
data-doc-row={d.id}
onDragStart={(e) => handleDocDragStart(e, d.id)}
{...docDropProps(d.id)}
onClick={() => {
setSelectedDocId(d.id);
setSubView('editor');
setMobileShowDetail(true);
}}
onMouseDown={(e) => e.shiftKey && e.preventDefault()}
onClick={(e) =>
handleDocRowClick(e, d.id, () => {
setSelectedDocId(d.id);
setSubView('editor');
setMobileShowDetail(true);
})
}
className={`w-full flex items-center gap-2 px-2 py-1.5 min-h-11 sm:min-h-0 rounded-btn text-left text-sm sm:text-xs cursor-pointer border transition-colors ${
mergeHoverId === d.id
? 'border-accent bg-accent/15 ring-2 ring-accent/30 text-main'
: markedDocIds.has(d.id)
? 'border-accent/40 bg-accent/10 text-main'
: selectedDocId === d.id && subView === 'editor'
? 'bg-panel-elevated text-main font-semibold border-transparent'
: 'border-transparent text-main hover:bg-panel-elevated'
@@ -1966,8 +2125,19 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
<button
type="button"
onClick={openTrash}
onDragOver={(e) => {
if (!draggingDocId) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (!trashDragOver) setTrashDragOver(true);
}}
onDragLeave={() => setTrashDragOver(false)}
onDrop={handleDropOnTrash}
title="Papierkorb – Notizen hierher ziehen, um sie zu löschen"
className={`w-full flex items-center gap-2 px-2 h-11 sm:h-8 rounded-btn text-xs border transition-colors cursor-pointer ${
subView === 'trash'
trashDragOver
? 'border-status-err bg-status-err/10 text-status-err ring-2 ring-status-err/30'
: subView === 'trash'
? 'bg-panel-elevated text-main font-semibold border-transparent'
: 'border-transparent text-muted hover:text-main hover:bg-panel-elevated'
}`}