From 3fbe015c064c611fdc400d118bd70eb9c899a6b6 Mon Sep 17 00:00:00 2001 From: Sandro Date: Sun, 1 Nov 2026 12:38:03 +0100 Subject: [PATCH] feat(workspace): mark several notes (Ctrl/Cmd, Shift) and drag them into a folder or onto the trash 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 --- CHANGELOG.md | 9 +- Doku/modules/workspace.md | 2 + README.md | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- .../src/features/workspace/WorkspaceView.tsx | 212 ++++++++++++++++-- 6 files changed, 205 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1f3960..de8156c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +## [0.37.0] - 2026-11-01 + +### Added +- Workspace: Mehrere Notizen auf einmal verschieben oder löschen. Am PC markierst du mit Strg/Cmd+Klick einzelne Notizen und mit Shift+Klick einen Bereich; ziehst du eine markierte Notiz in einen Ordner oder auf den Papierkorb, kommen alle markierten mit. Escape hebt die Markierung auf. +- Der Papierkorb unten in der Seitenleiste nimmt gezogene Notizen an, auch einzeln und am Handy (lange drücken, dann ziehen). Der Hinweis danach bietet „Rückgängig“. + ## [0.36.0] - 2026-11-01 ### Added @@ -611,7 +617,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V - Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken. - Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung. -[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.36.0...dev +[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.37.0...dev +[0.37.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.36.0...v0.37.0 [0.36.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.35.0...v0.36.0 [0.35.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.34.0...v0.35.0 [0.34.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.33.0...v0.34.0 diff --git a/Doku/modules/workspace.md b/Doku/modules/workspace.md index 6dcedc5..4bc1aff 100644 --- a/Doku/modules/workspace.md +++ b/Doku/modules/workspace.md @@ -41,6 +41,8 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts ## Explorer, Tags, To-Dos - Ordnerbaum mit Favoriten (`doc_pins`), Verschieben per Drag & Drop (`/documents/{id}/move`, `/folders/move`). +- **Mehrere Notizen** (PC): Strg/Cmd+Klick markiert einzeln, Shift+Klick einen Bereich in der angezeigten Reihenfolge; ein normaler Klick, Escape oder ein Klick ins Leere hebt die Markierung auf. Wer eine markierte Notiz zieht, nimmt alle markierten mit, in einen Ordner oder auf den Papierkorb (Zusammenführen gibt es dabei nicht). +- **Auf den Papierkorb ziehen** (auch eine einzelne Notiz, am Handy per langem Drücken wie beim Verschieben in Ordner) verschiebt in den Papierkorb; der Hinweis bietet „Rückgängig“. - **Tags:** Alle `#Hashtags` werden aggregiert (`/workspace/tags`). Eine Tag-Seite zeigt alle Fundstellen gruppiert nach Notiz und kann eine eigene Freitext-Notiz tragen (`tag_pages`). - **To-Do-Rollup:** Die Ansicht „To-Dos“ sammelt alle `todo`-Blöcke aller Notizen, filterbar nach Ordner. Abhaken im Rollup ändert den Block in der Notiz. - **Enhanced Tasks:** Ein `todo`-Block lässt sich als verknüpfte Aufgabe führen (`/documents/{id}/blocks/{blockId}/enhanced-task`); Erledigt-Status bleibt zwischen Notiz und Aufgabenansicht synchron. diff --git a/README.md b/README.md index 6f0f6b5..766ddae 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#) [![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart) [![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir) -[![Version](https://img.shields.io/badge/Version-v0.36.0-16a34a?style=flat-square)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/Version-v0.37.0-16a34a?style=flat-square)](CHANGELOG.md) **MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e92a5c8..13c9062 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index faac980..ebef55b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mindsift-frontend", "private": true, - "version": "0.36.0", + "version": "0.37.0", "license": "Unlicense", "type": "module", "scripts": { diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index 8eabc4a..d22974f 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -99,6 +99,9 @@ interface FolderTreeItemProps { // Dropping a note onto another one offers to merge them docDropProps: (targetId: string) => React.HTMLAttributes; mergeHoverId: string | null; + // Several notes marked with Ctrl/Cmd or Shift; a plain click opens the note + markedDocIds: Set; + onDocRowClick: (e: React.MouseEvent, id: string, open: () => void) => void; } const FolderTreeItem: React.FC = ({ @@ -120,6 +123,8 @@ const FolderTreeItem: React.FC = ({ 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 = ({ onSelectDoc={onSelectDoc} docDropProps={docDropProps} mergeHoverId={mergeHoverId} + markedDocIds={markedDocIds} + onDocRowClick={onDocRowClick} /> ))} @@ -258,13 +265,17 @@ const FolderTreeItem: React.FC = ({
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 = ({ // Drag and Drop state const [draggingDocId, setDraggingDocId] = useState(null); + // Marked notes (Ctrl/Cmd+click, Shift+click) are moved or trashed together by dragging one of them + const [markedDocIds, setMarkedDocIds] = useState>(() => new Set()); + const [draggingDocIds, setDraggingDocIds] = useState([]); + const [trashDragOver, setTrashDragOver] = useState(false); + const lastMarkedRef = useRef(null); + const treeRef = useRef(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 = ({ } }; - // 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('[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 = ({ // Dropping a dragged note onto another note (instead of a folder) offers to merge them const docDropProps = (targetId: string): React.HTMLAttributes => ({ 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 = ({ 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 = ({ 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 = ({
{/* Scrollable Navigation Tree */} -
+
{ + if (e.target === e.currentTarget && markedDocIds.size > 0) setMarkedDocIds(new Set()); + }} + > + {markedDocIds.size > 0 && ( +
+ + {markedDocIds.size} markiert · in einen Ordner oder auf den Papierkorb ziehen + + +
+ )} {/* 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 = ({
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 = ({ }} docDropProps={docDropProps} mergeHoverId={mergeHoverId} + markedDocIds={markedDocIds} + onDocRowClick={handleDocRowClick} /> ))} @@ -1888,16 +2041,22 @@ export const WorkspaceView: React.FC = ({
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 = ({