Files
MindSift/frontend/src/features/workspace/DocumentEditor.tsx
T
sandrodach 6a261e3453
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
feat(workspace): intelligent multi-line clipboard paste with markdown prefix & indentation parsing
2026-10-27 21:27:01 +01:00

1123 lines
41 KiB
TypeScript

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<DocumentEditorProps> = ({
docId,
currentUser,
initialDoc,
autoFocusTitle,
initialFocusedBlockId,
onDocMetadataChange,
onDeleteDoc,
onOpenShareModal,
onSelectTag,
onWikiLinkClick,
onBackMobile,
onOpenTask,
onSaveStatusChange,
}) => {
const [doc, setDoc] = useState<Document | null>(initialDoc || null);
const [title, setTitle] = useState<string>(initialDoc?.title || '');
const [blocks, setBlocks] = useState<DocumentBlock[]>(() =>
ensureNonEmptyBlocks(docId, initialDoc?.blocks)
);
const [loading, setLoading] = useState<boolean>(!initialDoc);
const [error, setError] = useState<string | null>(null);
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
const [emojiSearch, setEmojiSearch] = useState<string>('');
const [zoomedBlockId, setZoomedBlockId] = useState<string | null>(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<DocSaveStatus>('saved');
const [, setSaveErrorMessage] = useState<string | null>(null);
const saveTimeoutRef = useRef<number | null>(null);
const isSavingRef = useRef<boolean>(false);
const lastSavedSnapshotRef = useRef<string>('');
const isInitialLoadRef = useRef<boolean>(true);
const titleInputRef = useRef<HTMLTextAreaElement | HTMLInputElement | null>(null);
const outlinerTreeRef = useRef<OutlinerTreeHandle | null>(null);
const docRef = useRef<Document | null>(doc);
docRef.current = doc;
const currentTitleRef = useRef<string>(title);
currentTitleRef.current = title;
const currentBlocksRef = useRef<DocumentBlock[]>(blocks);
currentBlocksRef.current = blocks;
const lastKnownUpdatedAtRef = useRef<string | null>(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<string>();
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<string[]>(extractedTags);
currentTagsRef.current = extractedTags;
// AutoSave snapshot
const currentSnapshot = useMemo(() => {
return JSON.stringify({
title,
blocks,
tags: extractedTags,
});
}, [title, blocks, extractedTags]);
const currentSnapshotRef = useRef<string>(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<HTMLInputElement | HTMLTextAreaElement>) => {
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<string, DocumentBlock>();
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 (
<div className="flex-1 flex items-center justify-center p-12 text-muted">
<Loader2 className="w-5 h-5 animate-spin mr-2 text-accent" />
<span className="text-xs">Notiz wird geladen…</span>
</div>
);
}
if (error) {
return (
<div className="flex-1 flex flex-col items-center justify-center p-12 text-center">
<p className="text-xs text-status-err font-medium mb-2">{error}</p>
{onBackMobile && (
<button
type="button"
onClick={onBackMobile}
className="btn-action px-3 py-1.5 text-xs text-main"
>
Zurück zur Übersicht
</button>
)}
</div>
);
}
return (
<div className="flex-1 flex flex-col h-full min-h-0 bg-canvas overflow-y-auto">
{/* Top Document Action Bar */}
<div className="sticky top-0 z-20 flex flex-wrap items-center justify-between gap-2 px-4 sm:px-6 py-2 bg-canvas border-b border-border">
<div className="flex items-center gap-2 min-w-0">
{onBackMobile && (
<button
type="button"
onClick={onBackMobile}
className="lg:hidden btn-icon"
title="Zurück zur Seitenleiste"
>
<ArrowLeft className="w-3.5 h-3.5" />
</button>
)}
{/* Breadcrumbs Navigation (Fokus-Modus) or Doc Indicator */}
{zoomedBlock && breadcrumbs.length > 0 ? (
<nav
aria-label="Breadcrumb Navigation"
className="flex items-center flex-wrap gap-1 text-xs text-muted"
>
<button
type="button"
onClick={() => setZoomedBlockId(null)}
className="hover:text-accent inline-flex items-center gap-1 font-medium transition-colors cursor-pointer"
title="Zurück zum Hauptdokument"
>
<Home className="w-3.5 h-3.5 text-accent shrink-0" />
<span className="truncate max-w-[130px]">
{title || doc?.title || 'Dokument'}
</span>
</button>
{breadcrumbs.slice(1).map((crumb, idx) => {
const isCurrent = idx === breadcrumbs.length - 2;
return (
<React.Fragment key={crumb.id || idx}>
<ChevronRight className="w-3 h-3 text-faint shrink-0" />
{isCurrent ? (
<span
className="font-bold text-accent truncate max-w-[160px]"
title={crumb.title}
>
{crumb.title || 'Punkt'}
</span>
) : (
<button
type="button"
onClick={() => setZoomedBlockId(crumb.id)}
className="hover:text-main truncate max-w-[120px] transition-colors cursor-pointer"
title={`Zoom zu "${crumb.title}"`}
>
{crumb.title || 'Punkt'}
</button>
)}
</React.Fragment>
);
})}
</nav>
) : (
<div className="flex items-center gap-1.5 text-xs text-muted">
<FileText className="w-3.5 h-3.5 text-accent shrink-0" />
<span className="truncate max-w-[180px] font-medium text-main">
{title || doc?.title || 'Dokument'}
</span>
</div>
)}
{/* Read-only indicator */}
{!canEdit && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-btn text-[11px] font-medium bg-status-warn/10 text-status-warn border border-status-warn/30">
<Lock className="w-3 h-3" />
Nur Lesen
</span>
)}
</div>
{/* Right Action Buttons */}
<div className="flex items-center gap-1.5">
{/* 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 (
<div className="flex items-center gap-1.5">
{isShared && (
isOwner ? (
<button
type="button"
onClick={() => onOpenShareModal(doc)}
className="px-2 py-0.5 rounded text-[10px] font-semibold bg-accent/15 text-accent border border-accent/30 flex items-center gap-1 hover:bg-accent/25 hover:border-accent/50 transition-colors cursor-pointer"
title="Teilen-Menü öffnen: Geteilt mit Nutzern/Gruppen anzeigen & verwalten"
>
<Users className="w-2.5 h-2.5" />
<span>Geteilt{sharesCount > 0 ? ` (${sharesCount})` : ''}</span>
</button>
) : (
<button
type="button"
onClick={() => onOpenShareModal(doc)}
className="px-2 py-0.5 rounded text-[10px] font-medium border border-border text-muted flex items-center gap-1 bg-surface-subtle hover:bg-panel-elevated hover:text-main transition-colors cursor-pointer"
title="Teilen & Berechtigungen öffnen"
>
<Users className="w-2.5 h-2.5" />
<span>Geteilt von {ownerDisplayName}</span>
</button>
)
)}
<button
type="button"
onClick={() => onOpenShareModal(doc)}
className="btn-icon"
title="Notiz teilen und Berechtigungen"
>
<Users className="w-3.5 h-3.5" />
</button>
</div>
);
})()}
<button
type="button"
onClick={handleTogglePin}
className={`btn-icon ${
doc?.isPinned
? 'text-status-warn bg-status-warn/10 border-status-warn/30'
: ''
}`}
title={doc?.isPinned ? 'Anheftung lösen' : 'Notiz anheften'}
>
<Pin className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={handleExportMarkdown}
className="btn-icon"
title="Als Markdown (.md) exportieren"
>
<Download className="w-3.5 h-3.5" />
</button>
{canEdit && (doc?.isOwner || doc?.permission === 'owner') && (
<button
type="button"
onClick={() => onDeleteDoc(docId)}
className="btn-icon-danger"
title="Notiz löschen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{/* Read-only notification banner */}
{!canEdit && (
<div className="flex items-center gap-2 px-4 sm:px-6 py-2 bg-status-warn/10 border-b border-status-warn/25 text-status-warn text-xs">
<Lock className="w-3.5 h-3.5 shrink-0" />
<span>Dieses Dokument wurde schreibgeschützt mit dir geteilt (Nur Lesen).</span>
</div>
)}
{/* Main Content Area */}
<div className="w-full px-4 sm:px-8 py-5 flex-1">
{zoomedBlock ? (
/* =========================================================================
FOKUS-MODUS & ZOOM-IN (HOISTED SUBTREE VIEW)
========================================================================= */
<div className="space-y-3">
<div className="flex items-start justify-between gap-3 pb-4 mb-2 border-b border-border">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1.5">
<span className="inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-accent bg-accent-dim px-2 py-0.5 rounded-btn border border-accent/30">
<Sparkles className="w-2.5 h-2.5 text-accent animate-pulse" />
Fokus-Modus
</span>
<span className="text-[11px] text-faint hidden sm:inline">
Isolierter Ast • Zoom aktiv
</span>
</div>
<input
type="text"
value={zoomedBlock.content}
onChange={(e) => {
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"
/>
</div>
<button
type="button"
onClick={() => setZoomedBlockId(null)}
className="btn-action inline-flex items-center gap-1.5 px-2.5 py-1 text-xs text-muted hover:text-main shrink-0 mt-1"
title="Fokus-Modus beenden"
>
<Minimize2 className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Fokus beenden</span>
</button>
</div>
{childBlocks.length === 0 ? (
<div className="py-8 text-center text-xs text-faint border border-dashed border-border rounded-card my-2">
<p className="mb-2">Dieser Ast hat noch keine Unterpunkte.</p>
{canEdit && (
<button
type="button"
onClick={() => {
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);
}}
className="btn-action inline-flex items-center gap-1.5 px-3 py-1.5 text-xs text-accent font-medium hover:bg-accent/10"
>
<Plus className="w-3.5 h-3.5" />
<span>Ersten Unterpunkt anlegen (oder Enter drücken)</span>
</button>
)}
</div>
) : (
<OutlinerTree
docId={docId}
blocks={childBlocks}
canEdit={canEdit}
levelOffset={levelOffset}
onZoomBlock={setZoomedBlockId}
onUpdateBlock={handleUpdateBlock}
onUpdateBlocks={handleUpdateBlocks}
onCreateBlock={handleCreateBlock}
onInsertBlocks={handleInsertBlocks}
onDeleteBlock={handleDeleteBlock}
onMoveBlock={handleMoveBlock}
onOpenTask={onOpenTask}
onTagClick={onSelectTag}
onWikiLinkClick={onWikiLinkClick}
initialFocusedBlockId={initialFocusedBlockId}
/>
)}
</div>
) : (
/* =========================================================================
STANDARD GESAMTDOKUMENT-ANSICHT
========================================================================= */
<>
{/* Document Icon + Title Header */}
<div className="flex items-center gap-3 mb-4 relative">
<div className="relative">
<button
type="button"
onClick={() => canEdit && setShowEmojiPicker((prev) => !prev)}
className="text-2xl sm:text-3xl hover:opacity-80 transition-opacity cursor-pointer select-none leading-none bg-transparent border-0 outline-none p-0 focus:outline-none focus:ring-0 shadow-none"
title="Icon ändern"
>
{doc?.icon || '📝'}
</button>
{showEmojiPicker && canEdit && (
<div
className="absolute left-0 mt-1 p-2 panel-surface shadow-lg border border-border rounded-card z-30 w-72 space-y-2"
onClick={(e) => e.stopPropagation()}
>
<div className="relative">
<Search className="w-3.5 h-3.5 text-faint absolute left-2 top-1/2 -translate-y-1/2" />
<input
type="text"
autoFocus
value={emojiSearch}
onChange={(e) => setEmojiSearch(e.target.value)}
placeholder="Icon suchen oder Emoji eingeben…"
className="input-field w-full pl-7 pr-2 py-1 text-xs"
/>
</div>
{doc?.icon && (
<button
type="button"
onClick={() => {
handleSelectIcon('');
setShowEmojiPicker(false);
setEmojiSearch('');
}}
className="w-full text-left px-2 py-1 text-status-warn/90 hover:bg-status-warn/10 rounded text-xs font-medium flex items-center gap-1.5 cursor-pointer"
>
<Trash2 className="w-3 h-3" />
<span>Icon entfernen</span>
</button>
)}
{emojiSearch.trim() && (
<button
type="button"
onClick={() => {
handleSelectIcon(emojiSearch.trim());
setShowEmojiPicker(false);
setEmojiSearch('');
}}
className="w-full text-left px-2 py-1 bg-accent/10 hover:bg-accent/20 text-accent rounded text-xs font-medium flex items-center justify-between cursor-pointer"
>
<span>Eingabe als Icon nutzen</span>
<span className="text-base">{emojiSearch.trim()}</span>
</button>
)}
<div className="max-h-48 overflow-y-auto grid grid-cols-6 gap-1 pr-1">
{filteredEmojis.map((item) => (
<button
key={item.emoji}
type="button"
onClick={() => {
handleSelectIcon(item.emoji);
setShowEmojiPicker(false);
setEmojiSearch('');
}}
className="p-1.5 rounded-btn hover:bg-panel-elevated text-lg flex items-center justify-center cursor-pointer transition-colors"
title={item.keywords}
>
{item.emoji}
</button>
))}
{filteredEmojis.length === 0 && (
<div className="col-span-6 text-center text-xs text-faint py-3">
Kein passendes Standard-Icon
</div>
)}
</div>
</div>
)}
</div>
<textarea
ref={(el) => {
titleInputRef.current = el;
if (el) {
el.style.height = 'auto';
el.style.height = `${el.scrollHeight}px`;
}
}}
rows={1}
value={title}
readOnly={!canEdit}
autoFocus={autoFocusTitle}
onChange={(e) => {
handleTitleChange(e);
e.target.style.height = 'auto';
e.target.style.height = `${e.target.scrollHeight}px`;
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
outlinerTreeRef.current?.focusFirstBlock();
}
}}
onBlur={handleTitleBlur}
placeholder="Titel der Notiz…"
className="flex-1 min-w-0 text-xl sm:text-2xl font-bold text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight resize-none leading-snug whitespace-pre-wrap break-words [overflow-wrap:anywhere]"
/>
</div>
{/* Logseq Outliner Tree */}
<OutlinerTree
ref={outlinerTreeRef}
docId={docId}
blocks={blocks}
canEdit={canEdit}
levelOffset={0}
onZoomBlock={setZoomedBlockId}
onUpdateBlock={handleUpdateBlock}
onUpdateBlocks={handleUpdateBlocks}
onCreateBlock={handleCreateBlock}
onInsertBlocks={handleInsertBlocks}
onDeleteBlock={handleDeleteBlock}
onMoveBlock={handleMoveBlock}
onOpenTask={onOpenTask}
onTagClick={onSelectTag}
onWikiLinkClick={onWikiLinkClick}
initialFocusedBlockId={initialFocusedBlockId}
/>
</>
)}
</div>
</div>
);
};