feat(workspace): turn a branch into its own note, merge notes
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m56s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m56s
- Extract: button on the bullet (desktop) or /extract (/notiz). The bullet becomes the title (hashtags stay at the link), its children the content of a new note in the same folder with the source's shares (a foreign source owner gets write access); the branch becomes [[title]] through the operation log, so open editors follow. - Merge: drop a note onto another in the sidebar or use "Mit anderer Notiz zusammenführen…" in the note header. The own source is appended as an h2 section with its bullets below and goes to the trash; the toast offers a complete undo. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4c26eb2960
commit
4d1b37c956
15 files changed
+934
-9
No files matched your search
@@ -27,6 +27,7 @@ import {
|
||||
import {
|
||||
Document,
|
||||
DocumentBlock,
|
||||
MergeResult,
|
||||
DocShare,
|
||||
SharedFolder,
|
||||
TagDetails,
|
||||
@@ -47,6 +48,7 @@ import { sendDocOps } from '../../lib/docSync/oneoff';
|
||||
import { useMobileActions } from '../../context/MobileActionContext';
|
||||
import { DocumentEditor, DocSaveStatus } from './DocumentEditor';
|
||||
import { TrashView } from './TrashView';
|
||||
import { MergeDialog } from './MergeDialog';
|
||||
import { CountBadge } from '../../components/CountBadge';
|
||||
import { extractHashtags } from '../../lib/hashtags';
|
||||
|
||||
@@ -87,6 +89,9 @@ interface FolderTreeItemProps {
|
||||
handleShareFolderClick: (folderPath: string, existingSf?: SharedFolder) => void;
|
||||
handleDeleteFolder: (folderPath: string) => void;
|
||||
onSelectDoc: (id: string) => void;
|
||||
// Dropping a note onto another one offers to merge them
|
||||
docDropProps: (targetId: string) => React.HTMLAttributes<HTMLDivElement>;
|
||||
mergeHoverId: string | null;
|
||||
}
|
||||
|
||||
const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
|
||||
@@ -106,6 +111,8 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
|
||||
handleShareFolderClick,
|
||||
handleDeleteFolder,
|
||||
onSelectDoc,
|
||||
docDropProps,
|
||||
mergeHoverId,
|
||||
}) => {
|
||||
const hasContent = node.subfolders.length > 0 || node.docs.length > 0;
|
||||
const isExpanded = Boolean(hasContent && expandedFolders[node.fullPath]);
|
||||
@@ -234,6 +241,8 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
|
||||
handleShareFolderClick={handleShareFolderClick}
|
||||
handleDeleteFolder={handleDeleteFolder}
|
||||
onSelectDoc={onSelectDoc}
|
||||
docDropProps={docDropProps}
|
||||
mergeHoverId={mergeHoverId}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -243,10 +252,13 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
|
||||
key={d.id}
|
||||
draggable
|
||||
onDragStart={(e) => handleDocDragStart(e, d.id)}
|
||||
{...docDropProps(d.id)}
|
||||
onClick={() => onSelectDoc(d.id)}
|
||||
style={{ paddingLeft: `${(depth + 1) * 12 + 16}px` }}
|
||||
className={`w-full flex items-center gap-2 pr-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${
|
||||
selectedDocId === d.id && subView === 'editor'
|
||||
mergeHoverId === d.id
|
||||
? 'border-accent bg-accent/15 ring-2 ring-accent/30 text-main'
|
||||
: selectedDocId === d.id && subView === 'editor'
|
||||
? 'bg-panel-elevated text-main font-semibold border-border/80'
|
||||
: 'border-transparent text-main hover:bg-panel-elevated'
|
||||
}`}
|
||||
@@ -378,6 +390,20 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
|
||||
// Drag and Drop state
|
||||
const [draggingDocId, setDraggingDocId] = useState<string | null>(null);
|
||||
|
||||
// Merging notes: dialog (picker/confirmation) and a toast with undo afterwards
|
||||
const [mergeDialog, setMergeDialog] = useState<{ sourceId: string; targetId?: string } | null>(null);
|
||||
const [mergeHoverId, setMergeHoverId] = useState<string | null>(null);
|
||||
const [toast, setToast] = useState<{ text: string; actions: { label: string; run: () => void }[] } | null>(null);
|
||||
const toastTimerRef = useRef<number | null>(null);
|
||||
const showToast = useCallback((text: string, actions: { label: string; run: () => void }[] = []) => {
|
||||
setToast({ text, actions });
|
||||
if (toastTimerRef.current !== null) window.clearTimeout(toastTimerRef.current);
|
||||
toastTimerRef.current = window.setTimeout(() => setToast(null), actions.length ? 10000 : 4000);
|
||||
}, []);
|
||||
useEffect(() => () => {
|
||||
if (toastTimerRef.current !== null) window.clearTimeout(toastTimerRef.current);
|
||||
}, []);
|
||||
const [draggingFolderPath, setDraggingFolderPath] = useState<string | null>(null);
|
||||
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
|
||||
|
||||
@@ -674,6 +700,66 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
setDraggingDocId(docId);
|
||||
};
|
||||
|
||||
const requestMerge = (sourceId: string, targetId?: string) => {
|
||||
const source = docs.find((d) => d.id === sourceId);
|
||||
if (!source?.isOwner) {
|
||||
showToast('Nur eigene Notizen lassen sich in eine andere einfügen');
|
||||
return;
|
||||
}
|
||||
setMergeDialog({ sourceId, targetId });
|
||||
};
|
||||
|
||||
// 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;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
if (mergeHoverId !== targetId) setMergeHoverId(targetId);
|
||||
},
|
||||
onDragLeave: () => setMergeHoverId((cur) => (cur === targetId ? null : cur)),
|
||||
onDrop: (e) => {
|
||||
const sourceId = e.dataTransfer.getData('application/mindsift-doc-id') || draggingDocId;
|
||||
if (!sourceId || sourceId === targetId) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDraggingDocId(null);
|
||||
setMergeHoverId(null);
|
||||
setDragOverFolder(null);
|
||||
requestMerge(sourceId, targetId);
|
||||
},
|
||||
});
|
||||
|
||||
const handleMerged = async (result: MergeResult) => {
|
||||
setMergeDialog(null);
|
||||
triggerHaptic(20);
|
||||
setDocs((prev) => prev.filter((d) => d.id !== result.sourceId));
|
||||
setSelectedDocId(result.targetId);
|
||||
setTargetBlockId(null);
|
||||
setSubView('editor');
|
||||
setMobileShowDetail(true);
|
||||
setEditorNonce((n) => n + 1);
|
||||
reloadTrash();
|
||||
showToast(`„${result.sourceTitle || 'Notiz'}“ wurde angehängt`, [
|
||||
{
|
||||
label: 'Rückgängig',
|
||||
run: async () => {
|
||||
try {
|
||||
await api.undoMerge(result);
|
||||
await loadWorkspaceData();
|
||||
setSelectedDocId(result.sourceId);
|
||||
setEditorNonce((n) => n + 1);
|
||||
reloadTrash();
|
||||
showToast('Zusammenführen rückgängig gemacht');
|
||||
} catch (err: any) {
|
||||
showToast(err?.message || 'Rückgängig fehlgeschlagen');
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleFolderDragStart = (e: React.DragEvent, path: string) => {
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.setData('application/mindsift-folder-path', path);
|
||||
@@ -1348,13 +1434,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
key={`pin-${d.id}`}
|
||||
draggable
|
||||
onDragStart={(e) => handleDocDragStart(e, d.id)}
|
||||
{...docDropProps(d.id)}
|
||||
onClick={() => {
|
||||
setSelectedDocId(d.id);
|
||||
setSubView('editor');
|
||||
setMobileShowDetail(true);
|
||||
}}
|
||||
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${
|
||||
selectedDocId === d.id && subView === 'editor'
|
||||
mergeHoverId === d.id
|
||||
? 'border-accent bg-accent/15 ring-2 ring-accent/30 text-main'
|
||||
: selectedDocId === d.id && subView === 'editor'
|
||||
? 'bg-panel-elevated text-main font-semibold border-border/80'
|
||||
: 'border-transparent text-main hover:bg-panel-elevated'
|
||||
}`}
|
||||
@@ -1398,6 +1487,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
setSubView('editor');
|
||||
setMobileShowDetail(true);
|
||||
}}
|
||||
docDropProps={docDropProps}
|
||||
mergeHoverId={mergeHoverId}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1406,13 +1497,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
key={d.id}
|
||||
draggable
|
||||
onDragStart={(e) => handleDocDragStart(e, d.id)}
|
||||
{...docDropProps(d.id)}
|
||||
onClick={() => {
|
||||
setSelectedDocId(d.id);
|
||||
setSubView('editor');
|
||||
setMobileShowDetail(true);
|
||||
}}
|
||||
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${
|
||||
selectedDocId === d.id && subView === 'editor'
|
||||
mergeHoverId === d.id
|
||||
? 'border-accent bg-accent/15 ring-2 ring-accent/30 text-main'
|
||||
: selectedDocId === d.id && subView === 'editor'
|
||||
? 'bg-panel-elevated text-main font-semibold border-border/80'
|
||||
: 'border-transparent text-main hover:bg-panel-elevated'
|
||||
}`}
|
||||
@@ -1527,6 +1621,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
onSelectTag={handleSelectTag}
|
||||
onWikiLinkClick={handleWikiLinkClick}
|
||||
onOpenDocBlock={handleJumpToDocBlock}
|
||||
onRequestMerge={(d) => setMergeDialog({ sourceId: d.id })}
|
||||
onBackMobile={() => setMobileShowDetail(false)}
|
||||
onOpenTask={onOpenTask}
|
||||
onSaveStatusChange={(status, err) => {
|
||||
@@ -2251,6 +2346,38 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
</FormSheet>
|
||||
)}
|
||||
|
||||
{mergeDialog && (
|
||||
<MergeDialog
|
||||
docs={docs}
|
||||
sourceId={mergeDialog.sourceId}
|
||||
targetId={mergeDialog.targetId}
|
||||
onClose={() => setMergeDialog(null)}
|
||||
onMerged={(r) => void handleMerged(r)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{toast && (
|
||||
<div
|
||||
role="status"
|
||||
className="fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] sm:bottom-6 z-50 max-w-[calc(100vw-1.5rem)] px-3 py-2 rounded-card text-xs text-main bg-panel-elevated border border-border shadow-panel flex items-center gap-3"
|
||||
>
|
||||
<span>{toast.text}</span>
|
||||
{toast.actions.map((a) => (
|
||||
<button
|
||||
key={a.label}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setToast(null);
|
||||
a.run();
|
||||
}}
|
||||
className="font-semibold text-accent hover:underline min-h-[44px] sm:min-h-0 shrink-0"
|
||||
>
|
||||
{a.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unified Sharing Modal (Strictly Read-Only for Docs and Folders) */}
|
||||
{(shareModalDoc || shareModalFolder) && (
|
||||
<UnifiedShareModal
|
||||
|
||||
Reference in new issue
Block a user