import React, { useEffect, useLayoutEffect, 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, Hash, User as UserIcon, Radio, } from 'lucide-react'; import { BlockType, Document, DocumentBlock, DocBlocksPatchEvent, DocLockInfo, DocShare, FolderShare, SharedFolder, TagDetails, TagSummary, Team, User } from '../../lib/types'; import { api, getToken, triggerHaptic, onWebSocketStatus, isWebSocketConnected, sendWebSocketMessage, createRealtimeConnection, RealtimeTransport } from '../../lib/api'; import { CodeBlockItem } from './CodeBlockItem'; import { UserAutocomplete } from '../UserAutocomplete'; import { UnifiedShareModal } from '../../components/UnifiedShareModal'; import { useMobileActions } from '../../context/MobileActionContext'; import { DocumentCRDTSession, CollaboratorAwareness, getCollaboratorColor } from '../../lib/crdt'; interface WorkspaceViewProps { currentUser?: User | null; initialDocId?: string | null; onStatsChange: () => void; } const ICONS = ['📄', '📝', '💡', '🚀', '🏡', '⚡', '🎯', '🍕', '🛠️', '🧠', '🔥', '🏔️', '📦', '📑', '⚙️', '📊']; interface FolderTreeItem { path: string; name: string; depth: number; subfolders: FolderTreeItem[]; docs: Document[]; sharedFolder?: SharedFolder; isShared?: boolean; isSharedRoot?: boolean; } 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, }) => { const { setGroups, setPrimaryAction } = useMobileActions(); const [showMobileFileDrawer, setShowMobileFileDrawer] = useState(false); // Top Workspace Tab Mode: 'editor' vs 'todos' vs 'tags' const [workspaceMode, setWorkspaceMode] = useState<'editor' | 'todos' | 'tags'>('editor'); const [selectedTag, setSelectedTag] = useState(null); const [tagDetails, setTagDetails] = useState(null); const [workspaceTags, setWorkspaceTags] = useState([]); const [isLoadingTag, setIsLoadingTag] = useState(false); const [tagSearchQuery, setTagSearchQuery] = useState(''); const [tagNoteContent, setTagNoteContent] = useState(''); const [tagNoteSaveState, setTagNoteSaveState] = useState<'saved' | 'saving'>('saved'); const tagNoteSaveTimerRef = useRef(null); // Collapsible sidebar sections const [isPinnedCollapsed, setIsPinnedCollapsed] = useState(false); const [isTagsCollapsed, setIsTagsCollapsed] = 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); // Realtime connection & status for live indicator const realtimeConnRef = useRef(null); const [wsConnected, setWsConnected] = useState(false); const [realtimeTransport, setRealtimeTransport] = useState('none'); const [wsDisconnectReason, setWsDisconnectReason] = useState(null); const onStatsChangeRef = useRef(onStatsChange); onStatsChangeRef.current = onStatsChange; const lastLocalEditTimestampRef = useRef(0); const isSavingToBackendRef = useRef(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(''); const [activeEditingBlockId, setActiveEditingBlockId] = useState(null); const [activeCursorOffset, setActiveCursorOffset] = useState(0); const activeCursorOffsetRef = useRef(0); const [editingLinkOriginalRaw, setEditingLinkOriginalRaw] = useState(null); const setBlockCursor = (blockId: string | null, offset: number) => { activeCursorOffsetRef.current = offset; setActiveEditingBlockId(blockId); setActiveCursorOffset(offset); }; const autoResizeTextarea = (el: HTMLTextAreaElement | null) => { if (!el) return; el.style.height = 'auto'; el.style.height = `${el.scrollHeight}px`; }; // Drag and Drop reordering state for blocks const [draggingBlockId, setDraggingBlockId] = useState(null); const [dragOverBlockTarget, setDragOverBlockTarget] = useState<{ blockId: string; position: 'before' | 'after' | 'inside' } | null>(null); // Modal for deleting block with sub-bulletpoints (X button) const [deleteSubpointsModal, setDeleteSubpointsModal] = useState<{ blockId: string; count: number; snippet: string; } | null>(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 [shareTeams, setShareTeams] = useState([]); const [addShareType, setAddShareType] = useState<'team' | 'user'>('team'); const [selectedShareTeamId, setSelectedShareTeamId] = useState(''); const [selectedShareUser, setSelectedShareUser] = useState(null); const [sharePermission, setSharePermission] = useState<'read' | 'write'>('read'); const [sharingLoading, setSharingLoading] = useState(false); const [shareError, setShareError] = useState(null); // --- FOLDER SHARING STATE --- const [sharedFolders, setSharedFolders] = useState([]); const [sharingFolder, setSharingFolder] = useState(null); const [showFolderShareModal, setShowFolderShareModal] = useState(false); const [folderShares, setFolderShares] = useState([]); // --- DOCUMENT CRDT & COLLABORATION STATE --- const crdtSessionRef = useRef(null); const [activePeers, setActivePeers] = useState([]); // --- DOCUMENT LOCKING STATE (FALLBACK) --- 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 isDraggingBlockRef = useRef(false); const blockInputRefs = useRef>({}); // With CRDT, multiple users can write simultaneously without lockouts const isLockedByOther = false; const isReadOnly = selectedDoc?.permission === 'read'; // Track server-synced blocks & metadata per document, plus local unsaved block deltas const syncedDocMetaRef = useRef>(new Map()); const syncedBlocksRef = useRef>>(new Map()); const syncedOrderRef = useRef>(new Map()); const localDirtyBlocksRef = useRef>>(new Map()); const localDeletedBlocksRef = useRef>>(new Map()); const blocksAreEqual = (a?: DocumentBlock, b?: DocumentBlock): boolean => { if (!a || !b) return false; return ( a.id === b.id && (a.type || 'bullet') === (b.type || 'bullet') && (a.content || '') === (b.content || '') && (a.level || 0) === (b.level || 0) && !!a.checked === !!b.checked && !!a.collapsed === !!b.collapsed && (a.lang || '') === (b.lang || '') ); }; const recordSyncedDoc = (doc: Document) => { if (!doc || !doc.id) return; syncedDocMetaRef.current.set(doc.id, { title: doc.title || '', icon: doc.icon || '📄', tagsJson: JSON.stringify((doc.tags || []).filter((t) => !t.startsWith('folder:'))), isPinned: !!doc.isPinned, }); const bMap = new Map(); const order: string[] = []; (doc.blocks || []).forEach((b) => { bMap.set(b.id, { ...b }); order.push(b.id); }); syncedBlocksRef.current.set(doc.id, bMap); syncedOrderRef.current.set(doc.id, order); }; const mergeIncomingBlocks = ( docId: string, currentBlocks: DocumentBlock[], patch: { upserted?: DocumentBlock[]; deletedIds?: string[]; blockOrder?: string[] } ): DocumentBlock[] => { const dirtyMap = localDirtyBlocksRef.current.get(docId); const localDeleted = localDeletedBlocksRef.current.get(docId); // Update server baseline refs let srvMap = syncedBlocksRef.current.get(docId); if (!srvMap) { srvMap = new Map(); currentBlocks.forEach((b) => srvMap!.set(b.id, { ...b })); syncedBlocksRef.current.set(docId, srvMap); } const deletedSet = new Set(patch.deletedIds || []); deletedSet.forEach((delId) => srvMap!.delete(delId)); (patch.upserted || []).forEach((b) => srvMap!.set(b.id, { ...b })); if (patch.blockOrder && patch.blockOrder.length > 0) { syncedOrderRef.current.set(docId, [...patch.blockOrder]); } // Build merged block map preserving any block with unsaved local edits const blockMap = new Map(); currentBlocks.forEach((b) => { if (!deletedSet.has(b.id) || dirtyMap?.has(b.id)) { blockMap.set(b.id, b); } }); (patch.upserted || []).forEach((b) => { if (localDeleted?.has(b.id)) { return; } if (dirtyMap?.has(b.id)) { blockMap.set(b.id, dirtyMap.get(b.id)!); } else { blockMap.set(b.id, b); } }); // Reconcile ordering const mergedOrder: string[] = []; const seen = new Set(); if (patch.blockOrder && patch.blockOrder.length > 0) { patch.blockOrder.forEach((id) => { if (blockMap.has(id) && !seen.has(id) && !localDeleted?.has(id)) { mergedOrder.push(id); seen.add(id); } }); // Preserve any locally created unsaved blocks in their relative position currentBlocks.forEach((b, idx) => { if (!seen.has(b.id) && blockMap.has(b.id) && !deletedSet.has(b.id)) { let insertIdx = 0; for (let j = idx - 1; j >= 0; j--) { const predPos = mergedOrder.indexOf(currentBlocks[j].id); if (predPos !== -1) { insertIdx = predPos + 1; break; } } mergedOrder.splice(insertIdx, 0, b.id); seen.add(b.id); } }); } else { currentBlocks.forEach((b) => { if (blockMap.has(b.id) && !seen.has(b.id)) { mergedOrder.push(b.id); seen.add(b.id); } }); (patch.upserted || []).forEach((b) => { if (blockMap.has(b.id) && !seen.has(b.id)) { mergedOrder.push(b.id); seen.add(b.id); } }); } return mergedOrder.map((id) => blockMap.get(id)!).filter(Boolean); }; 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 (force = false): Promise => { if (!selectedDoc || selectedDoc.permission === 'read') return false; if (holdsLockRef.current && !force) { 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); return false; } } catch (e) { console.error('Failed to acquire lock', e); } return false; }; const broadcastAwareness = (blockId: string | null) => { if (!selectedDoc) return; const payload = { userId: currentUser?.id, userName: currentUser?.displayName || currentUser?.username || 'Kollege', activeBlockId: blockId, color: getCollaboratorColor(currentUser?.id || ''), lastActive: Date.now(), }; sendWebSocketMessage({ event: 'CRDT_AWARENESS', docId: selectedDoc.id, payload, }); }; const handleBlockFocus = (blockIdOrEvent?: string | React.FocusEvent) => { const blockId = typeof blockIdOrEvent === 'string' ? blockIdOrEvent : undefined; isEditorFocusedRef.current = true; if (idleUnlockTimerRef.current) { clearTimeout(idleUnlockTimerRef.current); idleUnlockTimerRef.current = null; } if (blockId) { broadcastAwareness(blockId); } if (!isReadOnly) { ensureLock(); } }; const handleBlockBlur = () => { isEditorFocusedRef.current = false; broadcastAwareness(null); if (idleUnlockTimerRef.current) { clearTimeout(idleUnlockTimerRef.current); } if (isDraggingBlockRef.current) { return; } idleUnlockTimerRef.current = setTimeout(async () => { if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) { await flushPendingSave(); await releaseMyLock(); } }, 8000); }; // Debounced auto-save refs (500ms delay) const debounceSaveTimerRef = useRef(null); const pendingSaveDocRef = useRef(null); const executeSaveToBackend = async (docToSave: Document) => { if (isReadOnly) return; let saveSucceeded = false; isSavingToBackendRef.current = true; try { const docId = docToSave.id; const dirtyMap = localDirtyBlocksRef.current.get(docId); const deletedSet = localDeletedBlocksRef.current.get(docId); const srvBlocks = syncedBlocksRef.current.get(docId); const srvOrder = syncedOrderRef.current.get(docId) || []; const upsertedMap = new Map(); if (dirtyMap) { dirtyMap.forEach((b, id) => upsertedMap.set(id, b)); } (docToSave.blocks || []).forEach((b) => { const srv = srvBlocks?.get(b.id); if (!srv || !blocksAreEqual(b, srv)) { upsertedMap.set(b.id, b); } }); const upsertedToSend = Array.from(upsertedMap.values()); const deletedToSend = Array.from(deletedSet?.values() || []); const currentOrder = (docToSave.blocks || []).map((b) => b.id); const orderChanged = currentOrder.length !== srvOrder.length || currentOrder.some((id, idx) => id !== srvOrder[idx]); const srvMeta = syncedDocMetaRef.current.get(docId); const cleanTags = (docToSave.tags || []).filter((t) => !t.startsWith('folder:')); const cleanTagsJson = JSON.stringify(cleanTags); const metaChanged = !srvMeta || srvMeta.title !== docToSave.title || srvMeta.icon !== docToSave.icon || srvMeta.tagsJson !== cleanTagsJson || srvMeta.isPinned !== !!docToSave.isPinned; // Clear the sent snapshot from dirty/deleted maps before awaiting network call if (dirtyMap) { upsertedToSend.forEach((b) => { const cur = dirtyMap.get(b.id); if (cur && blocksAreEqual(cur, b)) { dirtyMap.delete(b.id); } }); } if (deletedSet) { deletedToSend.forEach((id) => deletedSet.delete(id)); } if (upsertedToSend.length > 0 || deletedToSend.length > 0 || orderChanged) { const patchedDoc = await api.patchDocumentBlocks(docId, { upserted: upsertedToSend, deletedIds: deletedToSend, blockOrder: currentOrder, }); // Update server block baseline with patched result const nextSrvMap = new Map(); const nextSrvOrder: string[] = []; (patchedDoc.blocks || []).forEach((b) => { nextSrvMap.set(b.id, { ...b }); nextSrvOrder.push(b.id); }); syncedBlocksRef.current.set(docId, nextSrvMap); syncedOrderRef.current.set(docId, nextSrvOrder); } if (metaChanged) { await api.updateDocument(docId, { title: docToSave.title, icon: docToSave.icon, tags: cleanTags, isPinned: docToSave.isPinned, }); syncedDocMetaRef.current.set(docId, { title: docToSave.title, icon: docToSave.icon, tagsJson: cleanTagsJson, isPinned: !!docToSave.isPinned, }); } saveSucceeded = true; onStatsChangeRef.current(); api.getWorkspaceTags().then(setWorkspaceTags).catch(() => {}); if (selectedTag) { api.getTagDetails(selectedTag).then(setTagDetails).catch(() => {}); } } catch (e: any) { console.error('Failed to save document to backend', e); } finally { isSavingToBackendRef.current = false; if (saveSucceeded) { if (pendingSaveDocRef.current === null || pendingSaveDocRef.current === docToSave) { pendingSaveDocRef.current = null; setSaveState('saved'); } } else { setSaveState('saving'); } if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) { if (idleUnlockTimerRef.current) { clearTimeout(idleUnlockTimerRef.current); } idleUnlockTimerRef.current = setTimeout(async () => { if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) { await releaseMyLock(); } }, 8000); } } }; 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; ensureLock(); lastLocalEditTimestampRef.current = Date.now(); // Record block-level delta against previous local state and server baseline const docId = updatedDoc.id; let dirtyMap = localDirtyBlocksRef.current.get(docId); if (!dirtyMap) { dirtyMap = new Map(); localDirtyBlocksRef.current.set(docId, dirtyMap); } let deletedSet = localDeletedBlocksRef.current.get(docId); if (!deletedSet) { deletedSet = new Set(); localDeletedBlocksRef.current.set(docId, deletedSet); } const srvBlocks = syncedBlocksRef.current.get(docId); const prevLocalBlocks = selectedDoc && selectedDoc.id === docId ? selectedDoc.blocks || [] : []; const nextIds = new Set(); (updatedDoc.blocks || []).forEach((b) => { nextIds.add(b.id); deletedSet!.delete(b.id); const srv = srvBlocks?.get(b.id); if (!srv || !blocksAreEqual(b, srv)) { dirtyMap!.set(b.id, { ...b }); } }); prevLocalBlocks.forEach((prevB) => { if (!nextIds.has(prevB.id)) { dirtyMap!.delete(prevB.id); deletedSet!.add(prevB.id); } }); // Sync into active CRDT session if (crdtSessionRef.current && crdtSessionRef.current.docId === updatedDoc.id) { crdtSessionRef.current.syncFullBlocks(updatedDoc.blocks || []); } // 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 { debounceSaveTimerRef.current = setTimeout(async () => { debounceSaveTimerRef.current = null; if (pendingSaveDocRef.current) { const doc = pendingSaveDocRef.current; pendingSaveDocRef.current = null; await executeSaveToBackend(doc); } }, 350); } }; const loadDocs = async (selectId?: string | null) => { try { const [list, serverFolders, tags, sFolders] = await Promise.all([ api.getDocuments(), api.getFolders(), api.getWorkspaceTags().catch(() => []), api.getSharedFolders().catch(() => []), ]); list.forEach(recordSyncedDoc); setDocs(list); setCustomFolders(serverFolders); setWorkspaceTags(tags); setSharedFolders(sFolders); 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(); if (tagNoteSaveTimerRef.current) { clearTimeout(tagNoteSaveTimerRef.current); tagNoteSaveTimerRef.current = null; } setSelectedTag(clean); setWorkspaceMode('tags'); setIsLoadingTag(true); try { const details = await api.getTagDetails(clean); setTagDetails(details); setTagNoteContent(details.noteContent || ''); setTagNoteSaveState('saved'); } catch (err) { console.error('Failed to load tag details', err); } finally { setIsLoadingTag(false); } }; const handleTagNoteChange = (newContent: string) => { setTagNoteContent(newContent); if (!selectedTag) return; const targetTag = selectedTag; setTagNoteSaveState('saving'); if (tagNoteSaveTimerRef.current) { clearTimeout(tagNoteSaveTimerRef.current); } tagNoteSaveTimerRef.current = setTimeout(async () => { try { await api.updateTagPageNote(targetTag, newContent); setTagDetails((prev) => prev && prev.tag === targetTag ? { ...prev, noteContent: newContent } : prev ); setTagNoteSaveState('saved'); } catch (err) { console.error('Failed to save tag note', err); setTagNoteSaveState('saved'); } }, 450); }; const handleExportTagMarkdown = () => { if (!selectedTag) return; const md = `# #${selectedTag}\n\n${tagNoteContent || ''}\n`; const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `tag-${selectedTag}.md`; a.click(); URL.revokeObjectURL(url); }; 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, CRDT session lifecycle, and lock status checks useEffect(() => { const prevId = currentDocIdRef.current; if (prevId && prevId !== selectedDoc?.id) { flushPendingSave(); releaseMyLock(prevId); sendWebSocketMessage({ event: 'CRDT_LEAVE', docId: prevId }); if (crdtSessionRef.current) { crdtSessionRef.current.destroy(); crdtSessionRef.current = null; } setActivePeers([]); } currentDocIdRef.current = selectedDoc?.id || null; if (selectedDoc) { const targetDoc = selectedDoc; const session = new DocumentCRDTSession(targetDoc.id); session.initFromBlocks(targetDoc.blocks || []); session.onLocalUpdate = (b64Update) => { sendWebSocketMessage({ event: 'CRDT_UPDATE', docId: targetDoc.id, payload: { update: b64Update }, }); }; session.onRemoteChange = (crdtBlocks) => { const recentlyEdited = Date.now() - lastLocalEditTimestampRef.current < 2500; const hasPendingSave = pendingSaveDocRef.current !== null || isSavingToBackendRef.current; const hasDirtyBlocks = (localDirtyBlocksRef.current.get(targetDoc.id)?.size || 0) > 0; if (!recentlyEdited && !hasPendingSave && !hasDirtyBlocks) { setSelectedDoc((prev) => (prev && prev.id === targetDoc.id ? { ...prev, blocks: crdtBlocks } : prev)); setDocs((prev) => prev.map((d) => (d.id === targetDoc.id ? { ...d, blocks: crdtBlocks } : d))); } }; crdtSessionRef.current = session; sendWebSocketMessage({ event: 'CRDT_JOIN', docId: targetDoc.id }); api .getDocumentCRDT(targetDoc.id) .then((res) => { if (res && crdtSessionRef.current && crdtSessionRef.current.docId === targetDoc.id) { if (res.state) { crdtSessionRef.current.applyRemoteUpdate(res.state); } if (res.updates && Array.isArray(res.updates)) { crdtSessionRef.current.applyRemoteUpdates(res.updates); } const blocks = crdtSessionRef.current.getBlocks(); const recentlyEdited = Date.now() - lastLocalEditTimestampRef.current < 2500; const hasPendingSave = pendingSaveDocRef.current !== null || isSavingToBackendRef.current; const hasDirtyBlocks = (localDirtyBlocksRef.current.get(targetDoc.id)?.size || 0) > 0; if (blocks.length > 0 && !recentlyEdited && !hasPendingSave && !hasDirtyBlocks) { setSelectedDoc((prev) => (prev && prev.id === targetDoc.id ? { ...prev, blocks } : prev)); setDocs((prev) => prev.map((d) => (d.id === targetDoc.id ? { ...d, blocks } : d))); } } }) .catch(() => {}); 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) { sendWebSocketMessage({ event: 'CRDT_LEAVE', docId: currentDocIdRef.current }); if (crdtSessionRef.current) { crdtSessionRef.current.destroy(); crdtSessionRef.current = null; } releaseMyLock(currentDocIdRef.current); } }; }, [selectedDoc?.id]); // Window blur / visibility change listener: release lock when user leaves tab/window useEffect(() => { const handleWindowBlur = () => { if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) { flushPendingSave(); releaseMyLock(); } }; const handleVisibilityChange = () => { if (document.visibilityState === 'hidden' && !isDraggingBlockRef.current) { 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); } }; }, []); // Dedicated Real-time WebSocket connection for Document, Block Delta, Lock & Folder events useEffect(() => { let isMounted = true; const handleWsMsg = (msg: any) => { if (!msg || !msg.event) return; if (msg.event === 'CRDT_INIT') { const payload = msg.payload as { docId: string; state?: string; updates?: string[] }; if (payload && payload.docId === selectedDocIdRef.current && crdtSessionRef.current) { if (payload.state) { crdtSessionRef.current.applyRemoteUpdate(payload.state); } if (payload.updates && Array.isArray(payload.updates)) { crdtSessionRef.current.applyRemoteUpdates(payload.updates); } const blocks = crdtSessionRef.current.getBlocks(); const recentlyEdited = Date.now() - lastLocalEditTimestampRef.current < 2500; const hasPendingSave = pendingSaveDocRef.current !== null || isSavingToBackendRef.current; const hasDirtyBlocks = (localDirtyBlocksRef.current.get(payload.docId)?.size || 0) > 0; if (blocks.length > 0 && !recentlyEdited && !hasPendingSave && !hasDirtyBlocks) { setSelectedDoc((prev) => (prev && prev.id === payload.docId ? { ...prev, blocks } : prev)); setDocs((prev) => prev.map((d) => (d.id === payload.docId ? { ...d, blocks } : d))); } } } else if (msg.event === 'CRDT_UPDATE') { const payload = msg.payload as { docId: string; update: string; fromUserId?: string }; if (payload && payload.docId === selectedDocIdRef.current && crdtSessionRef.current) { if (payload.fromUserId !== currentUserIdRef.current) { crdtSessionRef.current.applyRemoteUpdate(payload.update); const blocks = crdtSessionRef.current.getBlocks(); const recentlyEdited = Date.now() - lastLocalEditTimestampRef.current < 2500; const hasPendingSave = pendingSaveDocRef.current !== null || isSavingToBackendRef.current; const hasDirtyBlocks = (localDirtyBlocksRef.current.get(payload.docId)?.size || 0) > 0; if (blocks && blocks.length > 0 && !recentlyEdited && !hasPendingSave && !hasDirtyBlocks) { setSelectedDoc((prev) => (prev && prev.id === payload.docId ? { ...prev, blocks } : prev)); setDocs((prev) => prev.map((d) => (d.id === payload.docId ? { ...d, blocks } : d))); } } } } else if (msg.event === 'CRDT_AWARENESS') { const payload = (msg.payload?.payload || msg.payload) as CollaboratorAwareness; if (payload && payload.userId && payload.userId !== currentUserIdRef.current) { setActivePeers((prev) => { const filtered = prev.filter((c) => c.userId !== payload.userId); return [...filtered, { ...payload, color: getCollaboratorColor(payload.userId), lastActive: Date.now() }]; }); } } else if (msg.event === 'CRDT_AWARENESS_LEAVE') { const payload = msg.payload as { docId?: string; userId?: string }; if (payload && payload.userId) { setActivePeers((prev) => prev.filter((c) => c.userId !== payload.userId)); } } else 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); } } else if (msg.event === 'DOC_BLOCKS_PATCHED') { const patch = msg.payload as DocBlocksPatchEvent; if (!patch || !patch.docId) return; setDocs((prev) => prev.map((d) => { if (d.id !== patch.docId) return d; const merged = mergeIncomingBlocks(d.id, d.blocks || [], { upserted: patch.upserted, deletedIds: patch.deletedIds, blockOrder: patch.blockOrder, }); return { ...d, blocks: merged, updatedAt: patch.updatedAt || d.updatedAt }; }) ); if (selectedDocIdRef.current === patch.docId) { setSelectedDoc((prev) => { if (!prev || prev.id !== patch.docId) return prev; const merged = mergeIncomingBlocks(prev.id, prev.blocks || [], { upserted: patch.upserted, deletedIds: patch.deletedIds, blockOrder: patch.blockOrder, }); const nextDoc = { ...prev, blocks: merged, updatedAt: patch.updatedAt || prev.updatedAt }; if (pendingSaveDocRef.current && pendingSaveDocRef.current.id === patch.docId) { pendingSaveDocRef.current = { ...pendingSaveDocRef.current, blocks: merged }; } return nextDoc; }); } } else if (msg.event === 'DOC_CREATED') { const created = msg.payload as Document; if (created && created.id) { recordSyncedDoc(created); setDocs((prev) => (prev.some((d) => d.id === created.id) ? prev : [created, ...prev])); onStatsChangeRef.current(); } } else if (msg.event === 'DOC_UPDATED') { const updated = msg.payload as Document; if (updated && updated.id) { const incomingOrder = (updated.blocks || []).map((b) => b.id); setDocs((prev) => prev.map((d) => { if (d.id !== updated.id) return d; const mergedBlocks = mergeIncomingBlocks(d.id, d.blocks || [], { upserted: updated.blocks || [], blockOrder: incomingOrder, }); return { ...d, ...updated, blocks: mergedBlocks, isPinned: d.isPinned, }; }) ); if (selectedDocIdRef.current === updated.id) { setSelectedDoc((prev) => { if (!prev || prev.id !== updated.id) return prev; const mergedBlocks = mergeIncomingBlocks(prev.id, prev.blocks || [], { upserted: updated.blocks || [], blockOrder: incomingOrder, }); const nextDoc = { ...prev, ...updated, blocks: mergedBlocks, isPinned: prev.isPinned, }; if (pendingSaveDocRef.current && pendingSaveDocRef.current.id === updated.id) { pendingSaveDocRef.current = { ...pendingSaveDocRef.current, blocks: mergedBlocks, }; } return nextDoc; }); } onStatsChangeRef.current(); } } 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); } onStatsChangeRef.current(); } } else if (msg.event === 'WORKSPACE_FOLDERS_UPDATED') { api.getFolders().then(setCustomFolders).catch(() => {}); api.getSharedFolders().then(setSharedFolders).catch(() => {}); api.getDocuments().then((freshList) => { freshList.forEach(recordSyncedDoc); setDocs(freshList); if (selectedDocIdRef.current) { const found = freshList.find((d) => d.id === selectedDocIdRef.current); if (found) { setSelectedDoc((prev) => prev && prev.id === found.id ? { ...prev, folderPath: found.folderPath, sharedFolderId: found.sharedFolderId, subFolderPath: found.subFolderPath, isSharedFolder: found.isSharedFolder, permission: found.permission, isOwner: found.isOwner, } : prev ); } } }).catch(() => {}); } }; const conn = createRealtimeConnection({ docId: selectedDocIdRef.current || undefined, onMessage: handleWsMsg, onStatusChange: (status) => { if (!isMounted) return; setWsConnected(status.connected); setRealtimeTransport(status.transport); if (status.connected) { setWsDisconnectReason(null); if (selectedDocIdRef.current) { conn.send({ event: 'CRDT_JOIN', docId: selectedDocIdRef.current }); } api.getFolders().then(setCustomFolders).catch(() => {}); api.getSharedFolders().then(setSharedFolders).catch(() => {}); } else { setWsDisconnectReason(status.reason || 'Verbindung getrennt'); } }, }); realtimeConnRef.current = conn; // Also synchronize with global WebSocket status const unsubGlobalStatus = onWebSocketStatus((connected) => { if (!isMounted) return; if (connected) { setWsConnected(true); setWsDisconnectReason(null); } }); // Fallback: also listen for window custom event 'mindsift:ws' const handleGlobalCustomEvent = (e: any) => { if (e.detail) { handleWsMsg(e.detail); } }; window.addEventListener('mindsift:ws', handleGlobalCustomEvent); return () => { isMounted = false; unsubGlobalStatus(); if (realtimeConnRef.current) { try { realtimeConnRef.current.close(); } catch {} realtimeConnRef.current = null; } window.removeEventListener('mindsift:ws', handleGlobalCustomEvent); }; }, []); // Fallback HTTP polling when WebSocket is disconnected (e.g. corporate proxy / firewall blocking WebSockets) useEffect(() => { if (wsConnected) return; let isMounted = true; const pollInterval = setInterval(async () => { if (!isMounted || document.visibilityState !== 'visible') return; // 1. Poll lock status of currently selected document if (selectedDocIdRef.current) { try { const res = await api.getDocLock(selectedDocIdRef.current); if (res.locked && res.lock && res.lock.userId !== currentUserIdRef.current) { setActiveLock(res.lock); } else { setActiveLock(null); } } catch {} } // 2. Poll document content if not actively typing and document has changed on server const recentlyEdited = Date.now() - lastLocalEditTimestampRef.current < 4000; const hasPendingSave = pendingSaveDocRef.current !== null || isSavingToBackendRef.current; const hasDirtyBlocks = (localDirtyBlocksRef.current.get(selectedDocIdRef.current || '')?.size || 0) > 0; if ( selectedDocIdRef.current && !isEditorFocusedRef.current && !hasPendingSave && !hasDirtyBlocks && !recentlyEdited ) { try { const fresh = await api.getDocument(selectedDocIdRef.current); if (fresh && (!selectedDoc || fresh.updatedAt !== selectedDoc.updatedAt)) { setSelectedDoc(fresh); setDocs((prev) => prev.map((d) => (d.id === fresh.id ? fresh : d))); } } catch {} } }, 4000); return () => { isMounted = false; clearInterval(pollInterval); }; }, [wsConnected, selectedDoc?.updatedAt]); const openShareModal = async () => { if (!selectedDoc) return; setShowShareModal(true); setShareError(null); setSharingLoading(true); setSelectedShareUser(null); setSelectedShareTeamId(''); try { const [sharesList, usersList, teamsList] = await Promise.all([ api.getDocShares(selectedDoc.id).catch(() => []), api.getUsersDirectory ? api.getUsersDirectory().catch(() => api.getCollaborators().catch(() => [])) : api.getCollaborators().catch(() => []), api.getUserTeams().catch(() => []), ]); setDocShares(sharesList); setCollaborators(usersList); setShareTeams(teamsList); } finally { setSharingLoading(false); } }; const openFolderShareModal = async (node: FolderTreeItem) => { try { let sf = node.sharedFolder; if (!sf) { sf = await api.createSharedFolder({ name: node.name, mountPath: node.path }); const sfList = await api.getSharedFolders(); setSharedFolders(sfList); } const [shares, usersList, teamsList] = await Promise.all([ api.getFolderShares(sf.id).catch(() => []), api.getUsersDirectory ? api.getUsersDirectory().catch(() => api.getCollaborators().catch(() => [])) : api.getCollaborators().catch(() => []), api.getUserTeams().catch(() => []), ]); setFolderShares(shares); setCollaborators(usersList); setShareTeams(teamsList); setSharingFolder(sf); setShowFolderShareModal(true); } catch (err) { console.error('Failed to open folder share modal', err); alert('Fehler beim Öffnen der Ordnerfreigabe'); } }; const handleAddShare = async (e: React.FormEvent) => { e.preventDefault(); if (!selectedDoc) return; setSharingLoading(true); setShareError(null); try { if (addShareType === 'team') { if (!selectedShareTeamId) { setShareError('Bitte ein Team oder eine Gruppe auswählen'); setSharingLoading(false); return; } await api.createDocShare(selectedDoc.id, { teamId: selectedShareTeamId, permission: sharePermission, }); setSelectedShareTeamId(''); } else { if (!selectedShareUser) { setShareError('Bitte einen bestehenden Benutzer auswählen'); setSharingLoading(false); return; } await api.createDocShare(selectedDoc.id, { userId: selectedShareUser.id, permission: sharePermission, }); setSelectedShareUser(null); } const sharesList = await api.getDocShares(selectedDoc.id).catch(() => []); setDocShares(sharesList); setSelectedDoc((prev) => prev ? { ...prev, sharedWith: sharesList, } : 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 (targetId: string) => { if (!selectedDoc) return; if (!window.confirm('Freigabe wirklich aufheben?')) return; setSharingLoading(true); try { await api.deleteDocShare(selectedDoc.id, targetId); setDocShares((prev) => prev.filter((s) => (s.targetId || s.userId || s.id) !== targetId && s.userId !== targetId)); setSelectedDoc((prev) => prev ? { ...prev, sharedWith: (prev.sharedWith || []).filter((s) => (s.targetId || s.userId || s.id) !== targetId && s.userId !== targetId), } : 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 => { return doc.folderPath || ''; }; // Compute all unique folder paths const allFolderPaths = useMemo(() => { const set = new Set(customFolders); sharedFolders.forEach((sf) => { if (sf.mountPath) { const parts = sf.mountPath.split('/'); let cur = ''; for (const p of parts) { cur = cur ? `${cur}/${p}` : p; set.add(cur); } } }); 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, sharedFolders, 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 || ''; 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(); try { const updatedDoc = await api.moveDocument(selectedDoc.id, cleanPath); setDocs((prev) => prev.map((d) => d.id === selectedDoc.id ? { ...d, folderPath: updatedDoc.folderPath, sharedFolderId: updatedDoc.sharedFolderId, subFolderPath: updatedDoc.subFolderPath, isSharedFolder: updatedDoc.isSharedFolder, } : d ) ); setSelectedDoc((prev) => prev && prev.id === selectedDoc.id ? { ...prev, folderPath: updatedDoc.folderPath, sharedFolderId: updatedDoc.sharedFolderId, subFolderPath: updatedDoc.subFolderPath, isSharedFolder: updatedDoc.isSharedFolder, } : prev ); api.getFolders().then(setCustomFolders).catch(() => {}); } catch (err: any) { console.error('Failed to change document folder', err); alert(err.message || 'Fehler beim Verschieben der Notiz'); } }; // 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; } triggerHaptic(15); try { const res = await api.moveFolder(cleanSource, cleanTarget); await Promise.all([ api.getFolders().then(setCustomFolders), api.getSharedFolders().then(setSharedFolders), loadDocs(selectedDoc?.id), ]); setExpandedFolders((prev) => { const next = new Set(prev); if (res.newPath) next.add(res.newPath); if (cleanTarget) next.add(cleanTarget); return next; }); } catch (err: any) { console.error('Failed to move folder', err); alert(err.message || 'Fehler beim Verschieben des Ordners'); } }; 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); try { const updatedDoc = await api.moveDocument(doc.id, cleanPath); setDocs((prev) => prev.map((d) => d.id === doc.id ? { ...d, folderPath: updatedDoc.folderPath, sharedFolderId: updatedDoc.sharedFolderId, subFolderPath: updatedDoc.subFolderPath, isSharedFolder: updatedDoc.isSharedFolder, } : d ) ); if (selectedDoc?.id === doc.id) { setSelectedDoc((prev) => prev ? { ...prev, folderPath: updatedDoc.folderPath, sharedFolderId: updatedDoc.sharedFolderId, subFolderPath: updatedDoc.subFolderPath, isSharedFolder: updatedDoc.isSharedFolder, } : null ); } if (cleanPath) { setExpandedFolders((prev) => new Set([...prev, cleanPath])); } api.getFolders().then(setCustomFolders).catch(() => {}); } catch (err: any) { console.error('Failed to move doc', err); alert(err.message || 'Fehler beim Verschieben der Notiz'); } }; 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) => { const matchingSF = sharedFolders.find((sf) => sf.mountPath === folderPath); if (matchingSF) { if (matchingSF.isOwner) { if (!window.confirm(`Möchtest du den geteilten Ordner "${folderName}" wirklich löschen? Die Freigabe wird für alle Teilnehmer aufgehoben.`)) { return; } } else { if (!window.confirm(`Möchtest du den geteilten Ordner "${folderName}" aus deinem Workspace entfernen? (Die Quell-Dateien des Eigentümers bleiben erhalten).`)) { return; } } try { await api.deleteSharedFolder(matchingSF.id); const sfList = await api.getSharedFolders(); setSharedFolders(sfList); await loadDocs(selectedDoc?.id); return; } catch (err: any) { console.error('Failed to delete shared folder', err); alert(err.message || 'Fehler beim Löschen des geteilten Ordners'); return; } } // 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 // ============================================================================ // 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 (respecting collapsed sub-branches inside the zoomed view) const descendants: { block: DocumentBlock; originalIndex: number }[] = []; let hideUntilLevel = -1; for (let i = zoomIdx + 1; i < all.length; i++) { const b = all[i]; const lvl = b.level || 0; if (lvl <= targetLevel) { break; } if (hideUntilLevel !== -1) { if (lvl > hideUntilLevel) { continue; } else { hideUntilLevel = -1; } } descendants.push({ block: b, originalIndex: i }); if (b.collapsed) { hideUntilLevel = lvl; } } return { visibleBlocks: descendants, breadcrumbBlocks: crumbs, zoomedBlock: target }; }, [selectedDoc, zoomedBlockId]); // 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; }; // Zoom into a bullet (or back out to parent/root) and ensure childless bullets get an editable sub-bullet const handleZoomTo = (targetBlockId: string | null) => { if (!selectedDoc) { setZoomedBlockId(targetBlockId); return; } let workingBlocks = [...selectedDoc.blocks]; let blocksChanged = false; // If zooming out/away from a zoomed block whose only descendant is a completely empty bullet, clean it up if (!isReadOnly && zoomedBlock && targetBlockId !== zoomedBlock.id) { const curZoomIdx = workingBlocks.findIndex((b) => b.id === zoomedBlock.id); if (curZoomIdx !== -1) { const curZoomLevel = workingBlocks[curZoomIdx].level || 0; const descIndices: number[] = []; for (let i = curZoomIdx + 1; i < workingBlocks.length; i++) { if ((workingBlocks[i].level || 0) > curZoomLevel) { descIndices.push(i); } else { break; } } if (descIndices.length === 1) { const onlyChild = workingBlocks[descIndices[0]]; if ( onlyChild.id !== targetBlockId && onlyChild.type === 'bullet' && (!onlyChild.content || onlyChild.content.trim() === '') ) { workingBlocks.splice(descIndices[0], 1); blocksChanged = true; } } } } if (targetBlockId && !isReadOnly) { const targetIdx = workingBlocks.findIndex((b) => b.id === targetBlockId); if (targetIdx !== -1 && !hasChildren(workingBlocks, targetIdx)) { const childLevel = (workingBlocks[targetIdx].level || 0) + 1; const newChild: DocumentBlock = { id: `blk_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, type: 'bullet', content: '', level: childLevel, checked: false, }; workingBlocks.splice(targetIdx + 1, 0, newChild); persistDocument({ ...selectedDoc, blocks: workingBlocks }); setZoomedBlockId(targetBlockId); setLastFocusedBlockId(newChild.id); setBlockCursor(newChild.id, 0); setTimeout(() => { const el = blockInputRefs.current[newChild.id]; if (el) { el.focus(); if ('setSelectionRange' in el && typeof (el as any).value === 'string') { (el as any).setSelectionRange(0, 0); } } }, 30); return; } } if (blocksChanged) { persistDocument({ ...selectedDoc, blocks: workingBlocks }); } setZoomedBlockId(targetBlockId); }; // Insert a new bullet immediately after index, inheriting level const insertNewBulletAfter = ( afterIndex: number, level: number, type: BlockType = 'bullet', initialContent: string = '', updatedCurrentBlock?: { id: string; content: 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.map((b) => updatedCurrentBlock && b.id === updatedCurrentBlock.id ? { ...b, content: updatedCurrentBlock.content } : b ); nextBlocks.splice(afterIndex + 1, 0, newBlock); lastLocalEditTimestampRef.current = Date.now(); persistDocument({ ...selectedDoc, blocks: nextBlocks }); focusBlock(newBlock.id, 0); }; // Indent block (Tab): increase level by 1 and move all descendant sub-bullets along const indentBlock = (index: number) => { if (isReadOnly || !selectedDoc || index === 0) return; const blocks = selectedDoc.blocks; const block = blocks[index]; const prevBlock = blocks[index - 1]; if (!block || !prevBlock) return; const currentLevel = block.level || 0; const maxLevel = (prevBlock.level || 0) + 1; if (currentLevel >= maxLevel) return; // Find all descendant sub-bullets directly or indirectly nested under this block const descendantIndices: number[] = []; for (let i = index + 1; i < blocks.length; i++) { if ((blocks[i].level || 0) <= currentLevel) break; descendantIndices.push(i); } const nextBlocks = blocks.map((b, idx) => { if (idx === index) { return { ...b, level: currentLevel + 1 }; } if (descendantIndices.includes(idx)) { return { ...b, level: (b.level || 0) + 1 }; } return b; }); persistDocument({ ...selectedDoc, blocks: nextBlocks }); }; // Outdent block (Shift+Tab): decrease level by 1 and move all descendant sub-bullets along const outdentBlock = (blockId: string) => { if (isReadOnly || !selectedDoc) return; const blocks = selectedDoc.blocks; const index = blocks.findIndex((b) => b.id === blockId); if (index === -1) return; const block = blocks[index]; const currentLevel = block.level || 0; const minLevel = zoomedBlock ? (zoomedBlock.level || 0) + 1 : 0; if (currentLevel <= minLevel) return; // Find all descendant sub-bullets directly or indirectly nested under this block const descendantIndices: number[] = []; for (let i = index + 1; i < blocks.length; i++) { if ((blocks[i].level || 0) <= currentLevel) break; descendantIndices.push(i); } const nextBlocks = blocks.map((b, idx) => { if (idx === index) { return { ...b, level: currentLevel - 1 }; } if (descendantIndices.includes(idx)) { return { ...b, level: Math.max(minLevel, (b.level || 0) - 1) }; } return b; }); persistDocument({ ...selectedDoc, blocks: nextBlocks }); }; // Delete a single block without deleting its sub-bullets (sub-bullets are promoted 1 level up) const deleteSingleBlock = (blockId: string, focusPrev = true) => { if (isReadOnly || !selectedDoc) return; const blocks = selectedDoc.blocks; const index = blocks.findIndex((b) => b.id === blockId); if (index === -1) return; const targetBlock = blocks[index]; const targetLevel = targetBlock.level || 0; const minLevel = zoomedBlock ? (zoomedBlock.level || 0) + 1 : 0; // Find all descendant sub-bullets directly or indirectly nested under this block const descendantIndices: number[] = []; for (let i = index + 1; i < blocks.length; i++) { if ((blocks[i].level || 0) <= targetLevel) break; descendantIndices.push(i); } triggerHaptic(10); // Target block is removed, but descendants are PRESERVED! // Descendants are shifted up by 1 level to adopt the target block's level let nextBlocks: DocumentBlock[] = []; for (let i = 0; i < blocks.length; i++) { if (i === index) continue; if (descendantIndices.includes(i)) { const curLevel = blocks[i].level || 0; nextBlocks.push({ ...blocks[i], level: Math.max(minLevel, curLevel - 1), }); } else { nextBlocks.push(blocks[i]); } } if (nextBlocks.length === 0) { nextBlocks = [ { id: `blk_${Date.now()}_1`, type: 'bullet', content: '', level: minLevel, }, ]; } let prevBlock: DocumentBlock | undefined; if (focusPrev) { if (zoomedBlock) { const zoomIdx = blocks.findIndex((b) => b.id === zoomedBlock.id); if (index > zoomIdx + 1) { prevBlock = blocks[index - 1]; } else { const nextFirstChild = nextBlocks[zoomIdx + 1]; if (nextFirstChild && (nextFirstChild.level || 0) > (zoomedBlock.level || 0)) { prevBlock = nextFirstChild; } } } else { prevBlock = index > 0 ? blocks[index - 1] : nextBlocks[0]; } } persistDocument({ ...selectedDoc, blocks: nextBlocks }); if (prevBlock) { setLastFocusedBlockId(prevBlock.id); setBlockCursor(prevBlock.id, (prevBlock.content || '').length); 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); } }; // Delete a block AND all its descendant sub-bullets const deleteBlockWithDescendants = (blockId: string) => { if (isReadOnly || !selectedDoc) return; const blocks = selectedDoc.blocks; const index = blocks.findIndex((b) => b.id === blockId); if (index === -1) return; const targetBlock = blocks[index]; const targetLevel = targetBlock.level || 0; const minLevel = zoomedBlock ? (zoomedBlock.level || 0) + 1 : 0; const descendantIds: string[] = []; for (let i = index + 1; i < blocks.length; i++) { if ((blocks[i].level || 0) <= targetLevel) break; descendantIds.push(blocks[i].id); } 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: minLevel, }, ]; } let prevBlock: DocumentBlock | undefined; if (zoomedBlock) { const zoomIdx = blocks.findIndex((b) => b.id === zoomedBlock.id); if (index > zoomIdx + 1) { prevBlock = blocks[index - 1]; } else { const nextFirstChild = nextBlocks[zoomIdx + 1]; if (nextFirstChild && (nextFirstChild.level || 0) > (zoomedBlock.level || 0)) { prevBlock = nextFirstChild; } } } else { prevBlock = index > 0 ? blocks[index - 1] : nextBlocks[0]; } persistDocument({ ...selectedDoc, blocks: nextBlocks }); if (prevBlock) { setLastFocusedBlockId(prevBlock.id); setBlockCursor(prevBlock.id, (prevBlock.content || '').length); 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); } }; // Handler for delete click on the X icon: prompts if sub-bullets exist const handleDeleteButtonClick = (blockId: string) => { if (isReadOnly || !selectedDoc) return; const blocks = selectedDoc.blocks; const index = blocks.findIndex((b) => b.id === blockId); if (index === -1) return; const targetBlock = blocks[index]; const targetLevel = targetBlock.level || 0; let count = 0; for (let i = index + 1; i < blocks.length; i++) { if ((blocks[i].level || 0) <= targetLevel) break; count++; } if (count > 0) { setDeleteSubpointsModal({ blockId, count, snippet: targetBlock.content?.trim() ? `"${targetBlock.content.trim().slice(0, 35)}..."` : '', }); } else { deleteSingleBlock(blockId, false); } }; 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 }); } }; useLayoutEffect(() => { if (!activeEditingBlockId) return; const el = blockInputRefs.current[activeEditingBlockId]; if (!el) return; if (el instanceof HTMLTextAreaElement) { autoResizeTextarea(el); } // Only programmatically adjust selection if the element is NOT already the actively focused element! if (document.activeElement !== el && 'setSelectionRange' in el && typeof el.value === 'string') { const segStartAttr = el.getAttribute('data-seg-start'); const segStart = segStartAttr !== null ? parseInt(segStartAttr, 10) || 0 : 0; const desiredOffset = activeCursorOffsetRef.current; const localPos = Math.max(0, Math.min(el.value.length, desiredOffset - segStart)); el.focus(); try { el.setSelectionRange(localPos, localPos); } catch { // Ignore selection range errors on unsupported inputs } } }, [activeEditingBlockId]); // Keyboard Navigation: Enter, Shift+Enter, Tab, Shift+Tab, Backspace, / Slash Commands const handleKeyDown = ( e: React.KeyboardEvent, block: DocumentBlock, index: number, segStart = 0 ) => { 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) { // Shift+Enter: insert a newline within the current bullet point if (block.type === 'bullet' || block.type === 'paragraph' || block.type === 'todo') { e.preventDefault(); setSlashBlockId(null); const target = e.target as HTMLTextAreaElement | HTMLInputElement; const localPos = target && typeof target.selectionStart === 'number' ? target.selectionStart : (target?.value || '').length; const cursor = segStart + localPos; const fullContent = block.content || ''; const nextContent = fullContent.slice(0, cursor) + '\n' + fullContent.slice(cursor); const nextCursor = cursor + 1; setBlockCursor(block.id, nextCursor); updateBlock(block.id, { content: nextContent }); if (target instanceof HTMLTextAreaElement) { setTimeout(() => autoResizeTextarea(target), 0); } } return; } if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); setSlashBlockId(null); const target = e.target as HTMLTextAreaElement | HTMLInputElement; const fullContent = block.content || ''; let afterContent = ''; let updatedCurrent: { id: string; content: string } | undefined = undefined; if (target && typeof target.selectionStart === 'number') { const cursor = segStart + target.selectionStart; const before = fullContent.slice(0, cursor); const after = fullContent.slice(cursor); if (after.length > 0) { updatedCurrent = { id: block.id, content: before }; afterContent = after; } } else if (fullContent.endsWith('\n')) { const trimmedTrailing = fullContent.replace(/\n+$/, ''); if (trimmedTrailing !== fullContent) { updatedCurrent = { id: block.id, content: trimmedTrailing }; } } insertNewBulletAfter( index, block.level || 0, block.type === 'todo' ? 'todo' : 'bullet', afterContent, updatedCurrent ); return; } else if (e.key === 'Tab') { e.preventDefault(); if (e.shiftKey) { outdentBlock(block.id); } else { indentBlock(index); } } else if (e.key === 'Backspace') { const target = e.target as HTMLTextAreaElement | HTMLInputElement; const fullContent = block.content || ''; if (!fullContent || fullContent === '') { e.preventDefault(); setSlashBlockId(null); deleteSingleBlock(block.id, true); return; } else if ( target && target.selectionStart === 0 && target.selectionEnd === 0 ) { e.preventDefault(); if ((block.level || 0) > 0) { outdentBlock(block.id); return; } if (selectedDoc && index > 0) { const prevBlock = selectedDoc.blocks[index - 1]; if (prevBlock && prevBlock.type !== 'divider') { const prevLen = (prevBlock.content || '').length; const mergedContent = (prevBlock.content || '') + fullContent; const nextBlocks = selectedDoc.blocks .map((b) => (b.id === prevBlock.id ? { ...b, content: mergedContent } : b)) .filter((b) => b.id !== block.id); persistDocument({ ...selectedDoc, blocks: nextBlocks }, true); focusBlock(prevBlock.id, prevLen); } } } } else if (!e.shiftKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { const target = e.target as HTMLTextAreaElement | HTMLInputElement; const fullContent = block.content || ''; if (target && typeof target.selectionStart === 'number') { const cursor = target.selectionStart; if (e.key === 'ArrowUp') { const firstNewline = fullContent.indexOf('\n'); if (firstNewline === -1 || cursor <= firstNewline) { if (index > 0 && selectedDoc) { const prev = selectedDoc.blocks[index - 1]; if (prev) { e.preventDefault(); focusBlock(prev.id); return; } } } } else if (e.key === 'ArrowDown') { const lastNewline = fullContent.lastIndexOf('\n'); if (lastNewline === -1 || cursor > lastNewline) { if (selectedDoc && index < selectedDoc.blocks.length - 1) { const next = selectedDoc.blocks[index + 1]; if (next) { e.preventDefault(); focusBlock(next.id, 0); return; } } } } } } else if (e.key === 'Escape') { setSlashBlockId(null); setActiveEditingBlockId(null); (e.target as HTMLElement)?.blur(); } }; 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, customOffset?: number) => { setLastFocusedBlockId(blockId); const blk = selectedDoc?.blocks.find((b) => b.id === blockId); const targetOffset = customOffset !== undefined ? customOffset : (blk?.content || '').length; setBlockCursor(blockId, targetOffset); const tryFocus = () => { const el = blockInputRefs.current[blockId]; if (el) { if (el instanceof HTMLTextAreaElement) { autoResizeTextarea(el); } el.focus(); if ('setSelectionRange' in el && typeof (el as any).value === 'string') { const segStartAttr = el.getAttribute('data-seg-start'); const segStart = segStartAttr !== null ? parseInt(segStartAttr, 10) || 0 : 0; const len = (el as any).value.length; const localPos = Math.max(0, Math.min(len, targetOffset - segStart)); (el as any).setSelectionRange(localPos, localPos); } return true; } return false; }; setTimeout(() => { if (!tryFocus()) { setTimeout(tryFocus, 30); } }, 20); }; const handleCanvasBottomClick = () => { if (isReadOnly || !selectedDoc) return; if (zoomedBlock) { const zoomIdx = selectedDoc.blocks.findIndex((b) => b.id === zoomedBlock.id); if (zoomIdx === -1) return; const minChildLevel = (zoomedBlock.level || 0) + 1; let lastDescendantIndex = zoomIdx; for (let i = zoomIdx + 1; i < selectedDoc.blocks.length; i++) { if ((selectedDoc.blocks[i].level || 0) >= minChildLevel) { lastDescendantIndex = i; } else { break; } } if (lastDescendantIndex === zoomIdx) { insertNewBulletAfter(zoomIdx, minChildLevel); return; } const lastVisible = visibleBlocks[visibleBlocks.length - 1]; if ( lastVisible && lastVisible.block.type !== 'divider' && (!lastVisible.block.content || lastVisible.block.content.trim() === '') ) { focusBlock(lastVisible.block.id); } else { insertNewBulletAfter(lastDescendantIndex, minChildLevel); } 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 }); setEditingLinkOriginalRaw(null); 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, lang: cmd.type === 'code' ? (block.lang || 'javascript') : block.lang, }); focusBlock(blockId); } }; const handleInsertNoteLink = (docTitle: string) => { if (!linkModalBlockId || !selectedDoc) return; const targetBlockId = linkModalBlockId; const block = selectedDoc.blocks.find((b) => b.id === targetBlockId); let nextCursor = 0; if (block) { let newContent: string; if (editingLinkOriginalRaw && block.content.includes(editingLinkOriginalRaw)) { newContent = block.content.replace(editingLinkOriginalRaw, `[[${docTitle}]]`); if (!newContent.endsWith(' ')) newContent += ' '; } else { const base = block.content || ''; const space = base.length > 0 && !base.endsWith(' ') ? ' ' : ''; newContent = `${base}${space}[[${docTitle}]] `; } nextCursor = newContent.length; updateBlock(targetBlockId, { content: newContent }); } setLinkModalBlockId(null); setEditingLinkOriginalRaw(null); focusBlock(targetBlockId, nextCursor); }; const handleInsertWebLink = () => { if (!linkModalBlockId || !selectedDoc) return; const targetBlockId = linkModalBlockId; const block = selectedDoc.blocks.find((b) => b.id === targetBlockId); 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; } } let nextCursor = 0; if (block) { let newContent: string; if (editingLinkOriginalRaw && block.content.includes(editingLinkOriginalRaw)) { newContent = block.content.replace(editingLinkOriginalRaw, `[${title}](${url})`); if (!newContent.endsWith(' ')) newContent += ' '; } else { const base = block.content || ''; const space = base.length > 0 && !base.endsWith(' ') ? ' ' : ''; newContent = `${base}${space}[${title}](${url}) `; } nextCursor = newContent.length; updateBlock(targetBlockId, { content: newContent }); } setLinkModalBlockId(null); setEditingLinkOriginalRaw(null); setLinkWebTitle(''); setLinkWebUrl(''); focusBlock(targetBlockId, nextCursor); }; 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; }; // Helper: Get subtree count (root + all indented children) const getBlockSubtreeCount = (blocks: DocumentBlock[], startIndex: number): number => { if (startIndex < 0 || startIndex >= blocks.length) return 0; const rootLevel = blocks[startIndex].level || 0; let count = 1; for (let i = startIndex + 1; i < blocks.length; i++) { if ((blocks[i].level || 0) > rootLevel) { count++; } else { break; } } return count; }; // Helper: Check if target block is a descendant of source block const isDescendantBlock = (blocks: DocumentBlock[], sourceIdx: number, targetBlockId: string): boolean => { if (sourceIdx < 0 || sourceIdx >= blocks.length) return false; const subtreeCount = getBlockSubtreeCount(blocks, sourceIdx); for (let i = sourceIdx; i < sourceIdx + subtreeCount; i++) { if (blocks[i]?.id === targetBlockId) return true; } return false; }; // Drag & Drop Handlers on the Bullet row / Grip / Dot const handleBlockDragStart = (e: React.DragEvent, blockId: string) => { e.stopPropagation(); if (isReadOnly) { e.preventDefault(); return; } isDraggingBlockRef.current = true; if (idleUnlockTimerRef.current) { clearTimeout(idleUnlockTimerRef.current); idleUnlockTimerRef.current = null; } // Activate write lock immediately upon dragging (optimistic UI + backend broadcast) setHoldsLock(true); ensureLock(true); e.dataTransfer.setData('application/mindsift-block-id', blockId); e.dataTransfer.setData('text/plain', blockId); e.dataTransfer.effectAllowed = 'move'; setDraggingBlockId(blockId); }; const handleBlockDragEnd = () => { isDraggingBlockRef.current = false; setDraggingBlockId(null); setDragOverBlockTarget(null); if (idleUnlockTimerRef.current) { clearTimeout(idleUnlockTimerRef.current); } idleUnlockTimerRef.current = setTimeout(async () => { if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) { await flushPendingSave(); await releaseMyLock(); } }, 2000); }; const handleBlockDragOver = (e: React.DragEvent, targetBlockId: string) => { e.preventDefault(); e.stopPropagation(); if (!draggingBlockId || draggingBlockId === targetBlockId) return; const rect = e.currentTarget.getBoundingClientRect(); const relY = e.clientY - rect.top; const height = rect.height; let position: 'before' | 'after' | 'inside'; if (relY < height * 0.25) { position = 'before'; } else if (relY > height * 0.75) { position = 'after'; } else { position = 'inside'; } if ( !dragOverBlockTarget || dragOverBlockTarget.blockId !== targetBlockId || dragOverBlockTarget.position !== position ) { setDragOverBlockTarget({ blockId: targetBlockId, position }); } }; const handleBlockDragLeave = (e: React.DragEvent, targetBlockId: string) => { e.stopPropagation(); if (dragOverBlockTarget?.blockId === targetBlockId) { setDragOverBlockTarget(null); } }; const handleBlockDrop = (e: React.DragEvent, targetBlockId: string, explicitPosition?: 'before' | 'after' | 'inside') => { e.preventDefault(); e.stopPropagation(); const sourceId = e.dataTransfer.getData('application/mindsift-block-id') || e.dataTransfer.getData('text/plain') || draggingBlockId; const position = explicitPosition || dragOverBlockTarget?.position || 'after'; isDraggingBlockRef.current = false; setDraggingBlockId(null); setDragOverBlockTarget(null); if (!selectedDoc || !sourceId || sourceId === targetBlockId) { return; } const blocks = [...selectedDoc.blocks]; const sourceIdx = blocks.findIndex((b) => b.id === sourceId); if (sourceIdx === -1) return; // Prevent circular drag into one's own children if (isDescendantBlock(blocks, sourceIdx, targetBlockId)) { return; } // Extract the entire subtree (parent + all indented children) const subtreeCount = getBlockSubtreeCount(blocks, sourceIdx); const subtree = blocks.splice(sourceIdx, subtreeCount); // Find new index of target block after splice const newTargetIdx = blocks.findIndex((b) => b.id === targetBlockId); if (newTargetIdx === -1) return; const targetBlock = blocks[newTargetIdx]; if (position === 'inside') { // Nest directly under targetBlock as its sub-bullet const targetLevel = (targetBlock.level || 0) + 1; const oldRootLevel = subtree[0].level || 0; const levelDiff = targetLevel - oldRootLevel; for (const b of subtree) { b.level = Math.max(0, (b.level || 0) + levelDiff); } // Automatically uncollapse targetBlock so the newly added sub-bullet is visible if (targetBlock.collapsed) { blocks[newTargetIdx] = { ...targetBlock, collapsed: false }; } // Insert immediately below targetBlock blocks.splice(newTargetIdx + 1, 0, ...subtree); } else { const targetLevel = targetBlock.level || 0; const oldRootLevel = subtree[0].level || 0; const levelDiff = targetLevel - oldRootLevel; // Adjust levels of all blocks in subtree to match target hierarchy for (const b of subtree) { b.level = Math.max(0, (b.level || 0) + levelDiff); } // Calculate insertion index let insertIdx = newTargetIdx; if (position === 'after') { const targetSubtreeCount = getBlockSubtreeCount(blocks, newTargetIdx); insertIdx = newTargetIdx + targetSubtreeCount; } blocks.splice(insertIdx, 0, ...subtree); } persistDocument({ ...selectedDoc, blocks }, true); if (idleUnlockTimerRef.current) { clearTimeout(idleUnlockTimerRef.current); } idleUnlockTimerRef.current = setTimeout(async () => { if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) { await flushPendingSave(); await releaseMyLock(); } }, 2500); }; // 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); }; const findRichTokens = (text: string): { start: number; end: number; raw: string }[] => { if (!text) return []; const regex = /(\[\[[^\]]+\]\]|\[[^\]]*\]\(https?:\/\/[^\s)]+\)|#[a-zA-Z0-9äöüÄÖÜß_-]+)/g; const tokens: { start: number; end: number; raw: string }[] = []; let match: RegExpExecArray | null; while ((match = regex.exec(text)) !== null) { tokens.push({ start: match.index, end: match.index + match[0].length, raw: match[0], }); } return tokens; }; const renderSingleRichToken = (part: string, blockId: string | undefined, key: React.Key) => { 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 cursor-pointer" title={target ? `Öffne: ${target.title}` : `Dokument "${linkTitle}" existiert noch nicht`} > 📄 {linkTitle} {!isReadOnly && blockId && ( )} ); } 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 cursor-pointer" title={`Öffne: ${url}`} > 🌐 {title} {!isReadOnly && blockId && ( )} ); } 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.5 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 align-baseline" title={`Öffne Hashtag-Seite: #${tagWord}`} > {tagWord} ); } return {part}; }; // Helper to render text with clickable Wiki-Links, Web-Links and Hashtags across multiple lines const renderFormattedText = (text: string, blockId?: string) => { const rawLines = (text || '').split('\n'); if (rawLines.length <= 1) { const parts = (text || '').split(/(\[\[.*?\]\]|\[.*?\]\(https?:\/\/[^\s)]+\)|#[a-zA-Z0-9äöüÄÖÜß_-]+)/g); return parts.map((part, i) => renderSingleRichToken(part, blockId, i)); } return (
{rawLines.map((lineText, lineIdx) => { const parts = lineText.split(/(\[\[.*?\]\]|\[.*?\]\(https?:\/\/[^\s)]+\)|#[a-zA-Z0-9äöüÄÖÜß_-]+)/g); return (
{lineText.length === 0 ? (   ) : ( parts.map((part, i) => renderSingleRichToken(part, blockId, `${lineIdx}-${i}`)) )}
); })}
); }; // Auto-resizing textarea / formatted view for bullet points and tasks: // - When editing: renders an auto-expanding textarea that naturally wraps text (no horizontal scrolling). // - When inactive: renders full rich tokens (Wiki-Links, external URLs, #hashtags) with word wrapping. const renderLiveInlineBlock = ( block: DocumentBlock, originalIndex: number, extraTextClass = 'text-main' ) => { const text = block.content || ''; if (isReadOnly) { return (
{renderFormattedText(text, block.id)}
); } const isEditing = activeEditingBlockId === block.id; if (!isEditing) { return (
{ if (isReadOnly) return; if ((e.target as HTMLElement).closest('button, a')) return; let clickOffset: number | undefined = undefined; try { if (document.caretRangeFromPoint) { const range = document.caretRangeFromPoint(e.clientX, e.clientY); if (range && e.currentTarget.contains(range.startContainer)) { const preCaretRange = range.cloneRange(); preCaretRange.selectNodeContents(e.currentTarget); preCaretRange.setEnd(range.endContainer, range.endOffset); clickOffset = preCaretRange.toString().length; } } else if ((document as any).caretPositionFromPoint) { const pos = (document as any).caretPositionFromPoint(e.clientX, e.clientY); if (pos && pos.offsetNode && e.currentTarget.contains(pos.offsetNode)) { const range = document.createRange(); range.selectNodeContents(e.currentTarget); range.setEnd(pos.offsetNode, pos.offset); clickOffset = range.toString().length; } } } catch { // fallback to end of text } focusBlock(block.id, clickOffset); }} className={`w-full text-sm leading-relaxed break-words whitespace-pre-wrap min-h-[22px] cursor-text ${extraTextClass}`} > {text ? ( renderFormattedText(text, block.id) ) : (   )}
); } return (