import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { Pin, Users, Trash2, Download, FileText, Loader2, ArrowLeft, Hash, Lock, Home, ChevronRight, Minimize2, Sparkles, Plus, } from 'lucide-react'; import { Document, DocumentBlock, User, BlockLockInfo, LiveSessionInfo, WSMessage } from '../../lib/types'; import { api, getToken, onWebSocketMessage } from '../../lib/api'; import { useDocumentSync, DocSyncEvent, RemoteCursor, DocSyncStatus, getUserColor } from '../../lib/useDocumentSync'; import { OutlinerTree } from './OutlinerTree'; export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error'; export type DocLiveState = 'idle' | 'connecting' | 'active' | 'blocked' | 'locked_by_other'; export interface DocumentEditorProps { docId: string; currentUser?: User | null; initialDoc?: Document | null; availableFolders: string[]; autoFocusTitle?: boolean; initialFocusedBlockId?: string | null; onDocMetadataChange: (updated: Document) => void; onDeleteDoc: (docId: string) => void; onOpenShareModal: (doc: Document) => void; onSelectTag: (tag: string) => void; onWikiLinkClick?: (pageTitle: string) => void; onBackMobile?: () => void; onSyncStatusChange?: (status: DocSyncStatus) => void; onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void; onLiveStateChange?: (state: DocLiveState, initiatorName?: string) => void; registerLiveToggle?: (handler: () => void) => void; } const EMOJI_PALETTE = ['📝', '📄', '💡', '🚀', '📌', '📂', '🧠', '⚡', '🎯', '🛠️', '📊', '🔒']; function ensureNonEmptyBlocks(docId: string, blocks?: DocumentBlock[]): DocumentBlock[] { if (!blocks || blocks.length === 0) { return [ { id: `${docId}-b0`, documentId: docId, type: 'text', content: '', level: 0, position: 1000, }, ]; } return blocks.map((b, idx) => ({ ...b, id: b.id || `${docId}-b${idx}`, documentId: docId, type: b.type || 'text', level: Math.max(0, b.level || 0), position: b.position || (idx + 1) * 1000, })); } export interface BreadcrumbNode { id: string | null; title: string; } export function computeBreadcrumbs( docTitle: string, blocks: DocumentBlock[], zoomedBlockId: string | null ): BreadcrumbNode[] { if (!zoomedBlockId) return []; const zoomedIdx = blocks.findIndex((b) => b.id === zoomedBlockId); if (zoomedIdx === -1) return []; const trail: BreadcrumbNode[] = []; const zoomedBlock = blocks[zoomedIdx]; let currentLevel = zoomedBlock.level; for (let i = zoomedIdx - 1; i >= 0; i--) { if (blocks[i].level < currentLevel) { trail.unshift({ id: blocks[i].id, title: blocks[i].content?.trim() || 'Punkt', }); currentLevel = blocks[i].level; if (currentLevel === 0) break; } } trail.unshift({ id: null, title: docTitle || 'Dokument', }); trail.push({ id: zoomedBlock.id, title: zoomedBlock.content?.trim() || 'Punkt', }); return trail; } export function getZoomedSubtree( blocks: DocumentBlock[], zoomedBlockId: string | null ): { zoomedBlock: DocumentBlock | null; childBlocks: DocumentBlock[]; levelOffset: number; } { if (!zoomedBlockId) { return { zoomedBlock: null, childBlocks: blocks, levelOffset: 0 }; } const idx = blocks.findIndex((b) => b.id === zoomedBlockId); if (idx === -1) { return { zoomedBlock: null, childBlocks: blocks, levelOffset: 0 }; } const zoomedBlock = blocks[idx]; const zLevel = zoomedBlock.level; const childBlocks: DocumentBlock[] = []; for (let i = idx + 1; i < blocks.length; i++) { if (blocks[i].level > zLevel) { childBlocks.push(blocks[i]); } else { break; } } return { zoomedBlock, childBlocks, levelOffset: zLevel + 1, }; } export const DocumentEditor: React.FC = ({ docId, currentUser, initialDoc, availableFolders, autoFocusTitle, initialFocusedBlockId, onDocMetadataChange, onDeleteDoc, onOpenShareModal, onSelectTag, onWikiLinkClick, onBackMobile, onSyncStatusChange, onSaveStatusChange, onLiveStateChange, registerLiveToggle, }) => { const [doc, setDoc] = useState(initialDoc || null); const [title, setTitle] = useState(initialDoc?.title || ''); const [blocks, setBlocks] = useState(() => ensureNonEmptyBlocks(docId, initialDoc?.blocks) ); const [loading, setLoading] = useState(!initialDoc); const [error, setError] = useState(null); const [showEmojiPicker, setShowEmojiPicker] = useState(false); const [zoomedBlockId, setZoomedBlockId] = useState(null); // Live Collaboration & Locks state const [liveSession, setLiveSession] = useState(null); const [isLiveEnabled, setIsLiveEnabled] = useState(false); const [activeBlockLocks, setActiveBlockLocks] = useState>({}); // AutoSave state const [saveStatus, setSaveStatus] = useState('saved'); const [, setSaveErrorMessage] = useState(null); const saveTimeoutRef = useRef(null); const isSavingRef = useRef(false); const lastSavedSnapshotRef = useRef(''); const isInitialLoadRef = useRef(true); const titleInputRef = useRef(null); const titleDebounceRef = useRef(null); const docRef = useRef(doc); docRef.current = doc; const currentTitleRef = useRef(title); currentTitleRef.current = title; const currentBlocksRef = useRef(blocks); currentBlocksRef.current = blocks; useEffect(() => { if (!loading && (autoFocusTitle || (doc?.title === 'Neue Notiz' && (!doc?.blocks || doc?.blocks.length <= 1)))) { const focusTitle = () => { if (titleInputRef.current) { titleInputRef.current.focus(); titleInputRef.current.select(); } }; focusTitle(); const t1 = setTimeout(focusTitle, 50); const t2 = setTimeout(focusTitle, 150); return () => { clearTimeout(t1); clearTimeout(t2); }; } }, [loading, autoFocusTitle, doc?.id]); useEffect(() => { if (zoomedBlockId && !blocks.some((b) => b.id === zoomedBlockId)) { setZoomedBlockId(null); } }, [blocks, zoomedBlockId]); const { zoomedBlock, childBlocks, levelOffset } = useMemo(() => { return getZoomedSubtree(blocks, zoomedBlockId); }, [blocks, zoomedBlockId]); const breadcrumbs = useMemo(() => { return computeBreadcrumbs(title || doc?.title || 'Dokument', blocks, zoomedBlockId); }, [title, doc?.title, blocks, zoomedBlockId]); // Crucial fix: Collaborator with write permission can edit! const canEdit = useMemo(() => { if (!doc) return true; if (doc.isOwner) return true; return doc.permission === 'owner' || doc.permission === 'write'; }, [doc]); // Is this document completely locked because another user has an active live session? const isLockedByOtherLive = Boolean(liveSession && liveSession.initiatorId !== currentUser?.id); const effectiveCanEdit = canEdit && !isLockedByOtherLive; // Handle incoming WebSocket events for THIS document room const handleRemoteEvent = useCallback( (evt: DocSyncEvent) => { if (evt.docId && evt.docId !== docId) return; switch (evt.type) { case 'doc_state': { if (evt.title !== undefined) { const isTitleActivelyEditing = titleInputRef.current !== null && document.activeElement === titleInputRef.current && typeof document !== 'undefined' && document.hasFocus(); if (!isTitleActivelyEditing) { setTitle(evt.title); } } if (Array.isArray(evt.blocks) && evt.blocks.length > 0) { setBlocks(ensureNonEmptyBlocks(docId, evt.blocks)); } break; } case 'title_updated': { if (evt.title !== undefined) { const isTitleActivelyEditing = titleInputRef.current !== null && document.activeElement === titleInputRef.current && typeof document !== 'undefined' && document.hasFocus(); if (!isTitleActivelyEditing) { setTitle(evt.title); } if (docRef.current) { const updated = { ...docRef.current, title: evt.title }; setDoc(updated); onDocMetadataChange(updated); } } break; } case 'block_created': { if (!evt.block) return; const incoming = evt.block; setBlocks((prev) => { if (prev.some((b) => b.id === incoming.id)) { return prev.map((b) => (b.id === incoming.id ? incoming : b)); } const next = [...prev]; if (evt.afterBlockId) { const idx = next.findIndex((b) => b.id === evt.afterBlockId); if (idx >= 0) { next.splice(idx + 1, 0, incoming); return ensureNonEmptyBlocks(docId, next); } } next.push(incoming); return ensureNonEmptyBlocks(docId, next); }); break; } case 'block_updated': { const targetId = evt.blockId || evt.block?.id; if (!targetId) return; setBlocks((prev) => prev.map((b) => { if (b.id !== targetId) return b; if (evt.block) { return { ...b, ...evt.block }; } return { ...b, content: evt.content !== undefined ? evt.content : b.content, level: evt.level !== undefined ? evt.level : b.level, checked: evt.checked !== undefined ? evt.checked : b.checked, collapsed: evt.collapsed !== undefined ? evt.collapsed : b.collapsed, }; }) ); break; } case 'block_deleted': { if (!evt.blockId) return; setBlocks((prev) => ensureNonEmptyBlocks( docId, prev.filter((b) => b.id !== evt.blockId) ) ); break; } case 'block_moved': { if (!evt.blockId || evt.newIndex === undefined) return; const targetIndex = evt.newIndex; setBlocks((prev) => { const fromIdx = prev.findIndex((b) => b.id === evt.blockId); if (fromIdx === -1) return prev; const copy = [...prev]; const [moving] = copy.splice(fromIdx, 1); const updatedMoving: DocumentBlock = { ...moving, level: evt.level !== undefined ? evt.level : moving.level, }; const boundedIdx = Math.max(0, Math.min(targetIndex, copy.length)); copy.splice(boundedIdx, 0, updatedMoving); return ensureNonEmptyBlocks(docId, copy); }); break; } case 'blocks_replaced': { if (Array.isArray(evt.blocks)) { setBlocks(ensureNonEmptyBlocks(docId, evt.blocks)); } break; } } }, [docId, onDocMetadataChange] ); // Extract all #tags present across the current blocks const extractedTags = useMemo(() => { const tagSet = new Set(); for (const b of blocks) { const matches = b.content?.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g); if (matches) { for (const m of matches) { tagSet.add(m.slice(1).toLowerCase()); } } } return Array.from(tagSet); }, [blocks]); const currentTagsRef = useRef(extractedTags); currentTagsRef.current = extractedTags; // AutoSave implementation (authoritative persistence via HTTP REST) const currentSnapshot = useMemo(() => { return JSON.stringify({ title, blocks, tags: extractedTags, }); }, [title, blocks, extractedTags]); const currentSnapshotRef = useRef(currentSnapshot); currentSnapshotRef.current = currentSnapshot; const executeAutoSave = useCallback( async (immediate: boolean = false) => { if (!docId || !canEdit || isLockedByOtherLive) return; if (isSavingRef.current && !immediate) return; const snapshot = currentSnapshotRef.current; if (snapshot === lastSavedSnapshotRef.current) { setSaveStatus('saved'); onSaveStatusChange?.('saved'); return; } isSavingRef.current = true; setSaveStatus('saving'); onSaveStatusChange?.('saving'); try { const updated = await api.updateDocument(docId, { title: currentTitleRef.current, blocks: currentBlocksRef.current, tags: currentTagsRef.current, }); isSavingRef.current = false; lastSavedSnapshotRef.current = snapshot; setSaveStatus('saved'); setSaveErrorMessage(null); onSaveStatusChange?.('saved'); if (docRef.current) { docRef.current = updated; onDocMetadataChange(updated); } } catch (err: any) { isSavingRef.current = false; const msg = err?.message || 'Fehler beim Speichern'; setSaveStatus('error'); setSaveErrorMessage(msg); onSaveStatusChange?.('error', msg); } }, [docId, canEdit, isLockedByOtherLive, onSaveStatusChange, onDocMetadataChange] ); useEffect(() => { if (isInitialLoadRef.current) return; if (currentSnapshot === lastSavedSnapshotRef.current) return; setSaveStatus('unsaved'); onSaveStatusChange?.('unsaved'); if (saveTimeoutRef.current !== null) { window.clearTimeout(saveTimeoutRef.current); } saveTimeoutRef.current = window.setTimeout(() => { saveTimeoutRef.current = null; executeAutoSave(); }, 800); return () => { if (saveTimeoutRef.current !== null) { window.clearTimeout(saveTimeoutRef.current); saveTimeoutRef.current = null; } }; }, [currentSnapshot, executeAutoSave, onSaveStatusChange]); // Flush on page unload / pagehide via keepalive fetch useEffect(() => { const handleUnload = () => { if (saveTimeoutRef.current !== null) { window.clearTimeout(saveTimeoutRef.current); saveTimeoutRef.current = null; } const snapshot = currentSnapshotRef.current; if (snapshot !== lastSavedSnapshotRef.current && canEdit && !isLockedByOtherLive) { const token = getToken(); if (token) { fetch(`/api/v1/documents/${docId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, body: JSON.stringify({ title: currentTitleRef.current, blocks: currentBlocksRef.current, tags: currentTagsRef.current, }), keepalive: true, }).catch(() => {}); } } }; window.addEventListener('beforeunload', handleUnload); window.addEventListener('pagehide', handleUnload); return () => { window.removeEventListener('beforeunload', handleUnload); window.removeEventListener('pagehide', handleUnload); handleUnload(); }; }, [docId, canEdit, isLockedByOtherLive]); const { status: syncStatus, remoteCursors, sendCursorMoved, sendBlockCreated, sendBlockUpdated, sendBlockDeleted, sendBlockMoved, sendTitleUpdated, } = useDocumentSync(docId, handleRemoteEvent, effectiveCanEdit, currentUser, isLiveEnabled); useEffect(() => { onSyncStatusChange?.(syncStatus); }, [syncStatus, onSyncStatusChange]); const computedLiveState: DocLiveState = useMemo(() => { if (isLockedByOtherLive) return 'locked_by_other'; if (!isLiveEnabled) return 'idle'; if (syncStatus === 'connecting' || syncStatus === 'reconnecting') return 'connecting'; if (syncStatus === 'blocked') return 'blocked'; if (syncStatus === 'connected') return 'active'; return 'idle'; }, [isLockedByOtherLive, isLiveEnabled, syncStatus]); useEffect(() => { onLiveStateChange?.(computedLiveState, liveSession?.initiatorName); }, [computedLiveState, liveSession?.initiatorName, onLiveStateChange]); const handleToggleLive = useCallback(() => { if (isLiveEnabled) { api.endLiveSession(docId).catch(() => {}); setIsLiveEnabled(false); setLiveSession(null); } else { if (isLockedByOtherLive) return; api.startLiveSession(docId) .then((session) => { setLiveSession(session); setIsLiveEnabled(true); }) .catch((err) => { console.error('[DocumentEditor] Failed to start live session:', err); }); } }, [docId, isLiveEnabled, isLockedByOtherLive]); useEffect(() => { if (registerLiveToggle) { registerLiveToggle(handleToggleLive); } }, [registerLiveToggle, handleToggleLive]); // Load initial locks on mount useEffect(() => { let cancelled = false; api.getDocLocks(docId) .then((res) => { if (cancelled) return; if (res.liveSession) { setLiveSession(res.liveSession); if (res.liveSession.initiatorId === currentUser?.id) { setIsLiveEnabled(true); } } if (Array.isArray(res.blockLocks)) { const map: Record = {}; for (const l of res.blockLocks) { map[l.blockId] = l; } setActiveBlockLocks(map); } }) .catch(() => {}); return () => { cancelled = true; }; }, [docId, currentUser?.id]); // Subscribe to realtime lock & live session events useEffect(() => { const unsub = onWebSocketMessage((msg: WSMessage) => { if (msg.event === 'DOC_BLOCK_LOCKED' && msg.payload?.docId === docId) { setActiveBlockLocks((prev) => ({ ...prev, [msg.payload.blockId]: msg.payload, })); } else if (msg.event === 'DOC_BLOCK_UNLOCKED' && msg.payload?.docId === docId) { setActiveBlockLocks((prev) => { const next = { ...prev }; delete next[msg.payload.blockId]; return next; }); } else if (msg.event === 'DOC_LIVE_STARTED' && msg.payload?.docId === docId) { setLiveSession(msg.payload); } else if (msg.event === 'DOC_LIVE_ENDED' && msg.payload?.docId === docId) { setLiveSession(null); if (liveSession?.initiatorId === currentUser?.id) { setIsLiveEnabled(false); } } }); return () => { unsub(); }; }, [docId, currentUser?.id, liveSession?.initiatorId]); // Block focus/blur locks const localUserColor = useMemo( () => getUserColor(currentUser?.id || 'anon'), [currentUser?.id] ); const focusedBlockIdRef = useRef(null); const lockKeepaliveTimerRef = useRef(null); const handleFocusBlock = useCallback( (blockId: string) => { if (!effectiveCanEdit) return; focusedBlockIdRef.current = blockId; api.lockBlock(docId, blockId, localUserColor, 30).catch(() => {}); if (lockKeepaliveTimerRef.current !== null) { window.clearInterval(lockKeepaliveTimerRef.current); } lockKeepaliveTimerRef.current = window.setInterval(() => { if (focusedBlockIdRef.current === blockId) { api.lockBlock(docId, blockId, localUserColor, 30).catch(() => {}); } }, 15000); }, [docId, effectiveCanEdit, localUserColor] ); const handleBlurBlock = useCallback( (blockId: string) => { if (lockKeepaliveTimerRef.current !== null) { window.clearInterval(lockKeepaliveTimerRef.current); lockKeepaliveTimerRef.current = null; } if (focusedBlockIdRef.current === blockId) { focusedBlockIdRef.current = null; } api.unlockBlock(docId, blockId).catch(() => {}); executeAutoSave(); }, [docId, executeAutoSave] ); useEffect(() => { return () => { if (lockKeepaliveTimerRef.current !== null) { window.clearInterval(lockKeepaliveTimerRef.current); lockKeepaliveTimerRef.current = null; } if (focusedBlockIdRef.current) { api.unlockBlock(docId, focusedBlockIdRef.current).catch(() => {}); } }; }, [docId]); // Fetch isolated document state on mount useEffect(() => { let isCancelled = false; if (!initialDoc) { setLoading(true); } setError(null); api .getDocument(docId) .then((fetched) => { if (isCancelled) return; const normalized = ensureNonEmptyBlocks(docId, fetched.blocks); setDoc(fetched); setTitle(fetched.title || 'Unbenannte Notiz'); setBlocks(normalized); setLoading(false); onDocMetadataChange(fetched); const initialTags: string[] = []; for (const b of normalized) { const matches = b.content?.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g); if (matches) { for (const m of matches) { const tag = m.slice(1).toLowerCase(); if (!initialTags.includes(tag)) initialTags.push(tag); } } } lastSavedSnapshotRef.current = JSON.stringify({ title: fetched.title || 'Unbenannte Notiz', blocks: normalized, tags: initialTags, }); isInitialLoadRef.current = false; setSaveStatus('saved'); onSaveStatusChange?.('saved'); }) .catch((err: any) => { if (isCancelled) return; setError(err?.message || 'Dokument konnte nicht geladen werden'); setLoading(false); }); return () => { isCancelled = true; if (titleDebounceRef.current !== null) { window.clearTimeout(titleDebounceRef.current); titleDebounceRef.current = null; } }; }, [docId]); // Keep parent sidebar metadata in sync when blocks/tags change useEffect(() => { if (!docRef.current) return; const updatedDoc: Document = { ...docRef.current, title, blocks, tags: extractedTags, updatedAt: new Date().toISOString(), }; docRef.current = updatedDoc; onDocMetadataChange(updatedDoc); }, [blocks, extractedTags, title]); // Active remote users in this room (excluding self) const activeUsers = useMemo(() => { const list: RemoteCursor[] = []; const seen = new Set(); for (const c of Object.values(remoteCursors)) { const key = c.userId || c.clientId; if (!seen.has(key)) { seen.add(key); list.push(c); } } return list; }, [remoteCursors]); const handleTitleChange = (e: React.ChangeEvent) => { if (!effectiveCanEdit) return; const nextTitle = e.target.value; setTitle(nextTitle); if (titleDebounceRef.current !== null) { window.clearTimeout(titleDebounceRef.current); } titleDebounceRef.current = window.setTimeout(() => { titleDebounceRef.current = null; if (isLiveEnabled) { sendTitleUpdated(nextTitle); } }, 250); }; const handleTitleBlur = () => { if (titleDebounceRef.current !== null) { window.clearTimeout(titleDebounceRef.current); titleDebounceRef.current = null; if (isLiveEnabled) { sendTitleUpdated(title); } } executeAutoSave(); }; // Flush title debouncing on window blur so background window never holds stale pending title useEffect(() => { const handleWindowBlur = () => { if (titleDebounceRef.current !== null) { window.clearTimeout(titleDebounceRef.current); titleDebounceRef.current = null; if (isLiveEnabled) { sendTitleUpdated(title); } } }; window.addEventListener('blur', handleWindowBlur); return () => window.removeEventListener('blur', handleWindowBlur); }, [title, isLiveEnabled, sendTitleUpdated]); const handleUpdateBlock = useCallback( (updatedBlock: DocumentBlock) => { if (!effectiveCanEdit) return; setBlocks((prev) => prev.map((b) => (b.id === updatedBlock.id ? updatedBlock : b)) ); if (isLiveEnabled) { sendBlockUpdated(updatedBlock); } }, [effectiveCanEdit, isLiveEnabled, sendBlockUpdated] ); const handleCreateBlock = useCallback( (newBlock: DocumentBlock, afterBlockId?: string) => { if (!effectiveCanEdit) return; setBlocks((prev) => { const next = [...prev]; if (afterBlockId) { const idx = next.findIndex((b) => b.id === afterBlockId); if (idx >= 0) { next.splice(idx + 1, 0, newBlock); const normalized = ensureNonEmptyBlocks(docId, next); if (isLiveEnabled) { sendBlockCreated(newBlock, afterBlockId, normalized); } return normalized; } } next.push(newBlock); const normalized = ensureNonEmptyBlocks(docId, next); if (isLiveEnabled) { sendBlockCreated(newBlock, afterBlockId, normalized); } return normalized; }); }, [docId, effectiveCanEdit, isLiveEnabled, sendBlockCreated] ); const handleDeleteBlock = useCallback( (blockId: string) => { if (!effectiveCanEdit) return; setBlocks((prev) => { const filtered = prev.filter((b) => b.id !== blockId); const normalized = ensureNonEmptyBlocks(docId, filtered); if (isLiveEnabled) { sendBlockDeleted(blockId, normalized); } return normalized; }); }, [docId, effectiveCanEdit, isLiveEnabled, sendBlockDeleted] ); const handleMoveBlock = useCallback( (blockId: string, newIndex: number, level?: number) => { if (!effectiveCanEdit) return; setBlocks((prev) => { const fromIdx = prev.findIndex((b) => b.id === blockId); if (fromIdx === -1) return prev; const copy = [...prev]; const [moving] = copy.splice(fromIdx, 1); const updatedMoving: DocumentBlock = { ...moving, level: level !== undefined ? level : moving.level, }; const boundedIdx = Math.max(0, Math.min(newIndex, copy.length)); copy.splice(boundedIdx, 0, updatedMoving); const normalized = ensureNonEmptyBlocks(docId, copy); if (isLiveEnabled) { sendBlockMoved(blockId, boundedIdx, updatedMoving.level, normalized); } return normalized; }); }, [docId, effectiveCanEdit, isLiveEnabled, sendBlockMoved] ); const handleTogglePin = async () => { if (!doc) return; try { const updated = await api.updateDocument(doc.id, { isPinned: !doc.isPinned }); setDoc(updated); onDocMetadataChange(updated); } catch { // ignore } }; const handleSelectIcon = async (icon: string) => { setShowEmojiPicker(false); if (!doc || !effectiveCanEdit) return; try { const updated = await api.updateDocument(doc.id, { icon }); setDoc(updated); onDocMetadataChange(updated); } catch { // ignore } }; const handleExportMarkdown = () => { const lines: string[] = [`# ${title || 'Unbenannte Notiz'}`, '']; for (const b of blocks) { const indent = ' '.repeat(Math.max(0, b.level || 0)); if (b.type === 'todo') { lines.push(`${indent}- [${b.checked ? 'x' : ' '}] ${b.content}`); } else if (b.type === 'h1') { lines.push(`${indent}- # ${b.content}`); } else if (b.type === 'h2') { lines.push(`${indent}- ## ${b.content}`); } else if (b.type === 'h3') { lines.push(`${indent}- ### ${b.content}`); } else if (b.type === 'code') { lines.push(`${indent}- \`\`\`${b.lang || ''}\n${b.content}\n${indent} \`\`\``); } else if (b.type === 'divider') { lines.push(`${indent}- ---`); } else { 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 = `${(title || 'notiz').toLowerCase().replace(/[^a-z0-9äöüß_-]+/gi, '-')}.md`; a.click(); URL.revokeObjectURL(url); }; if (loading) { return (
Notiz wird geladen…
); } if (error) { return (

{error}

{onBackMobile && ( )}
); } return (
{/* Top Document Action Bar */}
{onBackMobile && ( )} {/* Breadcrumbs Navigation (Fokus-Modus) or Doc Indicator */} {zoomedBlock && breadcrumbs.length > 0 ? ( ) : (
{title || doc?.title || 'Dokument'}
)} {/* Read-only indicator */} {!canEdit && ( Nur Lesen )}
{/* Right Status & Action Buttons */}
{/* Active Presence Avatars (Immer rund: rounded-full mit Überlappung) */} {activeUsers.length > 0 && (
{activeUsers.map((u) => { const initials = (u.userName || 'A') .split(' ') .map((n) => n[0]) .slice(0, 2) .join('') .toUpperCase(); return (
{initials}
{/* Tooltip */}
{u.userName}
); })}
)} {/* Sharing Section (1:1 styling from SharedListsView) */} {doc && (() => { const isOwner = doc.isOwner !== false && (doc.userId === currentUser?.id || !doc.userId || currentUser?.role === 'admin'); const sharesCount = doc.sharedWith?.length || 0; const isShared = sharesCount > 0 || !isOwner || Boolean(doc.sharedFolderId); const ownerDisplayName = doc.ownerName || 'Eigentümer'; return (
{isShared && ( isOwner ? ( ) : ( ) )}
); })()} {canEdit && (doc?.isOwner || doc?.permission === 'owner') && ( )}
{/* Live Session Active Banner (when another collaborator is live) */} {isLockedByOtherLive && (
Live-Sitzung aktiv (Bearbeitet von {liveSession?.initiatorName || 'anderer Nutzer'}) – Dokument schreibgeschützt, um Versionskonflikte zu vermeiden.
)} {/* Main Content Area */}
{zoomedBlock ? ( /* ========================================================================= FOKUS-MODUS & ZOOM-IN (HOISTED SUBTREE VIEW) ========================================================================= */
{/* Prominent Zoomed Ast-Heading */}
Fokus-Modus Isolierter Ast • Zoom aktiv
{ handleUpdateBlock({ ...zoomedBlock, content: e.target.value }); }} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); const firstChild: DocumentBlock = { id: `blk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`, documentId: docId, type: 'text', content: '', level: levelOffset, position: (zoomedBlock.position || 1000) + 10, }; handleCreateBlock(firstChild, zoomedBlock.id); } }} placeholder="Ast-Titel…" readOnly={!effectiveCanEdit} className="w-full text-2xl sm:text-3xl font-extrabold text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight leading-tight" />
{/* Subtree Children or Empty State */} {childBlocks.length === 0 ? (

Dieser Ast hat noch keine Unterpunkte.

{effectiveCanEdit && ( )}
) : ( )}
) : ( /* ========================================================================= STANDARD GESAMTDOKUMENT-ANSICHT ========================================================================= */ <> {/* Document Icon + Title Header */}
{showEmojiPicker && effectiveCanEdit && (
{EMOJI_PALETTE.map((emoji) => ( ))}
)}
{/* Logseq Outliner Tree */} )}
); };