import React, { useEffect, useState, useMemo, useRef } from 'react'; import { FilePlus, FolderPlus, Folder, FolderOpen, ChevronRight, ChevronDown, Search, Pin, Trash2, Download, CheckSquare, Heading1, Heading2, Heading3, List, Code, Sparkles, AlignLeft, Check, X, ArrowUp, ArrowDown, Bold, Italic, Strikethrough, Link, FileText, Table as TableIcon, Percent, Minus, ExternalLink, ArrowLeft, GripVertical, Layers, Filter, CheckCircle2, Circle, FolderTree, Plus, HelpCircle, Users, Lock, Unlock, Eye, Edit3, Share, Share2, Hash, } from 'lucide-react'; import { BlockType, Document, DocumentBlock, DocLockInfo, DocShare, TagDetails, TagSummary, User } from '../../lib/types'; import { api, getToken, triggerHaptic } from '../../lib/api'; interface WorkspaceViewProps { currentUser?: User | null; initialDocId?: string | null; onStatsChange: () => void; } const ICONS = ['📄', '📝', '💡', '🚀', '🏡', '⚡', '🎯', '🍕', '🛠️', '🧠', '🔥', '🏔️', '📦', '📑', '⚙️', '📊']; interface FolderTreeItem { path: string; name: string; depth: number; subfolders: FolderTreeItem[]; docs: Document[]; } interface SlashCommandItem { type?: BlockType; action?: 'insert_link' | 'insert_note' | 'insert_weblink'; label: string; desc: string; icon: string; } const SLASH_COMMANDS: SlashCommandItem[] = [ { action: 'insert_link', label: '/link', desc: 'Webseite oder Notiz verlinken', icon: '🔗' }, { action: 'insert_note', label: '/notiz', desc: 'Notiz verlinken [[Notiz]]', icon: '📄' }, { action: 'insert_weblink', label: '/web', desc: 'Externe Webseite verlinken [Name](url)', icon: '🌐' }, { type: 'todo', label: '/todo', desc: 'Aufgabe mit Checkbox', icon: '☑️' }, { type: 'h1', label: '/h1', desc: 'Große Überschrift', icon: 'H1' }, { type: 'h2', label: '/h2', desc: 'Abschnitts-Überschrift', icon: 'H2' }, { type: 'h3', label: '/h3', desc: 'Unterabschnitt', icon: 'H3' }, { type: 'bullet', label: '/bullet', desc: 'Standard Listenpunkt', icon: '•' }, { type: 'callout', label: '/callout', desc: 'Hervorgehobene Hinweisbox', icon: '💡' }, { type: 'code', label: '/code', desc: 'Code-Block mit Syntax', icon: '💻' }, { type: 'table', label: '/table', desc: 'Markdown-Tabelle', icon: '📊' }, { type: 'progress', label: '/progress', desc: 'Fortschrittsbalken', icon: '📈' }, { type: 'divider', label: '/divider', desc: 'Horizontale Trennlinie', icon: '—' }, ]; export const WorkspaceView: React.FC = ({ currentUser, initialDocId, onStatsChange, }) => { // Top Workspace Tab Mode: 'editor' vs 'todos' vs 'tag' const [workspaceMode, setWorkspaceMode] = useState<'editor' | 'todos' | 'tag'>('editor'); const [selectedTag, setSelectedTag] = useState(null); const [tagDetails, setTagDetails] = useState(null); const [workspaceTags, setWorkspaceTags] = useState([]); const [isLoadingTag, setIsLoadingTag] = useState(false); const [docs, setDocs] = useState([]); const [selectedDoc, setSelectedDoc] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [saveState, setSaveState] = useState<'saved' | 'saving'>('saved'); const [showIconPicker, setShowIconPicker] = useState(false); const [showWikiLinkMenu, setShowWikiLinkMenu] = useState(false); // Folder state (synchronized with server) const [customFolders, setCustomFolders] = useState([]); useEffect(() => { try { localStorage.removeItem('mindsift_workspace_folders'); } catch {} }, []); const [expandedFolders, setExpandedFolders] = useState>(new Set()); const [isCreatingFolder, setIsCreatingFolder] = useState(false); const [newFolderParent, setNewFolderParent] = useState(null); const [newFolderName, setNewFolderName] = useState(''); // Sidebar Document & Folder Drag and Drop state const [draggingDocId, setDraggingDocId] = useState(null); const [draggingFolderPath, setDraggingFolderPath] = useState(null); const [dragOverFolder, setDragOverFolder] = useState(null); // Active focused block ref const [lastFocusedBlockId, setLastFocusedBlockId] = useState(null); // --- LOGSEQ OUTLINER SPECIFIC STATE --- // Zoom-in / Focus mode (Hoisting to a bullet) const [zoomedBlockId, setZoomedBlockId] = useState(null); // Floating Slash Command menu const [slashBlockId, setSlashBlockId] = useState(null); const [slashSearch, setSlashSearch] = useState(''); const [slashSelectedIndex, setSlashSelectedIndex] = useState(0); const slashMenuRef = useRef(null); // Link Insertion Modal state (/link, /notiz, /web) const [linkModalBlockId, setLinkModalBlockId] = useState(null); const [linkModalTab, setLinkModalTab] = useState<'note' | 'web'>('note'); const [linkSearchQuery, setLinkSearchQuery] = useState(''); const [linkWebTitle, setLinkWebTitle] = useState(''); const [linkWebUrl, setLinkWebUrl] = useState(''); // Drag and Drop reordering state for blocks const [draggingBlockId, setDraggingBlockId] = useState(null); // --- GLOBAL TO-DO ROLLUP STATE --- const [todoFilter, setTodoFilter] = useState<'all' | 'open' | 'done'>('open'); const [todoFolderDrilldown, setTodoFolderDrilldown] = useState(null); const [collapsedTodoFolders, setCollapsedTodoFolders] = useState>(new Set()); // --- DOCUMENT SHARING STATE --- const [showShareModal, setShowShareModal] = useState(false); const [docShares, setDocShares] = useState([]); const [collaborators, setCollaborators] = useState([]); const [shareTargetEmailOrId, setShareTargetEmailOrId] = useState(''); const [sharePermission, setSharePermission] = useState<'read' | 'write'>('read'); const [sharingLoading, setSharingLoading] = useState(false); const [shareError, setShareError] = useState(null); // --- DOCUMENT LOCKING STATE --- const [activeLock, setActiveLock] = useState(null); const [holdsLock, setHoldsLock] = useState(false); const holdsLockRef = useRef(false); const heartbeatTimerRef = useRef(null); const idleUnlockTimerRef = useRef(null); const currentDocIdRef = useRef(null); const isEditorFocusedRef = useRef(false); const blockInputRefs = useRef>({}); const isLockedByOther = !!(activeLock && activeLock.userId !== currentUser?.id); const isReadOnly = selectedDoc?.permission === 'read' || isLockedByOther; const stopHeartbeat = () => { if (heartbeatTimerRef.current) { clearInterval(heartbeatTimerRef.current); heartbeatTimerRef.current = null; } }; const startHeartbeat = (docId: string) => { stopHeartbeat(); heartbeatTimerRef.current = setInterval(async () => { if (!holdsLockRef.current) { stopHeartbeat(); return; } const res = await api.heartbeatDocLock(docId); if (!res.extended) { holdsLockRef.current = false; setHoldsLock(false); stopHeartbeat(); } }, 4000); }; const releaseMyLock = async (docId?: string) => { const targetDocId = docId || currentDocIdRef.current; if (!targetDocId) return; const wasHolding = holdsLockRef.current; stopHeartbeat(); if (idleUnlockTimerRef.current) { clearTimeout(idleUnlockTimerRef.current); idleUnlockTimerRef.current = null; } holdsLockRef.current = false; setHoldsLock(false); if (wasHolding || docId) { try { await api.unlockDocument(targetDocId); } catch {} } }; const ensureLock = async (): Promise => { if (!selectedDoc || selectedDoc.permission === 'read') return false; if (holdsLockRef.current) { return true; } try { const res = await api.lockDocument(selectedDoc.id); if (res.acquired) { holdsLockRef.current = true; setHoldsLock(true); setActiveLock(null); startHeartbeat(selectedDoc.id); return true; } else if (res.lock) { setActiveLock(res.lock); holdsLockRef.current = false; setHoldsLock(false); return false; } } catch (e) { console.error('Failed to acquire lock', e); } return false; }; const handleBlockFocus = () => { isEditorFocusedRef.current = true; if (idleUnlockTimerRef.current) { clearTimeout(idleUnlockTimerRef.current); idleUnlockTimerRef.current = null; } if (!isReadOnly) { ensureLock(); } }; const handleBlockBlur = () => { isEditorFocusedRef.current = false; if (idleUnlockTimerRef.current) { clearTimeout(idleUnlockTimerRef.current); } // Short grace period: if user clicked into another block in the doc, focus will cancel this. // If user clicked outside or stopped editing, save and release the lock immediately! idleUnlockTimerRef.current = setTimeout(async () => { if (!isEditorFocusedRef.current && holdsLockRef.current) { await flushPendingSave(); await releaseMyLock(); } }, 1500); }; // Debounced auto-save refs (500ms delay to avoid writing every keystroke to SSD) const debounceSaveTimerRef = useRef(null); const pendingSaveDocRef = useRef(null); const executeSaveToBackend = async (docToSave: Document) => { if (isReadOnly) return; try { const saved = await api.updateDocument(docToSave.id, { title: docToSave.title, icon: docToSave.icon, folderPath: docToSave.folderPath, blocks: docToSave.blocks, tags: docToSave.tags, isPinned: docToSave.isPinned, }); setSelectedDoc((prev) => prev && prev.id === saved.id ? { ...prev, ...saved, permission: prev.permission, isOwner: prev.isOwner, ownerName: prev.ownerName, sharedWith: prev.sharedWith, } : prev ); onStatsChange(); api.getWorkspaceTags().then(setWorkspaceTags).catch(() => {}); if (selectedTag) { api.getTagDetails(selectedTag).then(setTagDetails).catch(() => {}); } } catch (e: any) { if (e.message && e.message.includes('423')) { api.getDocLock(docToSave.id).then((res) => { if (res.lock) setActiveLock(res.lock); }); } } finally { if (pendingSaveDocRef.current === null || pendingSaveDocRef.current === docToSave) { pendingSaveDocRef.current = null; setSaveState('saved'); } if (!isEditorFocusedRef.current && holdsLockRef.current) { releaseMyLock(); } } }; const flushPendingSave = async () => { if (debounceSaveTimerRef.current) { clearTimeout(debounceSaveTimerRef.current); debounceSaveTimerRef.current = null; } if (pendingSaveDocRef.current) { const doc = pendingSaveDocRef.current; pendingSaveDocRef.current = null; await executeSaveToBackend(doc); } }; const persistDocument = async (updatedDoc: Document, immediate = false) => { if (isReadOnly) return; await ensureLock(); // 1. Update local UI state immediately for responsive, instant typing setSelectedDoc(updatedDoc); setDocs((prev) => prev.map((d) => (d.id === updatedDoc.id ? updatedDoc : d))); setSaveState('saving'); pendingSaveDocRef.current = updatedDoc; if (debounceSaveTimerRef.current) { clearTimeout(debounceSaveTimerRef.current); debounceSaveTimerRef.current = null; } if (immediate) { await flushPendingSave(); } else { // 500 ms debounced delay after last keystroke so disk/SSD is only written after typing pauses debounceSaveTimerRef.current = setTimeout(async () => { debounceSaveTimerRef.current = null; if (pendingSaveDocRef.current) { const doc = pendingSaveDocRef.current; pendingSaveDocRef.current = null; await executeSaveToBackend(doc); } }, 500); } }; const loadDocs = async (selectId?: string | null) => { try { const [list, serverFolders, tags] = await Promise.all([ api.getDocuments(), api.getFolders(), api.getWorkspaceTags().catch(() => []), ]); setDocs(list); setCustomFolders(serverFolders); setWorkspaceTags(tags); if (selectId) { const found = list.find((d) => d.id === selectId); if (found) { setSelectedDoc(found); return; } } if (!selectedDoc && list.length > 0) { setSelectedDoc(list[0]); } else if (selectedDoc) { const updated = list.find((d) => d.id === selectedDoc.id); if (updated) setSelectedDoc(updated); } } catch (err) { console.error('Error loading documents or folders:', err); } }; const openTagPage = async (tag: string) => { const clean = tag.replace(/^#/, '').toLowerCase(); setSelectedTag(clean); setWorkspaceMode('tag'); setIsLoadingTag(true); try { const details = await api.getTagDetails(clean); setTagDetails(details); } catch (err) { console.error('Failed to load tag details', err); } finally { setIsLoadingTag(false); } }; useEffect(() => { loadDocs(initialDocId); }, [initialDocId]); const selectedDocIdRef = useRef(null); selectedDocIdRef.current = selectedDoc?.id || null; const currentUserIdRef = useRef(null); currentUserIdRef.current = currentUser?.id || null; // Handle document switch and lock status checks useEffect(() => { const prevId = currentDocIdRef.current; if (prevId && prevId !== selectedDoc?.id) { flushPendingSave(); releaseMyLock(prevId); } currentDocIdRef.current = selectedDoc?.id || null; if (selectedDoc) { api .getDocLock(selectedDoc.id) .then((res) => { if (res.locked && res.lock && res.lock.userId !== currentUser?.id) { setActiveLock(res.lock); } else { setActiveLock(null); } }) .catch(() => {}); } else { setActiveLock(null); } return () => { flushPendingSave(); if (currentDocIdRef.current) { releaseMyLock(currentDocIdRef.current); } }; }, [selectedDoc?.id]); // Window blur / visibility change listener: release lock when user leaves tab/window useEffect(() => { const handleWindowBlur = () => { if (!isEditorFocusedRef.current && holdsLockRef.current) { flushPendingSave(); releaseMyLock(); } }; const handleVisibilityChange = () => { if (document.visibilityState === 'hidden') { isEditorFocusedRef.current = false; flushPendingSave(); releaseMyLock(); } }; window.addEventListener('blur', handleWindowBlur); document.addEventListener('visibilitychange', handleVisibilityChange); return () => { window.removeEventListener('blur', handleWindowBlur); document.removeEventListener('visibilitychange', handleVisibilityChange); }; }, []); // Flush pending save and release lock before tab closes or unmounts useEffect(() => { const handleBeforeUnload = () => { flushPendingSave(); if (currentDocIdRef.current && holdsLockRef.current) { try { const token = getToken(); fetch(`/api/v1/documents/${currentDocIdRef.current}/lock`, { method: 'DELETE', headers: token ? { Authorization: `Bearer ${token}` } : {}, keepalive: true, }).catch(() => {}); } catch {} } }; window.addEventListener('beforeunload', handleBeforeUnload); return () => { window.removeEventListener('beforeunload', handleBeforeUnload); flushPendingSave(); if (currentDocIdRef.current && holdsLockRef.current) { releaseMyLock(currentDocIdRef.current); } }; }, []); // Real-time WebSocket listener for Document & Lock events useEffect(() => { const handleWs = (e: any) => { const msg = e.detail; if (!msg || !msg.event) return; if (msg.event === 'DOC_LOCKED') { const payload = msg.payload as DocLockInfo; if (payload && payload.docId === selectedDocIdRef.current) { if (payload.userId !== currentUserIdRef.current) { setActiveLock(payload); } } } else if (msg.event === 'DOC_UNLOCKED') { const payload = msg.payload as { docId?: string; doc_id?: string; id?: string }; const unlockedDocId = payload?.docId || payload?.doc_id || payload?.id; if (unlockedDocId && unlockedDocId === selectedDocIdRef.current) { setActiveLock(null); // Fetch fresh document from backend so updates made by the other user are immediately visible api.getDocument(unlockedDocId).then((freshDoc) => { if (freshDoc && freshDoc.id === selectedDocIdRef.current) { setSelectedDoc((prev) => (prev && prev.id === freshDoc.id ? { ...prev, ...freshDoc, isPinned: prev.isPinned } : prev)); setDocs((prev) => prev.map((d) => (d.id === freshDoc.id ? { ...d, ...freshDoc, isPinned: d.isPinned } : d))); } }).catch(() => {}); } } else if (msg.event === 'DOC_CREATED') { const created = msg.payload as Document; if (created && created.id) { setDocs((prev) => (prev.some((d) => d.id === created.id) ? prev : [created, ...prev])); onStatsChange(); } } else if (msg.event === 'DOC_UPDATED') { const updated = msg.payload as Document; if (updated && updated.id) { setDocs((prev) => prev.map((d) => (d.id === updated.id ? { ...d, ...updated, isPinned: d.isPinned } : d)) ); if (selectedDocIdRef.current === updated.id) { if (!holdsLockRef.current) { setSelectedDoc((prev) => (prev ? { ...prev, ...updated, isPinned: prev.isPinned } : updated)); } } onStatsChange(); } } else if (msg.event === 'DOC_DELETED') { const deletedId = typeof msg.payload === 'string' ? msg.payload : msg.payload?.id; if (deletedId) { setDocs((prev) => prev.filter((d) => d.id !== deletedId)); if (selectedDocIdRef.current === deletedId) { setSelectedDoc(null); } onStatsChange(); } } else if (msg.event === 'WORKSPACE_FOLDERS_UPDATED') { api.getFolders().then(setCustomFolders).catch(() => {}); } }; window.addEventListener('mindsift:ws', handleWs); return () => window.removeEventListener('mindsift:ws', handleWs); }, [onStatsChange]); const openShareModal = async () => { if (!selectedDoc) return; setShowShareModal(true); setShareError(null); setSharingLoading(true); try { const [sharesList, usersList] = await Promise.all([ api.getDocShares(selectedDoc.id).catch(() => []), api.getCollaborators().catch(() => []), ]); setDocShares(sharesList); setCollaborators(usersList); } finally { setSharingLoading(false); } }; const handleAddShare = async (e: React.FormEvent) => { e.preventDefault(); if (!selectedDoc) return; const target = shareTargetEmailOrId.trim(); if (!target) return; setSharingLoading(true); setShareError(null); try { const isId = collaborators.some((u) => u.id === target); const newShare = await api.createDocShare(selectedDoc.id, { userId: isId ? target : undefined, email: !isId ? target : undefined, permission: sharePermission, }); setDocShares((prev) => [...prev.filter((s) => s.userId !== newShare.userId), newShare]); setShareTargetEmailOrId(''); setSelectedDoc((prev) => prev ? { ...prev, sharedWith: [ ...(prev.sharedWith || []).filter((s) => s.userId !== newShare.userId), newShare, ], } : prev ); } catch (err: any) { setShareError(err.message || 'Freigabe konnte nicht erstellt werden'); } finally { setSharingLoading(false); } }; const handleUpdateSharePerm = async (targetUserId: string, newPerm: 'read' | 'write') => { if (!selectedDoc) return; setSharingLoading(true); try { const updated = await api.createDocShare(selectedDoc.id, { userId: targetUserId, permission: newPerm, }); setDocShares((prev) => prev.map((s) => (s.userId === targetUserId ? updated : s))); setSelectedDoc((prev) => prev ? { ...prev, sharedWith: (prev.sharedWith || []).map((s) => s.userId === targetUserId ? updated : s ), } : prev ); } catch (err: any) { setShareError(err.message || 'Berechtigung konnte nicht geändert werden'); } finally { setSharingLoading(false); } }; const handleRemoveShare = async (targetUserId: string) => { if (!selectedDoc) return; if (!window.confirm('Freigabe für diesen Benutzer wirklich aufheben?')) return; setSharingLoading(true); try { await api.deleteDocShare(selectedDoc.id, targetUserId); setDocShares((prev) => prev.filter((s) => s.userId !== targetUserId)); setSelectedDoc((prev) => prev ? { ...prev, sharedWith: (prev.sharedWith || []).filter((s) => s.userId !== targetUserId), } : prev ); } catch (err: any) { setShareError(err.message || 'Freigabe konnte nicht entfernt werden'); } finally { setSharingLoading(false); } }; const handleLeaveShare = async () => { if (!selectedDoc || !currentUser) return; const totalBlocks = selectedDoc.blocks?.length || 0; const subpointsCount = (selectedDoc.blocks || []).filter((b) => (b.level || 0) > 0).length; const title = selectedDoc.title || 'Geteilte Notiz'; let confirmMsg = 'Möchtest du diese freigegebene Notiz wirklich aus deinem Workspace entfernen?'; if (totalBlocks > 0 && subpointsCount > 0) { confirmMsg = `Möchtest du die geteilte Notiz "${title}" mit allen ${totalBlocks} Punkten (inklusive ${subpointsCount} Unterpunkten) wirklich aus deinem Workspace entfernen?`; } else if (totalBlocks > 0) { confirmMsg = `Möchtest du die geteilte Notiz "${title}" wirklich aus deinem Workspace entfernen?`; } if (!window.confirm(confirmMsg)) return; try { await api.deleteDocument(selectedDoc.id); setShowShareModal(false); const remaining = docs.filter((d) => d.id !== selectedDoc.id); setDocs(remaining); setSelectedDoc(remaining[0] || null); onStatsChange(); } catch (err: any) { alert(err.message || 'Konnte Freigabe nicht verlassen'); } }; const getDocFolder = (doc: Document): string => { if (doc.folderPath) return doc.folderPath; const tag = doc.tags?.find((t) => t.startsWith('folder:')); return tag ? tag.replace('folder:', '') : ''; }; // Compute all unique folder paths const allFolderPaths = useMemo(() => { const set = new Set(customFolders); docs.forEach((doc) => { const f = getDocFolder(doc); if (f) { const parts = f.split('/'); let cur = ''; for (const p of parts) { cur = cur ? `${cur}/${p}` : p; set.add(cur); } } }); return Array.from(set).sort((a, b) => a.localeCompare(b)); }, [customFolders, docs]); // Keep folders expanded by default useEffect(() => { setExpandedFolders((prev) => { const next = new Set(prev); allFolderPaths.forEach((p) => next.add(p)); return next; }); }, [allFolderPaths.join(',')]); const handleCreateDoc = async (folderPath?: string | null) => { triggerHaptic(15); const tags = ['notiz']; const path = folderPath || ''; if (path) { tags.push(`folder:${path}`); } const created = await api.createDocument({ title: 'Neue Notiz', icon: '📄', folderPath: path, blocks: [ { id: `blk_${Date.now()}_1`, type: 'bullet', content: '', level: 0, }, ], tags, isPinned: false, }); await loadDocs(created.id); onStatsChange(); setWorkspaceMode('editor'); }; const handleDeleteDoc = async (id: string) => { const docToDelete = docs.find((d) => d.id === id) || (selectedDoc?.id === id ? selectedDoc : null); const title = docToDelete?.title || selectedDoc?.title || 'Notiz'; const totalBlocks = docToDelete?.blocks?.length || 0; const subpointsCount = (docToDelete?.blocks || []).filter((b) => (b.level || 0) > 0).length; let confirmMsg = `Möchtest du die Notiz "${title}" wirklich löschen?`; if (totalBlocks > 0) { if (subpointsCount > 0) { confirmMsg = `Möchtest du die Notiz "${title}" mit allen ${totalBlocks} Aufzählungspunkten (inklusive ${subpointsCount} Unterpunkten) wirklich unwiderruflich löschen?`; } else { confirmMsg = `Möchtest du die Notiz "${title}" mit allen ${totalBlocks} Aufzählungspunkten wirklich löschen?`; } } if (!window.confirm(confirmMsg)) return; triggerHaptic(20); await api.deleteDocument(id); const remaining = docs.filter((d) => d.id !== id); setDocs(remaining); setSelectedDoc(remaining[0] || null); onStatsChange(); }; const handleChangeDocFolder = async (folderPath: string) => { if (!selectedDoc) return; const cleanPath = folderPath.trim(); const updatedTags = (selectedDoc.tags || []).filter((t) => !t.startsWith('folder:')); if (cleanPath) { updatedTags.push(`folder:${cleanPath}`); } const updatedDoc = { ...selectedDoc, folderPath: cleanPath, tags: updatedTags, }; await persistDocument(updatedDoc, true); }; // Global drag cleanup so drag states never get stuck if dragend is lost during re-render useEffect(() => { const handleGlobalDragEnd = () => { setDraggingDocId(null); setDraggingFolderPath(null); setDragOverFolder(null); setDraggingBlockId(null); }; window.addEventListener('dragend', handleGlobalDragEnd); window.addEventListener('drop', handleGlobalDragEnd); return () => { window.removeEventListener('dragend', handleGlobalDragEnd); window.removeEventListener('drop', handleGlobalDragEnd); }; }, []); 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. Sync folder structure with server api .createFolder(newFolderPath) .then(() => api.deleteFolder(cleanSource)) .then(() => api.getFolders()) .then(setCustomFolders) .catch((err) => console.error('Error syncing moved folders with server', err)); // 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 docId = e.dataTransfer.getData('application/mindsift-doc-id') || draggingDocId; setDraggingDocId(null); setDragOverFolder(null); if (!docId) return; const doc = docs.find((d) => d.id === docId); if (!doc) return; const cleanPath = folderPath.trim(); if (getDocFolder(doc) === cleanPath) return; triggerHaptic(15); const updatedTags = (doc.tags || []).filter((t) => !t.startsWith('folder:')); if (cleanPath) { updatedTags.push(`folder:${cleanPath}`); } const updatedDoc: Document = { ...doc, folderPath: cleanPath, tags: updatedTags, }; await persistDocument(updatedDoc, true); if (cleanPath) { setExpandedFolders((prev) => new Set([...prev, cleanPath])); } }; const handleStartCreateFolder = (parentPath?: string | null) => { setNewFolderParent(parentPath || null); setNewFolderName(''); setIsCreatingFolder(true); if (parentPath) { setExpandedFolders((prev) => new Set([...prev, parentPath])); } }; const handleConfirmCreateFolder = async () => { const trimmed = newFolderName.trim(); if (!trimmed) { setIsCreatingFolder(false); return; } const fullPath = newFolderParent ? `${newFolderParent}/${trimmed}` : trimmed; try { await api.createFolder(fullPath); const serverFolders = await api.getFolders(); setCustomFolders(serverFolders); } catch (err) { console.error('Failed to create folder on server', err); if (!customFolders.includes(fullPath)) { setCustomFolders((prev) => [...prev, fullPath].sort((a, b) => a.localeCompare(b))); } } setExpandedFolders((prev) => new Set([...prev, fullPath])); setIsCreatingFolder(false); setNewFolderName(''); setNewFolderParent(null); }; const handleDeleteFolder = async (folderPath: string, folderName: string) => { // Find all documents in this folder and subfolders const docsInFolder = docs.filter((d) => { const f = getDocFolder(d); return f === folderPath || f.startsWith(`${folderPath}/`); }); const confirmMsg = docsInFolder.length > 0 ? `Möchtest du den Ordner "${folderName}" wirklich löschen? Alle ${docsInFolder.length} darin enthaltenen Notizen werden unwiderruflich gelöscht!` : `Möchtest du den leeren Ordner "${folderName}" wirklich löschen?`; if (!window.confirm(confirmMsg)) return; try { const res = await api.deleteFolder(folderPath); const deletedIds = new Set(res.deletedDocIds || docsInFolder.map((d) => d.id)); const nextDocs = docs.filter((d) => !deletedIds.has(d.id)); setDocs(nextDocs); if (selectedDoc && deletedIds.has(selectedDoc.id)) { setSelectedDoc(nextDocs.length > 0 ? nextDocs[0] : null); } const serverFolders = await api.getFolders(); setCustomFolders(serverFolders); onStatsChange(); } catch (err) { console.error('Failed to delete folder', err); alert('Fehler beim Löschen des Ordners'); } }; const toggleFolder = (folderPath: string) => { setExpandedFolders((prev) => { const next = new Set(prev); if (next.has(folderPath)) { next.delete(folderPath); } else { next.add(folderPath); } return next; }); }; // ============================================================================ // LOGSEQ OUTLINER CORE LOGIC // ============================================================================ // Helper: check if a block has children (i.e. next block has greater level) const hasChildren = (blocks: DocumentBlock[], index: number): boolean => { if (index + 1 >= blocks.length) return false; const currentLevel = blocks[index].level || 0; return (blocks[index + 1].level || 0) > currentLevel; }; // Insert a new bullet immediately after index, inheriting level const insertNewBulletAfter = ( afterIndex: number, level: number, type: BlockType = 'bullet', initialContent: string = '' ) => { if (isReadOnly || !selectedDoc) return; const newBlock: DocumentBlock = { id: `blk_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, type, content: initialContent, level, checked: false, }; const nextBlocks = [...selectedDoc.blocks]; nextBlocks.splice(afterIndex + 1, 0, newBlock); persistDocument({ ...selectedDoc, blocks: nextBlocks }); setLastFocusedBlockId(newBlock.id); // Focus into the new element next tick setTimeout(() => { const el = blockInputRefs.current[newBlock.id]; if (el) { el.focus(); if ('setSelectionRange' in el && typeof (el as any).value === 'string') { (el as any).setSelectionRange(0, 0); } } }, 20); }; // Indent block (Tab): increase level by 1 const indentBlock = (index: number) => { if (isReadOnly || !selectedDoc || index === 0) return; const block = selectedDoc.blocks[index]; const prevBlock = selectedDoc.blocks[index - 1]; const currentLevel = block.level || 0; const maxLevel = (prevBlock.level || 0) + 1; if (currentLevel < maxLevel) { updateBlock(block.id, { level: currentLevel + 1 }); } }; // Outdent block (Shift+Tab): decrease level by 1 const outdentBlock = (blockId: string) => { if (isReadOnly || !selectedDoc) return; const block = selectedDoc.blocks.find((b) => b.id === blockId); if (!block) return; const currentLevel = block.level || 0; if (currentLevel > 0) { updateBlock(blockId, { level: currentLevel - 1 }); } }; // Delete block and all its descendant sub-bullets with confirmation const removeBlock = (blockId: string): boolean => { if (isReadOnly || !selectedDoc) return false; const blocks = selectedDoc.blocks; const index = blocks.findIndex((b) => b.id === blockId); if (index === -1) return false; const targetBlock = blocks[index]; const targetLevel = targetBlock.level || 0; // Find all descendant sub-bullets directly or indirectly nested under this block const descendantIds: string[] = []; for (let i = index + 1; i < blocks.length; i++) { const cur = blocks[i]; const curLevel = cur.level || 0; if (curLevel <= targetLevel) break; descendantIds.push(cur.id); } if (descendantIds.length > 0) { const count = descendantIds.length; const snippet = targetBlock.content?.trim() ? ` "${targetBlock.content.trim().slice(0, 30)}..."` : ''; const confirmText = count === 1 ? `Dieser Aufzählungspunkt${snippet} enthält 1 Unterpunkt. Möchtest du ihn mitsamt dem Unterpunkt wirklich löschen?` : `Dieser Aufzählungspunkt${snippet} enthält ${count} Unterpunkte. Möchtest du ihn mitsamt allen ${count} Unterpunkten wirklich löschen?`; if (!window.confirm(confirmText)) { return false; } } triggerHaptic(15); const idsToDelete = new Set([blockId, ...descendantIds]); let nextBlocks = blocks.filter((b) => !idsToDelete.has(b.id)); if (nextBlocks.length === 0) { nextBlocks = [ { id: `blk_${Date.now()}_1`, type: 'bullet', content: '', level: 0, }, ]; } // Determine fallback block to focus const prevBlock = index > 0 ? blocks[index - 1] : nextBlocks[0]; persistDocument({ ...selectedDoc, blocks: nextBlocks }); if (prevBlock) { setLastFocusedBlockId(prevBlock.id); setTimeout(() => { const el = blockInputRefs.current[prevBlock.id]; if (el) { el.focus(); if ('setSelectionRange' in el && typeof el.value === 'string') { el.setSelectionRange(el.value.length, el.value.length); } } }, 30); } return true; }; // Delete block and focus the preceding one const deleteBlockAndFocusPrev = (index: number) => { if (isReadOnly || !selectedDoc) return; const targetBlock = selectedDoc.blocks[index]; if (!targetBlock) return; removeBlock(targetBlock.id); }; const updateBlock = (blockId: string, patch: Partial) => { if (isReadOnly || !selectedDoc) return; const nextBlocks = selectedDoc.blocks.map((b) => b.id === blockId ? { ...b, ...patch } : b ); persistDocument({ ...selectedDoc, blocks: nextBlocks }); }; const toggleCollapse = (blockId: string) => { if (!selectedDoc) return; const block = selectedDoc.blocks.find((b) => b.id === blockId); if (block) { updateBlock(blockId, { collapsed: !block.collapsed }); } }; // Keyboard Navigation: Enter, Tab, Shift+Tab, Backspace, / Slash Commands const handleKeyDown = ( e: React.KeyboardEvent, block: DocumentBlock, index: number ) => { if (isReadOnly) return; ensureLock(); if (slashBlockId === block.id) { if (filteredSlashCommands.length === 0) { if (e.key === 'Escape') { setSlashBlockId(null); } return; } if (e.key === 'ArrowDown') { e.preventDefault(); setSlashSelectedIndex((prev) => (prev + 1) % filteredSlashCommands.length); return; } if (e.key === 'ArrowUp') { e.preventDefault(); setSlashSelectedIndex((prev) => prev === 0 ? filteredSlashCommands.length - 1 : prev - 1 ); return; } if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); const selected = filteredSlashCommands[slashSelectedIndex]; if (selected) { applySlashCommand(block.id, selected); } return; } if (e.key === 'Escape') { setSlashBlockId(null); return; } } if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); setSlashBlockId(null); const target = e.target as HTMLTextAreaElement | HTMLInputElement; let afterContent = ''; if (target && typeof target.selectionStart === 'number') { const cursor = target.selectionStart; const fullContent = block.content || ''; const before = fullContent.slice(0, cursor); const after = fullContent.slice(cursor); if (after.length > 0) { updateBlock(block.id, { content: before }); afterContent = after; } } insertNewBulletAfter(index, block.level || 0, block.type === 'todo' ? 'todo' : 'bullet', afterContent); return; } else if (e.key === 'Tab') { e.preventDefault(); if (e.shiftKey) { outdentBlock(block.id); } else { indentBlock(index); } } else if (e.key === 'Backspace' && (!block.content || block.content === '')) { if ((block.level || 0) > 0) { e.preventDefault(); outdentBlock(block.id); } else if (selectedDoc?.blocks && selectedDoc.blocks.length > 1) { e.preventDefault(); setSlashBlockId(null); deleteBlockAndFocusPrev(index); } } }; const handleContentChange = (block: DocumentBlock, newContent: string) => { if (isReadOnly) return; ensureLock(); // Markdown shortcut: typing '---' creates a divider and inserts a new bullet below if (newContent.trim() === '---') { const blkIdx = selectedDoc ? selectedDoc.blocks.findIndex((b) => b.id === block.id) : -1; if (selectedDoc && blkIdx !== -1) { const curLevel = block.level || 0; const newBlockId = `blk_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; const newBullet: DocumentBlock = { id: newBlockId, type: 'bullet', content: '', level: curLevel, checked: false, }; const nextBlocks = [...selectedDoc.blocks]; nextBlocks[blkIdx] = { ...block, type: 'divider', content: '', }; nextBlocks.splice(blkIdx + 1, 0, newBullet); persistDocument({ ...selectedDoc, blocks: nextBlocks }); setSlashBlockId(null); focusBlock(newBlockId); return; } } updateBlock(block.id, { content: newContent }); // Check if user just typed '/' if (newContent.endsWith('/') || newContent.includes('/')) { const match = newContent.match(/\/([a-z0-9]*)$/i); if (match) { setSlashBlockId(block.id); setSlashSearch(match[1]); setSlashSelectedIndex(0); return; } } if (slashBlockId === block.id) { setSlashBlockId(null); } }; const filteredSlashCommands = useMemo(() => { if (!slashSearch) return SLASH_COMMANDS; const q = slashSearch.toLowerCase(); return SLASH_COMMANDS.filter( (c) => c.label.toLowerCase().includes(q) || c.desc.toLowerCase().includes(q) ); }, [slashSearch]); useEffect(() => { if (slashBlockId && slashMenuRef.current) { const container = slashMenuRef.current; const activeBtn = container.querySelector( `[data-slash-index="${slashSelectedIndex}"]` ); if (activeBtn) { const itemTop = activeBtn.offsetTop; const itemBottom = itemTop + activeBtn.offsetHeight; const containerTop = container.scrollTop; const containerBottom = containerTop + container.clientHeight; if (itemTop < containerTop) { container.scrollTop = itemTop; } else if (itemBottom > containerBottom) { container.scrollTop = itemBottom - container.clientHeight; } } } }, [slashSelectedIndex, slashBlockId]); const focusBlock = (blockId: string) => { setLastFocusedBlockId(blockId); const tryFocus = () => { const el = blockInputRefs.current[blockId]; if (el) { el.focus(); if ('setSelectionRange' in el && typeof (el as any).value === 'string') { const len = (el as any).value.length; (el as any).setSelectionRange(len, len); } return true; } return false; }; setTimeout(() => { if (!tryFocus()) { setTimeout(tryFocus, 30); } }, 20); }; const handleCanvasBottomClick = () => { if (isReadOnly || !selectedDoc) return; if (selectedDoc.blocks.length === 0) { insertNewBulletAfter(-1, 0); return; } const lastIndex = selectedDoc.blocks.length - 1; const lastBlock = selectedDoc.blocks[lastIndex]; if (lastBlock && lastBlock.type !== 'divider' && (!lastBlock.content || lastBlock.content.trim() === '')) { focusBlock(lastBlock.id); } else { insertNewBulletAfter(lastIndex, 0); } }; const applySlashCommand = (blockId: string, cmd: SlashCommandItem) => { if (isReadOnly || !selectedDoc) return; const block = selectedDoc.blocks.find((b) => b.id === blockId); if (!block) return; const cleanContent = block.content.replace(/\/([a-z0-9]*)$/i, '').trim(); setSlashBlockId(null); setSlashSearch(''); if (cmd.action) { updateBlock(blockId, { content: cleanContent }); setLinkModalBlockId(blockId); setLinkModalTab(cmd.action === 'insert_weblink' ? 'web' : 'note'); setLinkSearchQuery(''); setLinkWebTitle(''); setLinkWebUrl(''); return; } if (cmd.type) { if (cmd.type === 'divider') { const blkIdx = selectedDoc.blocks.findIndex((b) => b.id === blockId); const curLevel = block.level || 0; const newBlockId = `blk_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; const newBullet: DocumentBlock = { id: newBlockId, type: 'bullet', content: '', level: curLevel, checked: false, }; const nextBlocks = [...selectedDoc.blocks]; nextBlocks[blkIdx] = { ...block, type: 'divider', content: '', }; nextBlocks.splice(blkIdx + 1, 0, newBullet); persistDocument({ ...selectedDoc, blocks: nextBlocks }); focusBlock(newBlockId); return; } updateBlock(blockId, { type: cmd.type, content: cleanContent, checked: cmd.type === 'todo' ? false : undefined }); focusBlock(blockId); } }; const handleInsertNoteLink = (docTitle: string) => { if (!linkModalBlockId || !selectedDoc) return; const block = selectedDoc.blocks.find((b) => b.id === linkModalBlockId); if (block) { const space = block.content && !block.content.endsWith(' ') ? ' ' : ''; const newContent = `${block.content}${space}[[${docTitle}]] `; updateBlock(linkModalBlockId, { content: newContent }); } const targetBlockId = linkModalBlockId; setLinkModalBlockId(null); focusBlock(targetBlockId); }; const handleInsertWebLink = () => { if (!linkModalBlockId || !selectedDoc) return; const block = selectedDoc.blocks.find((b) => b.id === linkModalBlockId); let title = linkWebTitle.trim(); let url = linkWebUrl.trim(); if (!url) return; if (!/^https?:\/\//i.test(url)) { url = `https://${url}`; } if (!title) { try { title = new URL(url).hostname; } catch { title = url; } } if (block) { const space = block.content && !block.content.endsWith(' ') ? ' ' : ''; const newContent = `${block.content}${space}[${title}](${url}) `; updateBlock(linkModalBlockId, { content: newContent }); } const targetBlockId = linkModalBlockId; setLinkModalBlockId(null); setLinkWebTitle(''); setLinkWebUrl(''); focusBlock(targetBlockId); }; const getDirectChildTodos = (blocks: DocumentBlock[], parentIndex: number): DocumentBlock[] => { const parent = blocks[parentIndex]; if (!parent) return []; const parentLevel = parent.level || 0; const childTodos: DocumentBlock[] = []; for (let i = parentIndex + 1; i < blocks.length; i++) { const cur = blocks[i]; const curLevel = cur.level || 0; if (curLevel <= parentLevel) break; if (cur.type === 'todo') { childTodos.push(cur); } } return childTodos; }; // Drag & Drop Handlers on the Bullet Dot const handleDragStart = (e: React.DragEvent, blockId: string) => { e.dataTransfer.setData('text/plain', blockId); setDraggingBlockId(blockId); }; const handleDropOnBlock = (e: React.DragEvent, targetBlockId: string) => { e.preventDefault(); const sourceId = e.dataTransfer.getData('text/plain') || draggingBlockId; if (!selectedDoc || !sourceId || sourceId === targetBlockId) { setDraggingBlockId(null); return; } const blocks = [...selectedDoc.blocks]; const sourceIdx = blocks.findIndex((b) => b.id === sourceId); const targetIdx = blocks.findIndex((b) => b.id === targetBlockId); if (sourceIdx === -1 || targetIdx === -1) { setDraggingBlockId(null); return; } const [moved] = blocks.splice(sourceIdx, 1); // Align moved block's level to target moved.level = blocks[targetIdx]?.level || 0; blocks.splice(targetIdx, 0, moved); persistDocument({ ...selectedDoc, blocks }, true); setDraggingBlockId(null); }; // Zoom-in / Hoisting Calculation const { visibleBlocks, breadcrumbBlocks, zoomedBlock } = useMemo(() => { if (!selectedDoc) return { visibleBlocks: [], breadcrumbBlocks: [], zoomedBlock: null }; const all = selectedDoc.blocks; if (!zoomedBlockId) { // Normal view: filter out children of collapsed blocks const list: { block: DocumentBlock; originalIndex: number }[] = []; let hideUntilLevel = -1; for (let i = 0; i < all.length; i++) { const b = all[i]; const lvl = b.level || 0; if (hideUntilLevel !== -1) { if (lvl > hideUntilLevel) { continue; // Skip collapsed child } else { hideUntilLevel = -1; // End of collapsed subtree } } list.push({ block: b, originalIndex: i }); if (b.collapsed) { hideUntilLevel = lvl; } } return { visibleBlocks: list, breadcrumbBlocks: [], zoomedBlock: null }; } // Zoomed view: find zoomed block and its descendants const zoomIdx = all.findIndex((b) => b.id === zoomedBlockId); if (zoomIdx === -1) { return { visibleBlocks: [], breadcrumbBlocks: [], zoomedBlock: null }; } const target = all[zoomIdx]; const targetLevel = target.level || 0; // Build breadcrumbs: find parents with strictly decreasing levels const crumbs: DocumentBlock[] = []; let curLevel = targetLevel; for (let i = zoomIdx - 1; i >= 0; i--) { const b = all[i]; const lvl = b.level || 0; if (lvl < curLevel) { crumbs.unshift(b); curLevel = lvl; } } // Descendants of zoomed block const descendants: { block: DocumentBlock; originalIndex: number }[] = []; for (let i = zoomIdx + 1; i < all.length; i++) { const b = all[i]; if ((b.level || 0) > targetLevel) { descendants.push({ block: b, originalIndex: i }); } else { break; } } return { visibleBlocks: descendants, breadcrumbBlocks: crumbs, zoomedBlock: target }; }, [selectedDoc, zoomedBlockId]); // Export full document as portable Logseq-indented Markdown const handleExportMarkdown = () => { if (!selectedDoc) return; const lines: string[] = [`# ${selectedDoc.icon} ${selectedDoc.title}`, '']; for (const b of selectedDoc.blocks) { const indent = ' '.repeat(b.level || 0); switch (b.type) { case 'h1': lines.push(`${indent}- # ${b.content}`); break; case 'h2': lines.push(`${indent}- ## ${b.content}`); break; case 'h3': lines.push(`${indent}- ### ${b.content}`); break; case 'todo': lines.push(`${indent}- [${b.checked ? 'x' : ' '}] ${b.content}`); break; case 'callout': lines.push(`${indent}- > 💡 ${b.content}`); break; case 'code': lines.push(`${indent}- \`\`\`${b.lang || ''}\n${indent} ${b.content}\n${indent} \`\`\``); break; case 'table': case 'bullet': case 'paragraph': default: lines.push(`${indent}- ${b.content}`); } } const blob = new Blob([lines.join('\n')], { type: 'text/markdown;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${selectedDoc.title.toLowerCase().replace(/[^a-z0-9äöüß]+/g, '-')}.md`; a.click(); URL.revokeObjectURL(url); }; // Helper to render text with clickable Wiki-Links, Web-Links and Hashtags const renderFormattedText = (text: string) => { const parts = text.split(/(\[\[.*?\]\]|\[.*?\]\(https?:\/\/[^\s)]+\)|#[a-zA-Z0-9äöüÄÖÜß_-]+)/g); return parts.map((part, i) => { if (part.startsWith('[[') && part.endsWith(']]')) { const linkTitle = part.slice(2, -2).trim(); const target = docs.find((d) => d.title.toLowerCase() === linkTitle.toLowerCase()); return ( { e.stopPropagation(); if (target) { setSelectedDoc(target); setZoomedBlockId(null); setWorkspaceMode('editor'); } }} className={`inline-flex items-center gap-1 px-1.5 py-0.2 mx-0.5 border text-xs font-medium cursor-pointer rounded-btn transition-colors ${ target ? 'bg-accent/20 border-accent/40 text-accent hover:bg-accent/30' : 'badge-neutral hover:text-main' }`} title={target ? `Öffne: ${target.title}` : `Dokument "${linkTitle}" existiert noch nicht`} > 📄 {linkTitle} ); } const webMatch = part.match(/^\[(.*?)\]\((https?:\/\/[^\s)]+)\)$/); if (webMatch) { const title = webMatch[1] || webMatch[2]; const url = webMatch[2]; return ( e.stopPropagation()} className="inline-flex items-center gap-1 px-1.5 py-0.2 mx-0.5 bg-sky-500/10 border border-sky-500/30 text-sky-500 dark:text-sky-400 hover:bg-sky-500/20 text-xs font-medium rounded-btn transition-colors" title={`Öffne: ${url}`} > 🌐 {title} ); } if (part.startsWith('#') && part.length > 1) { const tagWord = part.slice(1); return ( { e.stopPropagation(); openTagPage(tagWord); }} className="inline-flex items-center gap-0.5 px-1.5 py-0.2 mx-0.5 bg-blue-500/10 border border-blue-500/30 text-blue-500 dark:text-blue-400 hover:bg-blue-500/20 text-xs font-medium cursor-pointer rounded-btn transition-colors" title={`Öffne Hashtag-Seite: #${tagWord}`} > {tagWord} ); } return {part}; }); }; // --- GLOBAL TO-DO ROLLUP AGGREGATOR --- const allWorkspaceTodos = useMemo(() => { const items: { docId: string; docTitle: string; docIcon: string; folder: string; block: DocumentBlock; }[] = []; docs.forEach((doc) => { const folder = getDocFolder(doc) || 'Root (Unsortiert)'; (doc.blocks || []).forEach((b) => { if (b.type === 'todo' || (b.content && b.content.startsWith('- [ ]'))) { items.push({ docId: doc.id, docTitle: doc.title, docIcon: doc.icon || '📄', folder, block: b, }); } }); }); return items; }, [docs]); const filteredTodos = useMemo(() => { return allWorkspaceTodos.filter((item) => { if (todoFilter === 'open' && item.block.checked) return false; if (todoFilter === 'done' && !item.block.checked) return false; if (todoFolderDrilldown && item.folder !== todoFolderDrilldown) return false; return true; }); }, [allWorkspaceTodos, todoFilter, todoFolderDrilldown]); // Group filtered todos by folder -> docTitle const todosByFolder = useMemo(() => { const map = new Map(); filteredTodos.forEach((item) => { const existing = map.get(item.folder) || []; existing.push(item); map.set(item.folder, existing); }); return map; }, [filteredTodos]); // Direct toggle of a To-Do item in the rollup dashboard const handleToggleRollupTodo = async (docId: string, blockId: string) => { triggerHaptic(15); const targetDoc = docs.find((d) => d.id === docId); if (!targetDoc) return; const updatedBlocks = targetDoc.blocks.map((b) => b.id === blockId ? { ...b, checked: !b.checked } : b ); const updatedDoc = { ...targetDoc, blocks: updatedBlocks }; // Update in local state setDocs((prev) => prev.map((d) => (d.id === docId ? updatedDoc : d))); if (selectedDoc?.id === docId) { setSelectedDoc(updatedDoc); } // Also update tagDetails if currently viewing tag page setTagDetails((prev) => { if (!prev) return prev; return { ...prev, documents: prev.documents.map((dg) => { if (dg.docId !== docId) return dg; return { ...dg, matchedBlocks: dg.matchedBlocks.map((mb) => ({ rootBlock: mb.rootBlock.id === blockId ? { ...mb.rootBlock, checked: !mb.rootBlock.checked } : mb.rootBlock, children: mb.children.map((c) => c.id === blockId ? { ...c, checked: !c.checked } : c ), })), }; }), }; }); // Persist immediately to backend & disk await api.updateDocument(docId, { blocks: updatedBlocks, }); }; // Jump directly to document from rollup const handleJumpToDoc = (docId: string) => { const found = docs.find((d) => d.id === docId); if (found) { setSelectedDoc(found); setZoomedBlockId(null); setWorkspaceMode('editor'); } }; // --- FOLDER TREE CALCULATION --- const filteredDocs = useMemo(() => { if (!searchQuery.trim()) return docs; const q = searchQuery.toLowerCase(); return docs.filter( (d) => d.title.toLowerCase().includes(q) || d.content.toLowerCase().includes(q) ); }, [docs, searchQuery]); const { rootTree, rootDocs } = useMemo(() => { const folderMap = new Map(); allFolderPaths.forEach((path) => { const parts = path.split('/'); const name = parts[parts.length - 1]; folderMap.set(path, { path, name, depth: parts.length - 1, subfolders: [], docs: [], }); }); filteredDocs.forEach((doc) => { const folder = getDocFolder(doc); if (folder && folderMap.has(folder)) { folderMap.get(folder)!.docs.push(doc); } }); const rootFolders: FolderTreeItem[] = []; allFolderPaths.forEach((path) => { const item = folderMap.get(path)!; const lastSlash = path.lastIndexOf('/'); if (lastSlash === -1) { rootFolders.push(item); } else { const parentPath = path.substring(0, lastSlash); const parent = folderMap.get(parentPath); if (parent) { parent.subfolders.push(item); } else { rootFolders.push(item); } } }); const rootOnlyDocs = filteredDocs.filter((d) => !getDocFolder(d)); return { rootTree: rootFolders, rootDocs: rootOnlyDocs }; }, [allFolderPaths, filteredDocs]); const pinnedDocs = useMemo(() => { return docs.filter((d) => d.isPinned); }, [docs]); const renderDocItem = (doc: Document, depth: number) => { const isSelected = selectedDoc?.id === doc.id && workspaceMode === 'editor'; const isDragging = draggingDocId === doc.id; return (
{ e.stopPropagation(); e.dataTransfer.setData('application/mindsift-doc-id', doc.id); e.dataTransfer.setData('text/plain', doc.id); setDraggingDocId(doc.id); }} onDragEnd={() => setDraggingDocId(null)} onClick={() => { setSelectedDoc(doc); setZoomedBlockId(null); setWorkspaceMode('editor'); }} style={{ paddingLeft: `${depth * 14 + 16}px` }} className={`group flex items-center justify-between py-1.5 pr-2 border-l-2 transition-all cursor-pointer text-xs ${ isDragging ? 'opacity-40 scale-[0.98]' : '' } ${ isSelected ? 'bg-panel-elevated border-accent text-main font-medium' : 'border-transparent text-muted hover:text-main hover:bg-canvas/50' }`} title={`${doc.title || 'Notiz'} (Ziehen zum Verschieben in Ordner)`} >
{doc.icon || '📄'} {doc.title || 'Unbenannt'} {doc.isPinned && } {doc.isOwner === false && ( )}
); }; const renderFolder = (node: FolderTreeItem): React.ReactNode => { const isExpanded = expandedFolders.has(node.path); const isCreatingSubfolder = isCreatingFolder && newFolderParent === node.path; const isDragOver = dragOverFolder === node.path; const isDraggingThisFolder = draggingFolderPath === node.path; const totalItems = node.docs.length + node.subfolders.length; return (
{ if ( draggingDocId || (draggingFolderPath && draggingFolderPath !== node.path && !node.path.startsWith(draggingFolderPath + '/')) ) { e.preventDefault(); e.stopPropagation(); setDragOverFolder(node.path); } }} onDragLeave={(e) => { e.preventDefault(); if (dragOverFolder === node.path) { setDragOverFolder(null); } }} onDrop={(e) => { e.preventDefault(); e.stopPropagation(); const droppedFolder = e.dataTransfer.getData('application/mindsift-folder-path') || draggingFolderPath; setDragOverFolder(null); setDraggingDocId(null); setDraggingFolderPath(null); if (droppedFolder) { handleMoveFolder(droppedFolder, node.path); } else { handleDropDocToFolder(node.path, 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)} 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 ${ isDraggingThisFolder ? 'opacity-40 scale-[0.98]' : '' } ${ isDragOver ? 'bg-accent/20 border-l-2 border-accent text-main font-medium' : 'hover:bg-panel-elevated text-muted' }`} title={`${node.path} (Klick: Öffnen/Schließen · Ziehen: In anderen Ordner verschieben)`} >
{isExpanded ? ( ) : ( )} {isExpanded ? ( ) : ( )} {node.name} ({totalItems})
{isCreatingSubfolder && (
setNewFolderName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleConfirmCreateFolder(); if (e.key === 'Escape') setIsCreatingFolder(false); }} placeholder="Unterordner…" className="flex-1 h-6 px-1.5 input-field text-xs" />
)} {isExpanded && (
{node.subfolders.map(renderFolder)} {node.docs.map((doc) => renderDocItem(doc, node.depth + 1))} {node.subfolders.length === 0 && node.docs.length === 0 && !isCreatingSubfolder && (
(leer)
)}
)}
); }; const isEmpty = docs.length === 0 && allFolderPaths.length === 0; return (
{/* Top Navigation Mode Switcher: Notizen & Outliner vs. Globales To-Do Dashboard */}
{selectedTag && ( )}
{workspaceMode === 'editor' && selectedDoc && ( <> {saveState === 'saving' ? 'Speichert…' : 'Gespeichert'} )}
{/* ===================================================================== */} {/* VIEW MODE 1: LOGSEQ OUTLINER & EXPLORER */} {/* ===================================================================== */} {workspaceMode === 'editor' && (
{/* Left Document & Folder Explorer Tree */}
setSearchQuery(e.target.value)} placeholder="Dateien durchsuchen…" className="w-full h-7 pl-7 pr-2 input-field text-xs" />
{isCreatingFolder && newFolderParent === null && (
setNewFolderName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleConfirmCreateFolder(); if (e.key === 'Escape') setIsCreatingFolder(false); }} placeholder="Neuer Ordnername…" className="flex-1 h-6 px-1.5 input-field text-xs" />
)} {isEmpty ? (
Keine Dateien vorhanden

Erstelle eine neue Datei oder lege Ordner an, um deine Dokumente übersichtlich zu strukturieren.

) : (
{/* Drag target for Root when dragging a note or a folder */} {(draggingDocId || draggingFolderPath) && (
{ e.preventDefault(); e.stopPropagation(); setDragOverFolder('__root__'); }} onDragLeave={(e) => { e.preventDefault(); if (dragOverFolder === '__root__') setDragOverFolder(null); }} onDrop={(e) => { e.preventDefault(); e.stopPropagation(); const droppedFolder = e.dataTransfer.getData('application/mindsift-folder-path') || draggingFolderPath; setDragOverFolder(null); setDraggingDocId(null); 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 ${ dragOverFolder === '__root__' ? 'border-accent bg-accent/20 text-main font-semibold' : 'border-border text-muted bg-canvas' }`} > {draggingFolderPath ? '📁 Ordner in Hauptverzeichnis verschieben' : '📁 In Hauptverzeichnis verschieben (ohne Ordner)'}
)} {/* Angepinnte Notizen (Favorites) */} {pinnedDocs.length > 0 && !searchQuery && (
Angepinnte Notizen ({pinnedDocs.length})
{pinnedDocs.map((doc) => { const isSelected = selectedDoc?.id === doc.id && workspaceMode === 'editor'; return (
{ e.stopPropagation(); e.dataTransfer.setData('application/mindsift-doc-id', doc.id); e.dataTransfer.setData('text/plain', doc.id); setDraggingDocId(doc.id); }} onDragEnd={() => setDraggingDocId(null)} onClick={() => { setSelectedDoc(doc); setZoomedBlockId(null); setWorkspaceMode('editor'); }} className={`group flex items-center justify-between px-2 py-1.5 rounded-btn transition-colors cursor-pointer text-xs ${ isSelected ? 'bg-panel-elevated border-l-2 border-accent text-main font-medium' : 'text-muted hover:text-main hover:bg-canvas/50' }`} title="Angepinnte Notiz öffnen" >
{doc.icon || '📄'} {doc.title || 'Unbenannt'}
); })}
)} {rootTree.map(renderFolder)} {rootDocs.map((doc) => renderDocItem(doc, 0))} {/* Workspace Hashtags Section */} {workspaceTags.length > 0 && !searchQuery && (
Themen & Hashtags ({workspaceTags.length})
{workspaceTags.map((wt) => ( ))}
)} {filteredDocs.length === 0 && searchQuery && (
Keine Dateien gefunden für "{searchQuery}"
)}
)}
{/* Right Logseq Outliner Editor */}
{selectedDoc ? (
{ if (e.target === e.currentTarget) { handleCanvasBottomClick(); } }} > {/* Document Header & Metadata */}
{selectedDoc.folderPath || 'Hauptverzeichnis'}
• {selectedDoc.blocks.length} Bullets {selectedDoc.isOwner === false && ( )} {selectedDoc.permission === 'read' && ( )} {holdsLock && ( Schreibzugriff aktiv )} {isLockedByOther && ( Wird bearbeitet ({activeLock?.userName || 'anderer Nutzer'}) )} {selectedDoc.sharedWith && selectedDoc.sharedWith.length > 0 && selectedDoc.isOwner !== false && ( )}
{selectedDoc.isOwner !== false ? ( ) : ( )}
{/* LOCK WARNING BANNER */} {isLockedByOther && (
Schreibgeschützt
Wird aktuell bearbeitet von {activeLock?.userName || 'einem anderen Benutzer'}. Eingaben sind vorübergehend gesperrt.
)} {!isLockedByOther && selectedDoc.permission === 'read' && (
Du hast Nur-Lesezugriff auf diese Notiz. Änderungen können nicht vorgenommen werden.
)} {/* ZOOM BREADCRUMB BAR (Hoisting Focus Mode) */} {zoomedBlock && (
{breadcrumbBlocks.map((b) => ( ))} {zoomedBlock.content || 'Aktiver Ast'}
)} {/* Document Icon & Title (when not zoomed) */} {!zoomedBlock && (
{showIconPicker && !isReadOnly && (
{ICONS.map((ic) => ( ))}
)} { if (!isReadOnly) { ensureLock(); persistDocument({ ...selectedDoc, title: e.target.value }); } }} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); if (selectedDoc.blocks.length > 0) { const firstId = selectedDoc.blocks[0].id; blockInputRefs.current[firstId]?.focus(); } else if (!isReadOnly) { insertNewBulletAfter(-1, 0); } } }} placeholder="Dokument-Titel…" className={`w-full bg-transparent text-xl sm:text-2xl font-bold text-main focus:outline-none placeholder:text-faint ${ isReadOnly ? 'cursor-default' : '' }`} />
)} {/* If zoomed, show zoomed block as editable prominent header */} {zoomedBlock && (
updateBlock(zoomedBlock.id, { content: e.target.value })} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); const zoomIdx = selectedDoc.blocks.findIndex((b) => b.id === zoomedBlock.id); if (zoomIdx !== -1) { insertNewBulletAfter(zoomIdx, (zoomedBlock.level || 0) + 1); } } }} className="w-full bg-transparent text-xl font-bold text-main focus:outline-none placeholder:text-faint" />
)} {/* =========================================================== */} {/* LOGSEQ HIERARCHICAL BULLET LIST CANVAS */} {/* =========================================================== */}
{ if (e.target === e.currentTarget) { handleCanvasBottomClick(); } }} > {visibleBlocks.map(({ block, originalIndex }) => { const blockLevel = Math.max(0, (block.level || 0) - (zoomedBlock ? (zoomedBlock.level || 0) + 1 : 0)); const isChildrenPresent = hasChildren(selectedDoc.blocks, originalIndex); return (
{/* Drag Handle on hover */}
{/* Collapse Chevron if block has children */} {isChildrenPresent ? ( ) : (
)} {/* Interactive Bullet Dot (Click: Zoom-in! Drag: Reorder!) */}
!isReadOnly && handleDragStart(e, block.id)} onDragOver={(e) => e.preventDefault()} onDrop={(e) => !isReadOnly && handleDropOnBlock(e, block.id)} onClick={() => setZoomedBlockId(block.id)} className={`w-4 h-4 mt-1 rounded-pill flex items-center justify-center transition-colors flex-shrink-0 ${ isReadOnly ? 'cursor-pointer text-muted' : 'hover:bg-accent/20 cursor-pointer text-muted hover:text-accent' }`} title="Klick: In diesen Ast zoomen · Ziehen: Verschieben" >
{/* Block Content Container */}
{/* TYPE: TODO */} {block.type === 'todo' && (
{ if (!isReadOnly && selectedDoc) { const nextBlocks = selectedDoc.blocks.map((b) => b.id === block.id ? { ...b, checked: e.target.checked } : b ); persistDocument({ ...selectedDoc, blocks: nextBlocks }, true); onStatsChange(); } }} className={`w-4 h-4 checkbox-square flex-shrink-0 ${ isReadOnly ? 'cursor-not-allowed opacity-60' : 'cursor-pointer' }`} /> (blockInputRefs.current[block.id] = el)} type="text" readOnly={isReadOnly} value={block.content} onFocus={handleBlockFocus} onBlur={handleBlockBlur} onChange={(e) => handleContentChange(block, e.target.value)} onKeyDown={(e) => handleKeyDown(e, block, originalIndex)} className={`w-full bg-transparent text-sm focus:outline-none ${ block.checked ? 'line-through text-faint' : 'text-main' } ${isReadOnly ? 'cursor-default' : ''}`} /> {!isReadOnly && ( )}
{(block.content.includes('[[') || block.content.includes('#') || (block.content.includes('[') && block.content.includes(']('))) && (
{renderFormattedText(block.content)}
)} {/* Progress bar for child todos if any exist */} {(() => { const childTodos = getDirectChildTodos(selectedDoc?.blocks || [], originalIndex); if (childTodos.length === 0) return null; const doneChild = childTodos.filter((c) => c.checked).length; const totalChild = childTodos.length; const pct = Math.round((doneChild / totalChild) * 100); return (
{doneChild}/{totalChild} erledigt {pct}%
); })()}
)} {/* TYPE: H1 */} {block.type === 'h1' && ( (blockInputRefs.current[block.id] = el)} type="text" readOnly={isReadOnly} value={block.content} onFocus={handleBlockFocus} onBlur={handleBlockBlur} onChange={(e) => handleContentChange(block, e.target.value)} onKeyDown={(e) => handleKeyDown(e, block, originalIndex)} className={`w-full bg-transparent text-lg font-bold text-main focus:outline-none ${ isReadOnly ? 'cursor-default' : '' }`} /> )} {/* TYPE: H2 */} {block.type === 'h2' && ( (blockInputRefs.current[block.id] = el)} type="text" readOnly={isReadOnly} value={block.content} onFocus={handleBlockFocus} onBlur={handleBlockBlur} onChange={(e) => handleContentChange(block, e.target.value)} onKeyDown={(e) => handleKeyDown(e, block, originalIndex)} className={`w-full bg-transparent text-base font-semibold text-accent focus:outline-none ${ isReadOnly ? 'cursor-default' : '' }`} /> )} {/* TYPE: H3 */} {block.type === 'h3' && ( (blockInputRefs.current[block.id] = el)} type="text" readOnly={isReadOnly} value={block.content} onFocus={handleBlockFocus} onBlur={handleBlockBlur} onChange={(e) => handleContentChange(block, e.target.value)} onKeyDown={(e) => handleKeyDown(e, block, originalIndex)} className={`w-full bg-transparent text-sm font-semibold text-main focus:outline-none ${ isReadOnly ? 'cursor-default' : '' }`} /> )} {/* TYPE: CALLOUT */} {block.type === 'callout' && (