import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { Pin, Users, Trash2, Download, FileText, Loader2, ArrowLeft, Lock, Home, ChevronRight, Minimize2, Sparkles, Plus, Search, } from 'lucide-react'; import { Document, DocumentBlock, User } from '../../lib/types'; import { api, getToken } from '../../lib/api'; import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree'; export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error'; 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; onOpenTask?: (taskId: string) => void; onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void; } interface EmojiItem { emoji: string; category: string; keywords: string; } const EMOJI_CATALOG: EmojiItem[] = [ // Notizen & Dokumente { emoji: '📝', category: 'Notizen', keywords: 'notiz schreiben memo text note' }, { emoji: '📄', category: 'Notizen', keywords: 'dokument seite page doc' }, { emoji: '📋', category: 'Notizen', keywords: 'clipboard klemmbrett liste' }, { emoji: '📂', category: 'Notizen', keywords: 'ordner mappe folder datei' }, { emoji: '📁', category: 'Notizen', keywords: 'ordner folder directory' }, { emoji: '📌', category: 'Notizen', keywords: 'pin anheften bookmark wichtig' }, { emoji: '📍', category: 'Notizen', keywords: 'standort ort pin location' }, { emoji: '🏷️', category: 'Notizen', keywords: 'tag etikett label markierung' }, { emoji: '📚', category: 'Notizen', keywords: 'buch bibliothek lesen studium' }, { emoji: '📖', category: 'Notizen', keywords: 'buch open book wissen' }, // Ideen & Fokus { emoji: '🧠', category: 'Ideen', keywords: 'gehirn brain denken wissen thought mind' }, { emoji: '💡', category: 'Ideen', keywords: 'idee inspiration lampe glühbirne idea' }, { emoji: '🎯', category: 'Ideen', keywords: 'ziel target fokus mission okr' }, { emoji: '🚀', category: 'Ideen', keywords: 'rakete start launch projekt rocket' }, { emoji: '⚡', category: 'Ideen', keywords: 'blitz energie schnell quick power' }, { emoji: '🔥', category: 'Ideen', keywords: 'feuer flame priorität hot wichtig' }, { emoji: '⭐', category: 'Ideen', keywords: 'stern favoriten star top' }, { emoji: '✨', category: 'Ideen', keywords: 'funkeln magie neu neuigkeit sparkles' }, { emoji: '🧩', category: 'Ideen', keywords: 'puzzle teil lösung problem' }, { emoji: '🔬', category: 'Ideen', keywords: 'mikroskop forschung analyse test' }, // Tech & Dev { emoji: '💻', category: 'Tech', keywords: 'laptop computer dev code programming' }, { emoji: '🖥️', category: 'Tech', keywords: 'desktop monitor bildschirm workstation' }, { emoji: '🛠️', category: 'Tech', keywords: 'werkzeug tools optionen settings einstellungen' }, { emoji: '⚙️', category: 'Tech', keywords: 'zahnrad config settings system' }, { emoji: '📊', category: 'Tech', keywords: 'diagramm chart daten statistik dashboard' }, { emoji: '📈', category: 'Tech', keywords: 'wachstum trend kurve business' }, { emoji: '🔒', category: 'Tech', keywords: 'schloss sicherheit geheim privat secure' }, { emoji: '🔑', category: 'Tech', keywords: 'schlüssel key passwort access' }, { emoji: '🌐', category: 'Tech', keywords: 'web internet network welt globus' }, { emoji: '📦', category: 'Tech', keywords: 'paket box release lieferung package' }, // Alltag & Organisation { emoji: '💬', category: 'Alltag', keywords: 'chat nachricht kommentar call sprechen' }, { emoji: '✉️', category: 'Alltag', keywords: 'mail email brief post nachricht' }, { emoji: '📅', category: 'Alltag', keywords: 'kalender datum termin plan event' }, { emoji: '⏰', category: 'Alltag', keywords: 'wecker uhr zeit deadline alarm' }, { emoji: '🛒', category: 'Alltag', keywords: 'warenkorb einkauf shopping liste' }, { emoji: '☕', category: 'Alltag', keywords: 'kaffee pause bistro morning' }, { emoji: '🏠', category: 'Alltag', keywords: 'haus heim home wohnung' }, { emoji: '🏢', category: 'Alltag', keywords: 'büro firma arbeit office work' }, { emoji: '✈️', category: 'Alltag', keywords: 'flugzeug reise urlaub trip travel' }, { emoji: '🎨', category: 'Alltag', keywords: 'kunst farbe design creative' }, { emoji: '🏆', category: 'Alltag', keywords: 'pokal erfolg win trophy award' }, { emoji: '✅', category: 'Alltag', keywords: 'haken check done erledigt' }, ]; 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, autoFocusTitle, initialFocusedBlockId, onDocMetadataChange, onDeleteDoc, onOpenShareModal, onSelectTag, onWikiLinkClick, onBackMobile, onOpenTask, onSaveStatusChange, }) => { 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 [emojiSearch, setEmojiSearch] = useState(''); const [zoomedBlockId, setZoomedBlockId] = useState(null); const filteredEmojis = useMemo(() => { const q = emojiSearch.trim().toLowerCase(); if (!q) return EMOJI_CATALOG; return EMOJI_CATALOG.filter( (item) => item.keywords.includes(q) || item.emoji.includes(q) || item.category.toLowerCase().includes(q) ); }, [emojiSearch]); // AutoSave state (REST persistence) const [, 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 outlinerTreeRef = useRef(null); const docRef = useRef(doc); docRef.current = doc; const currentTitleRef = useRef(title); currentTitleRef.current = title; const currentBlocksRef = useRef(blocks); currentBlocksRef.current = blocks; const lastKnownUpdatedAtRef = useRef(initialDoc?.updatedAt || null); 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]); // Strictly Read-Only for shared documents: only the document owner can edit! const canEdit = useMemo(() => { if (!doc) return true; return Boolean(doc.isOwner || doc.permission === 'owner'); }, [doc]); // 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 snapshot 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) 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; lastKnownUpdatedAtRef.current = updated.updatedAt || null; 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, onSaveStatusChange, onDocMetadataChange] ); useEffect(() => { if (isInitialLoadRef.current) return; if (!canEdit) 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, canEdit, 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) { 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]); // Fetch document 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; }; }, [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]); const handleTitleChange = (e: React.ChangeEvent) => { if (!canEdit) return; setTitle(e.target.value); }; const handleTitleBlur = () => { executeAutoSave(); }; const handleUpdateBlock = useCallback( (updatedBlock: DocumentBlock, immediate: boolean = false) => { if (!canEdit) return; setBlocks((prev) => { const next = prev.map((b) => (b.id === updatedBlock.id ? updatedBlock : b)); currentBlocksRef.current = next; return next; }); if (immediate) { window.setTimeout(() => { executeAutoSave(true); }, 10); } }, [canEdit, executeAutoSave] ); const handleUpdateBlocks = useCallback( (updatedBlocks: DocumentBlock[]) => { if (!canEdit || updatedBlocks.length === 0) return; const map = new Map(); for (const b of updatedBlocks) { map.set(b.id, b); } setBlocks((prev) => prev.map((b) => map.get(b.id) || b) ); }, [canEdit] ); const handleCreateBlock = useCallback( (newBlock: DocumentBlock, afterBlockId?: string) => { if (!canEdit) 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); return ensureNonEmptyBlocks(docId, next); } } next.push(newBlock); return ensureNonEmptyBlocks(docId, next); }); }, [docId, canEdit] ); const handleInsertBlocks = useCallback( (afterBlockId: string, updatedCurrentBlock: DocumentBlock, newBlocks: DocumentBlock[]) => { if (!canEdit) return; setBlocks((prev) => { const next = [...prev]; const idx = next.findIndex((b) => b.id === afterBlockId); if (idx >= 0) { next[idx] = updatedCurrentBlock; next.splice(idx + 1, 0, ...newBlocks); } else { next.push(updatedCurrentBlock, ...newBlocks); } const normalized = ensureNonEmptyBlocks(docId, next); currentBlocksRef.current = normalized; return normalized; }); window.setTimeout(() => { executeAutoSave(true); }, 50); }, [docId, canEdit, executeAutoSave] ); const handleDeleteBlock = useCallback( (blockId: string) => { if (!canEdit) return; setBlocks((prev) => { const filtered = prev.filter((b) => b.id !== blockId); return ensureNonEmptyBlocks(docId, filtered); }); }, [docId, canEdit] ); const handleMoveBlock = useCallback( (blockId: string, targetIndex: number, newLevel?: number) => { if (!canEdit) return; setBlocks((prev) => { const fromIdx = prev.findIndex((b) => b.id === blockId); if (fromIdx === -1) return prev; const parentBlock = prev[fromIdx]; const parentLevel = Math.max(0, parentBlock.level || 0); // Calculate full subtree count (dragged block + all contiguous descendants) let count = 1; for (let i = fromIdx + 1; i < prev.length; i++) { if (Math.max(0, prev[i].level || 0) > parentLevel) { count++; } else { break; } } // Prevent dropping inside its own subtree if (targetIndex >= fromIdx && targetIndex < fromIdx + count) { return prev; } const copy = [...prev]; const subtree = copy.splice(fromIdx, count); // Adjust insertion index because removing elements from before shifts target index let insertIndex = targetIndex; if (fromIdx < targetIndex) { insertIndex = targetIndex - count; } insertIndex = Math.max(0, Math.min(insertIndex, copy.length)); // Calculate level adjustments if moving to a constrained position const prevBlockInPosition = insertIndex > 0 ? copy[insertIndex - 1] : null; const maxAllowed = prevBlockInPosition ? Math.max(0, prevBlockInPosition.level || 0) + 1 : 0; const targetLevel = newLevel !== undefined ? newLevel : parentLevel; const clampedLevel = Math.min(targetLevel, maxAllowed); const levelDelta = clampedLevel - parentLevel; const updatedSubtree = subtree.map((b) => ({ ...b, level: Math.max(0, (b.level || 0) + levelDelta), })); copy.splice(insertIndex, 0, ...updatedSubtree); return ensureNonEmptyBlocks(docId, copy); }); }, [docId, canEdit] ); 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 || !canEdit) 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 Action Buttons */}
{/* Sharing Section */} {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') && ( )}
{/* Read-only notification banner */} {!canEdit && (
Dieses Dokument wurde schreibgeschützt mit dir geteilt (Nur Lesen).
)} {/* Main Content Area */}
{zoomedBlock ? ( /* ========================================================================= FOKUS-MODUS & ZOOM-IN (HOISTED SUBTREE VIEW) ========================================================================= */
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={!canEdit} 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" />
{childBlocks.length === 0 ? (

Dieser Ast hat noch keine Unterpunkte.

{canEdit && ( )}
) : ( )}
) : ( /* ========================================================================= STANDARD GESAMTDOKUMENT-ANSICHT ========================================================================= */ <> {/* Document Icon + Title Header */}
{showEmojiPicker && canEdit && (
e.stopPropagation()} >
setEmojiSearch(e.target.value)} placeholder="Icon suchen oder Emoji eingeben…" className="input-field w-full pl-7 pr-2 py-1 text-xs" />
{doc?.icon && ( )} {emojiSearch.trim() && ( )}
{filteredEmojis.map((item) => ( ))} {filteredEmojis.length === 0 && (
Kein passendes Standard-Icon
)}
)}