Files
MindSift/frontend/src/features/workspace/DocumentEditor.tsx
T
sandrodachandClaude Opus 5.5 8b7a53daab
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m16s
feat(notes): push, long polling and fallback detection for note sync
Phase 4 of the collaboration plan:
- The sync engine listens for DOC_SEQ in the note's realtime room and syncs
  right away. Without WebSocket/SSE it long-polls /docs/{id}/changes (25 s);
  after three failures it falls back to polling every 3 s and retries long
  polling when the browser comes back online.
- With push or long polling the engine only syncs every 7 s while holding
  leases (renewal), otherwise every 30 s as a safety net. Focusing a block
  announces its lease immediately (otherwise it could take up to 30 s).
- Realtime connection: SSE counts as connected only after its first event;
  a stream that stays silent for 5 s (buffering proxy) switches SSE off for
  the tab. App-level PING/PONG detects WebSockets that are accepted but
  forward nothing and switches to SSE after 35 s.
- The editor shows the active channel for shared notes.
- Tests: engine channel tests (push, heartbeat, lease on focus, long
  polling, interval fallback, switching), realtime fallback tests with fake
  WebSocket/EventSource, Go tests for PONG and DOC_SEQ delivery, and
  e2e/channels.e2e.mjs measuring latency behind six simulated proxy setups
  (0.2-0.3 s on all live channels, ~3 s with plain polling).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 14:10:29 +01:00

1156 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 } from '../../lib/api';
import { DocSyncEngine, EngineState } from '../../lib/docSync/engine';
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,
}));
}
// 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<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);
// Sync engine (operation log): stores every change locally and syncs it with the server
const engineRef = useRef<DocSyncEngine | null>(null);
const [engineState, setEngineState] = useState<EngineState | 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]);
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;
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 canEdit = useMemo(() => {
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<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]);
// 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<string, string>();
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<string, string>();
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]
);
// Short hint at the bottom of the editor (blocked actions, skipped undo)
const [notice, setNotice] = useState<string | null>(null);
const noticeTimerRef = useRef<number | null>(null);
const showNotice = useCallback((message: string) => {
setNotice(message);
if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current);
noticeTimerRef.current = window.setTimeout(() => setNotice(null), 3500);
}, []);
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<HTMLInputElement | HTMLTextAreaElement>) => {
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<string, DocumentBlock>();
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 (
<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" data-sync-channel={engineState?.channel}>
{/* 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">
{/* How changes of others arrive (only relevant for shared notes) */}
{engineState && (engineState.shared || engineState.sessions > 1) && (
<span
className="hidden sm:inline-flex items-center gap-1 px-2 py-0.5 rounded-btn text-[11px] text-muted bg-panel border border-border"
title={
engineState.channel === 'ws'
? 'Live-Verbindung über WebSocket'
: engineState.channel === 'sse'
? 'Live-Verbindung über Server-Sent Events (WebSocket wird blockiert)'
: engineState.channel === 'longpoll'
? 'Live über Long-Polling (WebSocket und SSE werden blockiert)'
: 'Keine Live-Verbindung möglich – Änderungen werden alle 3 Sekunden abgefragt'
}
>
<span
className={`w-1.5 h-1.5 rounded-full ${
engineState.channel === 'interval' ? 'bg-status-warn' : 'bg-status-ok'
}`}
/>
{engineState.channel === 'longpoll' ? 'Live (Long-Polling)' : engineState.channel === 'interval' ? 'Abgleich alle 3 s' : 'Live'}
</span>
)}
{/* Who else has this note open */}
{otherPeople.length > 0 && (
<span
className="hidden sm:inline-flex items-center gap-1 px-2 py-0.5 rounded-btn text-[11px] text-muted bg-panel border border-border"
title="Hat diese Notiz gerade geöffnet"
>
<Users className="w-3 h-3 text-accent" />
{otherPeople.map((p) => p.name).join(', ')}
</span>
)}
{/* 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}
onFocusedBlockChange={(id) => engineRef.current?.focusBlock(id)}
lockedBlocks={lockedBlocks}
onLockedAction={handleLockedAction}
onResolveConflict={handleResolveConflict}
onUndo={handleUndo}
onRedo={handleRedo}
/>
)}
</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.ctrlKey || e.metaKey) && !e.altKey && ['z', 'y'].includes(e.key.toLowerCase())) {
e.preventDefault();
if (e.key.toLowerCase() === 'y' || e.shiftKey) handleRedo();
else handleUndo();
return;
}
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}
onFocusedBlockChange={(id) => engineRef.current?.focusBlock(id)}
lockedBlocks={lockedBlocks}
onLockedAction={handleLockedAction}
onResolveConflict={handleResolveConflict}
onUndo={handleUndo}
onRedo={handleRedo}
/>
</>
)}
</div>
{notice && (
<div
role="status"
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 max-w-[90vw] px-3 py-2 rounded-card text-xs text-main bg-panel-elevated border border-border shadow-panel"
>
{notice}
</div>
)}
</div>
);
};