import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { Pin, Users, Trash2, RotateCcw, Download, FileText, Loader2, ArrowLeft, Lock, Home, ChevronRight, Minimize2, Sparkles, Plus, Check, CloudOff, AlertTriangle, GitMerge, } from 'lucide-react'; import { Document, DocumentBlock, User } from '../../lib/types'; import { api } from '../../lib/api'; import { DocSyncEngine, EngineState } from '../../lib/docSync/engine'; import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree'; import { PresenceAvatars } from './PresenceAvatars'; import { extractHashtags } from '../../lib/hashtags'; 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; onRestoreDoc?: (docId: string) => void; onOpenShareModal: (doc: Document) => void; onSelectTag: (tag: string) => void; onWikiLinkClick?: (pageTitle: string) => void; onOpenDocBlock?: (docId: string, blockId?: string) => void; onBackMobile?: () => void; onOpenTask?: (taskId: string) => void; onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void; /** Opens the dialog to merge this note into another one (own notes only) */ onRequestMerge?: (doc: Document) => void; } import { EmojiPickerModal } from './EmojiPickerModal'; import { BacklinksSection } from './BacklinksSection'; 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, })); } // True if both lists show the same blocks; avoids re-rendering when the sync engine echoes a local change. function sameBlocks(a: DocumentBlock[], b: DocumentBlock[]): boolean { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) { const x = a[i]; const y = b[i]; if ( x.id !== y.id || x.content !== y.content || (x.level || 0) !== (y.level || 0) || x.type !== y.type || !!x.checked !== !!y.checked || !!x.collapsed !== !!y.collapsed || (x.lang || '') !== (y.lang || '') || (x.taskId || '') !== (y.taskId || '') || (x.conflictOf || '') !== (y.conflictOf || '') ) { return false; } } return true; } 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, onRestoreDoc, onOpenDocBlock, onOpenShareModal, onSelectTag, onWikiLinkClick, onBackMobile, onOpenTask, onSaveStatusChange, onRequestMerge, }) => { 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); // Sync engine (operation log): stores every change locally and syncs it with the server const engineRef = useRef(null); const [engineState, setEngineState] = useState(null); 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; 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]); // Owner and editors may edit; the role comes from the server via the sync engine const inTrash = Boolean(doc?.isArchived); const canEdit = useMemo(() => { if (doc?.isArchived) return false; if (engineState?.role) return engineState.role === 'owner' || engineState.role === 'editor'; if (!doc) return false; return Boolean(doc.isOwner || doc.permission === 'owner' || doc.permission === 'write'); }, [doc, engineState?.role]); // Extract all #tags present across the current blocks const extractedTags = useMemo(() => { const tagSet = new Set(); for (const b of blocks) { for (const tag of extractHashtags(b.content)) tagSet.add(tag); } return Array.from(tagSet); }, [blocks]); // 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); if (!engineRef.current?.getState().ready) { setTitle(fetched.title || 'Unbenannte Notiz'); currentBlocksRef.current = normalized; setBlocks(normalized); } const links = new Map(); for (const b of fetched.blocks || []) { if (b.taskId) links.set(b.id, b.taskId); } engineRef.current?.setTaskLinks(links); setLoading(false); onDocMetadataChange(fetched); }) .catch((err: any) => { if (isCancelled) return; setError(err?.message || 'Dokument konnte nicht geladen werden'); setLoading(false); }); return () => { isCancelled = true; }; }, [docId]); // Start the sync engine for this note: shows stored state immediately (also offline), then syncs useEffect(() => { const engine = new DocSyncEngine(docId); engineRef.current = engine; const unsubscribe = engine.subscribe((state) => { setEngineState(state); if (!state.ready) return; const next = ensureNonEmptyBlocks(docId, state.blocks); if (!sameBlocks(currentBlocksRef.current, next)) { currentBlocksRef.current = next; setBlocks(next); } if (state.title && state.title !== currentTitleRef.current) { setTitle(state.title); } }); void engine.start(); return () => { unsubscribe(); engine.stop(); if (engineRef.current === engine) engineRef.current = null; }; }, [docId]); useEffect(() => { if (!engineState) return; switch (engineState.status) { case 'saving': onSaveStatusChange?.('saving'); break; case 'offline': onSaveStatusChange?.('unsaved', 'Offline – Änderungen sind lokal gesichert und werden gesendet, sobald die Verbindung zurück ist'); break; case 'error': onSaveStatusChange?.('error', engineState.errorMessage); break; default: onSaveStatusChange?.('saved'); } }, [engineState?.status, engineState?.errorMessage]); // Blocks other users are editing right now (blockId -> name) const lockedBlocks = useMemo(() => { const m = new Map(); for (const l of engineState?.lockedByOthers || []) m.set(l.blockId, l.name || 'Jemand'); return m; }, [engineState?.lockedByOthers]); const otherPeople = useMemo( () => (engineState?.presence || []).filter((p) => p.userId !== currentUser?.id), [engineState?.presence, currentUser?.id] ); // Presence only carries names; profile pictures come from the user directory, loaded once // the first time someone else shows up (avatars can be data URLs, too big for every sync) const [avatarsById, setAvatarsById] = useState>(new Map()); const avatarsRequestedRef = useRef(false); useEffect(() => { if (otherPeople.length === 0 || avatarsRequestedRef.current) return; avatarsRequestedRef.current = true; api.getUsersDirectory() .then((users) => { const m = new Map(); for (const u of users) if (u.avatarUrl) m.set(u.id, u.avatarUrl); setAvatarsById(m); }) .catch(() => {}); }, [otherPeople.length]); // Short hint at the bottom of the editor (blocked actions, skipped undo), optionally with one action const [notice, setNotice] = useState(null); const [noticeAction, setNoticeAction] = useState<{ label: string; run: () => void } | null>(null); const noticeTimerRef = useRef(null); const showNotice = useCallback((message: string, action?: { label: string; run: () => void }) => { setNotice(message); setNoticeAction(action || null); if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current); noticeTimerRef.current = window.setTimeout(() => { setNotice(null); setNoticeAction(null); }, action ? 7000 : 3500); }, []); // A bullet with its children becomes a note of its own; the server swaps the branch for a [[link]] // and this editor receives that change through the normal sync const handleExtractBlock = useCallback( async (blockId: string) => { const engine = engineRef.current; try { await engine?.syncNow(); // the branch as typed so far const created = await api.extractBranch(docId, blockId, engine?.clientId || ''); await engine?.syncNow(); showNotice(`In eigene Notiz „${created.title}“ umgewandelt`, { label: 'Öffnen', run: () => onOpenDocBlock?.(created.id), }); } catch (err: any) { showNotice(err?.message || 'Umwandeln fehlgeschlagen'); } }, [docId, onOpenDocBlock, showNotice] ); useEffect(() => () => { if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current); }, []); const handleLockedAction = useCallback( (holder: string) => showNotice(`${holder} bearbeitet diesen Punkt gerade – bitte kurz warten`), [showNotice] ); const handleUndo = useCallback(() => { const res = engineRef.current?.undo(); if (res?.message) showNotice(res.message); }, [showNotice]); const handleRedo = useCallback(() => { const res = engineRef.current?.redo(); if (res?.message) showNotice(res.message); }, [showNotice]); const handleResolveConflict = useCallback( (blockId: string, accept: boolean) => { const res = engineRef.current?.resolveConflict(blockId, accept); if (res?.message) showNotice(res.message); }, [showNotice] ); // Every local block change goes to the engine, which turns it into operations const applyLocalBlocks = useCallback( (update: DocumentBlock[] | ((prev: DocumentBlock[]) => DocumentBlock[])) => { const prev = currentBlocksRef.current; const next = typeof update === 'function' ? update(prev) : update; if (next === prev) return; currentBlocksRef.current = next; setBlocks(next); engineRef.current?.applyFlat(next); }, [] ); // 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); engineRef.current?.setTitle(e.target.value); }; const handleTitleBlur = () => { void engineRef.current?.syncNow(); }; const handleUpdateBlock = useCallback( (updatedBlock: DocumentBlock) => { if (!canEdit) return; applyLocalBlocks((prev) => prev.map((b) => (b.id === updatedBlock.id ? updatedBlock : b))); }, [canEdit, applyLocalBlocks] ); 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); } applyLocalBlocks((prev) => prev.map((b) => map.get(b.id) || b)); }, [canEdit, applyLocalBlocks] ); const handleCreateBlock = useCallback( (newBlock: DocumentBlock, afterBlockId?: string) => { if (!canEdit) return; applyLocalBlocks((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, applyLocalBlocks] ); const handleInsertBlocks = useCallback( (afterBlockId: string, updatedCurrentBlock: DocumentBlock, newBlocks: DocumentBlock[]) => { if (!canEdit) return; applyLocalBlocks((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); } return ensureNonEmptyBlocks(docId, next); }); }, [docId, canEdit, applyLocalBlocks] ); const handleDeleteBlock = useCallback( (blockId: string) => { if (!canEdit) return; applyLocalBlocks((prev) => ensureNonEmptyBlocks(docId, prev.filter((b) => b.id !== blockId))); }, [docId, canEdit, applyLocalBlocks] ); const handleMoveBlock = useCallback( (blockId: string, targetIndex: number, newLevel?: number) => { if (!canEdit) return; applyLocalBlocks((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, applyLocalBlocks] ); 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'}
)} {/* Save status on phones (the desktop shows it above the workspace) */} {engineState && ( {engineState.status === 'saving' ? ( ) : engineState.status === 'offline' ? ( ) : engineState.status === 'error' ? ( ) : ( )} )} {/* Read-only indicator */} {!canEdit && ( Nur Lesen )}
{/* Right Action Buttons */}
{/* How changes of others arrive (only relevant for shared notes) */} {engineState && (engineState.shared || engineState.sessions > 1) && ( {engineState.channel === 'longpoll' ? 'Live (Long-Polling)' : engineState.channel === 'interval' ? 'Abgleich alle 3 s' : 'Live'} )} {/* Who else has this note open */} {/* Sharing Section */} {doc && !inTrash && (() => { 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 ? ( ) : ( ) )} {onRequestMerge && doc.isOwner && !inTrash && ( )}
); })()} {!inTrash && } {canEdit && (doc?.isOwner || doc?.permission === 'owner') && ( )}
{/* In the trash: read-only with a way back */} {inTrash && (
Diese Notiz liegt im Papierkorb und ist schreibgeschützt. {onRestoreDoc && ( )}
)} {/* Read-only notification banner */} {!canEdit && !inTrash && (
Dieses Dokument wurde schreibgeschützt mit dir geteilt (Nur Lesen).
)} {/* Main Content Area */} {/* Phones: almost no side margin; very deep outlines scroll sideways (rows keep a minimum width) */}
{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 && ( )}
) : ( engineRef.current?.focusBlock(id)} lockedBlocks={lockedBlocks} onLockedAction={handleLockedAction} onResolveConflict={handleResolveConflict} onUndo={handleUndo} onRedo={handleRedo} /> )}
) : ( /* ========================================================================= STANDARD GESAMTDOKUMENT-ANSICHT ========================================================================= */ <> {/* Document Icon + Title Header */}
{showEmojiPicker && canEdit && ( setShowEmojiPicker(false)} /> )}