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
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:
1 parent
0d34308601
commit
3fbe015c06
6 files changed
+205
-26
No files matched your search
Generated
+2
-2
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "mindsift-frontend",
|
||||
"private": true,
|
||||
"version": "0.36.0",
|
||||
"version": "0.37.0",
|
||||
"license": "Unlicense",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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'
|
||||
}`}
|
||||
|
||||
Reference in new issue
Block a user