MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
1314 lines
45 KiB
TypeScript
1314 lines
45 KiB
TypeScript
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||
import {
|
||
Pin,
|
||
Users,
|
||
Trash2,
|
||
Download,
|
||
FileText,
|
||
Loader2,
|
||
ArrowLeft,
|
||
Hash,
|
||
Lock,
|
||
Home,
|
||
ChevronRight,
|
||
Minimize2,
|
||
Sparkles,
|
||
Plus,
|
||
} from 'lucide-react';
|
||
import { Document, DocumentBlock, User, BlockLockInfo, LiveSessionInfo, WSMessage } from '../../lib/types';
|
||
import { api, getToken, onWebSocketMessage } from '../../lib/api';
|
||
import { useDocumentSync, DocSyncEvent, RemoteCursor, DocSyncStatus, getUserColor } from '../../lib/useDocumentSync';
|
||
import { OutlinerTree } from './OutlinerTree';
|
||
|
||
export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error';
|
||
export type DocLiveState = 'idle' | 'connecting' | 'active' | 'blocked' | 'locked_by_other';
|
||
|
||
export interface DocumentEditorProps {
|
||
docId: string;
|
||
currentUser?: User | null;
|
||
initialDoc?: Document | null;
|
||
availableFolders: string[];
|
||
autoFocusTitle?: boolean;
|
||
initialFocusedBlockId?: string | null;
|
||
onDocMetadataChange: (updated: Document) => void;
|
||
onDeleteDoc: (docId: string) => void;
|
||
onOpenShareModal: (doc: Document) => void;
|
||
onSelectTag: (tag: string) => void;
|
||
onWikiLinkClick?: (pageTitle: string) => void;
|
||
onBackMobile?: () => void;
|
||
onSyncStatusChange?: (status: DocSyncStatus) => void;
|
||
onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void;
|
||
onLiveStateChange?: (state: DocLiveState, initiatorName?: string) => void;
|
||
registerLiveToggle?: (handler: () => void) => void;
|
||
}
|
||
|
||
const EMOJI_PALETTE = ['📝', '📄', '💡', '🚀', '📌', '📂', '🧠', '⚡', '🎯', '🛠️', '📊', '🔒'];
|
||
|
||
function ensureNonEmptyBlocks(docId: string, blocks?: DocumentBlock[]): DocumentBlock[] {
|
||
if (!blocks || blocks.length === 0) {
|
||
return [
|
||
{
|
||
id: `${docId}-b0`,
|
||
documentId: docId,
|
||
type: 'text',
|
||
content: '',
|
||
level: 0,
|
||
position: 1000,
|
||
},
|
||
];
|
||
}
|
||
return blocks.map((b, idx) => ({
|
||
...b,
|
||
id: b.id || `${docId}-b${idx}`,
|
||
documentId: docId,
|
||
type: b.type || 'text',
|
||
level: Math.max(0, b.level || 0),
|
||
position: b.position || (idx + 1) * 1000,
|
||
}));
|
||
}
|
||
|
||
export interface BreadcrumbNode {
|
||
id: string | null;
|
||
title: string;
|
||
}
|
||
|
||
export function computeBreadcrumbs(
|
||
docTitle: string,
|
||
blocks: DocumentBlock[],
|
||
zoomedBlockId: string | null
|
||
): BreadcrumbNode[] {
|
||
if (!zoomedBlockId) return [];
|
||
|
||
const zoomedIdx = blocks.findIndex((b) => b.id === zoomedBlockId);
|
||
if (zoomedIdx === -1) return [];
|
||
|
||
const trail: BreadcrumbNode[] = [];
|
||
const zoomedBlock = blocks[zoomedIdx];
|
||
|
||
let currentLevel = zoomedBlock.level;
|
||
for (let i = zoomedIdx - 1; i >= 0; i--) {
|
||
if (blocks[i].level < currentLevel) {
|
||
trail.unshift({
|
||
id: blocks[i].id,
|
||
title: blocks[i].content?.trim() || 'Punkt',
|
||
});
|
||
currentLevel = blocks[i].level;
|
||
if (currentLevel === 0) break;
|
||
}
|
||
}
|
||
|
||
trail.unshift({
|
||
id: null,
|
||
title: docTitle || 'Dokument',
|
||
});
|
||
|
||
trail.push({
|
||
id: zoomedBlock.id,
|
||
title: zoomedBlock.content?.trim() || 'Punkt',
|
||
});
|
||
|
||
return trail;
|
||
}
|
||
|
||
export function getZoomedSubtree(
|
||
blocks: DocumentBlock[],
|
||
zoomedBlockId: string | null
|
||
): {
|
||
zoomedBlock: DocumentBlock | null;
|
||
childBlocks: DocumentBlock[];
|
||
levelOffset: number;
|
||
} {
|
||
if (!zoomedBlockId) {
|
||
return { zoomedBlock: null, childBlocks: blocks, levelOffset: 0 };
|
||
}
|
||
|
||
const idx = blocks.findIndex((b) => b.id === zoomedBlockId);
|
||
if (idx === -1) {
|
||
return { zoomedBlock: null, childBlocks: blocks, levelOffset: 0 };
|
||
}
|
||
|
||
const zoomedBlock = blocks[idx];
|
||
const zLevel = zoomedBlock.level;
|
||
const childBlocks: DocumentBlock[] = [];
|
||
|
||
for (let i = idx + 1; i < blocks.length; i++) {
|
||
if (blocks[i].level > zLevel) {
|
||
childBlocks.push(blocks[i]);
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
|
||
return {
|
||
zoomedBlock,
|
||
childBlocks,
|
||
levelOffset: zLevel + 1,
|
||
};
|
||
}
|
||
|
||
export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||
docId,
|
||
currentUser,
|
||
initialDoc,
|
||
availableFolders,
|
||
autoFocusTitle,
|
||
initialFocusedBlockId,
|
||
onDocMetadataChange,
|
||
onDeleteDoc,
|
||
onOpenShareModal,
|
||
onSelectTag,
|
||
onWikiLinkClick,
|
||
onBackMobile,
|
||
onSyncStatusChange,
|
||
onSaveStatusChange,
|
||
onLiveStateChange,
|
||
registerLiveToggle,
|
||
}) => {
|
||
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 [zoomedBlockId, setZoomedBlockId] = useState<string | null>(null);
|
||
|
||
// Live Collaboration & Locks state
|
||
const [liveSession, setLiveSession] = useState<LiveSessionInfo | null>(null);
|
||
const [isLiveEnabled, setIsLiveEnabled] = useState<boolean>(false);
|
||
const [activeBlockLocks, setActiveBlockLocks] = useState<Record<string, BlockLockInfo>>({});
|
||
|
||
// AutoSave state
|
||
const [saveStatus, 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<HTMLInputElement | null>(null);
|
||
const titleDebounceRef = useRef<number | 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]);
|
||
|
||
// Crucial fix: Collaborator with write permission can edit!
|
||
const canEdit = useMemo(() => {
|
||
if (!doc) return true;
|
||
if (doc.isOwner) return true;
|
||
return doc.permission === 'owner' || doc.permission === 'write';
|
||
}, [doc]);
|
||
|
||
// Is this document completely locked because another user has an active live session?
|
||
const isLockedByOtherLive = Boolean(liveSession && liveSession.initiatorId !== currentUser?.id);
|
||
const effectiveCanEdit = canEdit && !isLockedByOtherLive;
|
||
|
||
// Handle incoming WebSocket events for THIS document room
|
||
const handleRemoteEvent = useCallback(
|
||
(evt: DocSyncEvent) => {
|
||
if (evt.docId && evt.docId !== docId) return;
|
||
|
||
switch (evt.type) {
|
||
case 'doc_state': {
|
||
if (evt.title !== undefined) {
|
||
const isTitleActivelyEditing =
|
||
titleInputRef.current !== null &&
|
||
document.activeElement === titleInputRef.current &&
|
||
typeof document !== 'undefined' &&
|
||
document.hasFocus();
|
||
if (!isTitleActivelyEditing) {
|
||
setTitle(evt.title);
|
||
}
|
||
}
|
||
if (Array.isArray(evt.blocks) && evt.blocks.length > 0) {
|
||
setBlocks(ensureNonEmptyBlocks(docId, evt.blocks));
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'title_updated': {
|
||
if (evt.title !== undefined) {
|
||
const isTitleActivelyEditing =
|
||
titleInputRef.current !== null &&
|
||
document.activeElement === titleInputRef.current &&
|
||
typeof document !== 'undefined' &&
|
||
document.hasFocus();
|
||
if (!isTitleActivelyEditing) {
|
||
setTitle(evt.title);
|
||
}
|
||
if (docRef.current) {
|
||
const updated = { ...docRef.current, title: evt.title };
|
||
setDoc(updated);
|
||
onDocMetadataChange(updated);
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'block_created': {
|
||
if (!evt.block) return;
|
||
const incoming = evt.block;
|
||
setBlocks((prev) => {
|
||
if (prev.some((b) => b.id === incoming.id)) {
|
||
return prev.map((b) => (b.id === incoming.id ? incoming : b));
|
||
}
|
||
const next = [...prev];
|
||
if (evt.afterBlockId) {
|
||
const idx = next.findIndex((b) => b.id === evt.afterBlockId);
|
||
if (idx >= 0) {
|
||
next.splice(idx + 1, 0, incoming);
|
||
return ensureNonEmptyBlocks(docId, next);
|
||
}
|
||
}
|
||
next.push(incoming);
|
||
return ensureNonEmptyBlocks(docId, next);
|
||
});
|
||
break;
|
||
}
|
||
|
||
case 'block_updated': {
|
||
const targetId = evt.blockId || evt.block?.id;
|
||
if (!targetId) return;
|
||
setBlocks((prev) =>
|
||
prev.map((b) => {
|
||
if (b.id !== targetId) return b;
|
||
if (evt.block) {
|
||
return { ...b, ...evt.block };
|
||
}
|
||
return {
|
||
...b,
|
||
content: evt.content !== undefined ? evt.content : b.content,
|
||
level: evt.level !== undefined ? evt.level : b.level,
|
||
checked: evt.checked !== undefined ? evt.checked : b.checked,
|
||
collapsed: evt.collapsed !== undefined ? evt.collapsed : b.collapsed,
|
||
};
|
||
})
|
||
);
|
||
break;
|
||
}
|
||
|
||
case 'block_deleted': {
|
||
if (!evt.blockId) return;
|
||
setBlocks((prev) =>
|
||
ensureNonEmptyBlocks(
|
||
docId,
|
||
prev.filter((b) => b.id !== evt.blockId)
|
||
)
|
||
);
|
||
break;
|
||
}
|
||
|
||
case 'block_moved': {
|
||
if (!evt.blockId || evt.newIndex === undefined) return;
|
||
const targetIndex = evt.newIndex;
|
||
setBlocks((prev) => {
|
||
const fromIdx = prev.findIndex((b) => b.id === evt.blockId);
|
||
if (fromIdx === -1) return prev;
|
||
const copy = [...prev];
|
||
const [moving] = copy.splice(fromIdx, 1);
|
||
const updatedMoving: DocumentBlock = {
|
||
...moving,
|
||
level: evt.level !== undefined ? evt.level : moving.level,
|
||
};
|
||
const boundedIdx = Math.max(0, Math.min(targetIndex, copy.length));
|
||
copy.splice(boundedIdx, 0, updatedMoving);
|
||
return ensureNonEmptyBlocks(docId, copy);
|
||
});
|
||
break;
|
||
}
|
||
|
||
case 'blocks_replaced': {
|
||
if (Array.isArray(evt.blocks)) {
|
||
setBlocks(ensureNonEmptyBlocks(docId, evt.blocks));
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
},
|
||
[docId, onDocMetadataChange]
|
||
);
|
||
|
||
// Extract all #tags present across the current blocks
|
||
const extractedTags = useMemo(() => {
|
||
const tagSet = new Set<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 implementation (authoritative persistence via HTTP REST)
|
||
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 || isLockedByOtherLive) return;
|
||
if (isSavingRef.current && !immediate) return;
|
||
|
||
const snapshot = currentSnapshotRef.current;
|
||
if (snapshot === lastSavedSnapshotRef.current) {
|
||
setSaveStatus('saved');
|
||
onSaveStatusChange?.('saved');
|
||
return;
|
||
}
|
||
|
||
isSavingRef.current = true;
|
||
setSaveStatus('saving');
|
||
onSaveStatusChange?.('saving');
|
||
|
||
try {
|
||
const updated = await api.updateDocument(docId, {
|
||
title: currentTitleRef.current,
|
||
blocks: currentBlocksRef.current,
|
||
tags: currentTagsRef.current,
|
||
});
|
||
isSavingRef.current = false;
|
||
lastSavedSnapshotRef.current = snapshot;
|
||
setSaveStatus('saved');
|
||
setSaveErrorMessage(null);
|
||
onSaveStatusChange?.('saved');
|
||
if (docRef.current) {
|
||
docRef.current = updated;
|
||
onDocMetadataChange(updated);
|
||
}
|
||
} catch (err: any) {
|
||
isSavingRef.current = false;
|
||
const msg = err?.message || 'Fehler beim Speichern';
|
||
setSaveStatus('error');
|
||
setSaveErrorMessage(msg);
|
||
onSaveStatusChange?.('error', msg);
|
||
}
|
||
},
|
||
[docId, canEdit, isLockedByOtherLive, onSaveStatusChange, onDocMetadataChange]
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (isInitialLoadRef.current) return;
|
||
if (currentSnapshot === lastSavedSnapshotRef.current) return;
|
||
|
||
setSaveStatus('unsaved');
|
||
onSaveStatusChange?.('unsaved');
|
||
|
||
if (saveTimeoutRef.current !== null) {
|
||
window.clearTimeout(saveTimeoutRef.current);
|
||
}
|
||
saveTimeoutRef.current = window.setTimeout(() => {
|
||
saveTimeoutRef.current = null;
|
||
executeAutoSave();
|
||
}, 800);
|
||
|
||
return () => {
|
||
if (saveTimeoutRef.current !== null) {
|
||
window.clearTimeout(saveTimeoutRef.current);
|
||
saveTimeoutRef.current = null;
|
||
}
|
||
};
|
||
}, [currentSnapshot, executeAutoSave, onSaveStatusChange]);
|
||
|
||
// Flush on page unload / pagehide via keepalive fetch
|
||
useEffect(() => {
|
||
const handleUnload = () => {
|
||
if (saveTimeoutRef.current !== null) {
|
||
window.clearTimeout(saveTimeoutRef.current);
|
||
saveTimeoutRef.current = null;
|
||
}
|
||
const snapshot = currentSnapshotRef.current;
|
||
if (snapshot !== lastSavedSnapshotRef.current && canEdit && !isLockedByOtherLive) {
|
||
const token = getToken();
|
||
if (token) {
|
||
fetch(`/api/v1/documents/${docId}`, {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Authorization: `Bearer ${token}`,
|
||
},
|
||
body: JSON.stringify({
|
||
title: currentTitleRef.current,
|
||
blocks: currentBlocksRef.current,
|
||
tags: currentTagsRef.current,
|
||
}),
|
||
keepalive: true,
|
||
}).catch(() => {});
|
||
}
|
||
}
|
||
};
|
||
|
||
window.addEventListener('beforeunload', handleUnload);
|
||
window.addEventListener('pagehide', handleUnload);
|
||
return () => {
|
||
window.removeEventListener('beforeunload', handleUnload);
|
||
window.removeEventListener('pagehide', handleUnload);
|
||
handleUnload();
|
||
};
|
||
}, [docId, canEdit, isLockedByOtherLive]);
|
||
|
||
const {
|
||
status: syncStatus,
|
||
remoteCursors,
|
||
sendCursorMoved,
|
||
sendBlockCreated,
|
||
sendBlockUpdated,
|
||
sendBlockDeleted,
|
||
sendBlockMoved,
|
||
sendTitleUpdated,
|
||
} = useDocumentSync(docId, handleRemoteEvent, effectiveCanEdit, currentUser, isLiveEnabled);
|
||
|
||
useEffect(() => {
|
||
onSyncStatusChange?.(syncStatus);
|
||
}, [syncStatus, onSyncStatusChange]);
|
||
|
||
const computedLiveState: DocLiveState = useMemo(() => {
|
||
if (isLockedByOtherLive) return 'locked_by_other';
|
||
if (!isLiveEnabled) return 'idle';
|
||
if (syncStatus === 'connecting' || syncStatus === 'reconnecting') return 'connecting';
|
||
if (syncStatus === 'blocked') return 'blocked';
|
||
if (syncStatus === 'connected') return 'active';
|
||
return 'idle';
|
||
}, [isLockedByOtherLive, isLiveEnabled, syncStatus]);
|
||
|
||
useEffect(() => {
|
||
onLiveStateChange?.(computedLiveState, liveSession?.initiatorName);
|
||
}, [computedLiveState, liveSession?.initiatorName, onLiveStateChange]);
|
||
|
||
const handleToggleLive = useCallback(() => {
|
||
if (isLiveEnabled) {
|
||
api.endLiveSession(docId).catch(() => {});
|
||
setIsLiveEnabled(false);
|
||
setLiveSession(null);
|
||
} else {
|
||
if (isLockedByOtherLive) return;
|
||
api.startLiveSession(docId)
|
||
.then((session) => {
|
||
setLiveSession(session);
|
||
setIsLiveEnabled(true);
|
||
})
|
||
.catch((err) => {
|
||
console.error('[DocumentEditor] Failed to start live session:', err);
|
||
});
|
||
}
|
||
}, [docId, isLiveEnabled, isLockedByOtherLive]);
|
||
|
||
useEffect(() => {
|
||
if (registerLiveToggle) {
|
||
registerLiveToggle(handleToggleLive);
|
||
}
|
||
}, [registerLiveToggle, handleToggleLive]);
|
||
|
||
// Load initial locks on mount
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
api.getDocLocks(docId)
|
||
.then((res) => {
|
||
if (cancelled) return;
|
||
if (res.liveSession) {
|
||
setLiveSession(res.liveSession);
|
||
if (res.liveSession.initiatorId === currentUser?.id) {
|
||
setIsLiveEnabled(true);
|
||
}
|
||
}
|
||
if (Array.isArray(res.blockLocks)) {
|
||
const map: Record<string, BlockLockInfo> = {};
|
||
for (const l of res.blockLocks) {
|
||
map[l.blockId] = l;
|
||
}
|
||
setActiveBlockLocks(map);
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [docId, currentUser?.id]);
|
||
|
||
// Subscribe to realtime lock & live session events
|
||
useEffect(() => {
|
||
const unsub = onWebSocketMessage((msg: WSMessage) => {
|
||
if (msg.event === 'DOC_BLOCK_LOCKED' && msg.payload?.docId === docId) {
|
||
setActiveBlockLocks((prev) => ({
|
||
...prev,
|
||
[msg.payload.blockId]: msg.payload,
|
||
}));
|
||
} else if (msg.event === 'DOC_BLOCK_UNLOCKED' && msg.payload?.docId === docId) {
|
||
setActiveBlockLocks((prev) => {
|
||
const next = { ...prev };
|
||
delete next[msg.payload.blockId];
|
||
return next;
|
||
});
|
||
} else if (msg.event === 'DOC_LIVE_STARTED' && msg.payload?.docId === docId) {
|
||
setLiveSession(msg.payload);
|
||
} else if (msg.event === 'DOC_LIVE_ENDED' && msg.payload?.docId === docId) {
|
||
setLiveSession(null);
|
||
if (liveSession?.initiatorId === currentUser?.id) {
|
||
setIsLiveEnabled(false);
|
||
}
|
||
}
|
||
});
|
||
|
||
return () => {
|
||
unsub();
|
||
};
|
||
}, [docId, currentUser?.id, liveSession?.initiatorId]);
|
||
|
||
// Block focus/blur locks
|
||
const localUserColor = useMemo(
|
||
() => getUserColor(currentUser?.id || 'anon'),
|
||
[currentUser?.id]
|
||
);
|
||
const focusedBlockIdRef = useRef<string | null>(null);
|
||
const lockKeepaliveTimerRef = useRef<number | null>(null);
|
||
|
||
const handleFocusBlock = useCallback(
|
||
(blockId: string) => {
|
||
if (!effectiveCanEdit) return;
|
||
focusedBlockIdRef.current = blockId;
|
||
api.lockBlock(docId, blockId, localUserColor, 30).catch(() => {});
|
||
|
||
if (lockKeepaliveTimerRef.current !== null) {
|
||
window.clearInterval(lockKeepaliveTimerRef.current);
|
||
}
|
||
lockKeepaliveTimerRef.current = window.setInterval(() => {
|
||
if (focusedBlockIdRef.current === blockId) {
|
||
api.lockBlock(docId, blockId, localUserColor, 30).catch(() => {});
|
||
}
|
||
}, 15000);
|
||
},
|
||
[docId, effectiveCanEdit, localUserColor]
|
||
);
|
||
|
||
const handleBlurBlock = useCallback(
|
||
(blockId: string) => {
|
||
if (lockKeepaliveTimerRef.current !== null) {
|
||
window.clearInterval(lockKeepaliveTimerRef.current);
|
||
lockKeepaliveTimerRef.current = null;
|
||
}
|
||
if (focusedBlockIdRef.current === blockId) {
|
||
focusedBlockIdRef.current = null;
|
||
}
|
||
api.unlockBlock(docId, blockId).catch(() => {});
|
||
executeAutoSave();
|
||
},
|
||
[docId, executeAutoSave]
|
||
);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (lockKeepaliveTimerRef.current !== null) {
|
||
window.clearInterval(lockKeepaliveTimerRef.current);
|
||
lockKeepaliveTimerRef.current = null;
|
||
}
|
||
if (focusedBlockIdRef.current) {
|
||
api.unlockBlock(docId, focusedBlockIdRef.current).catch(() => {});
|
||
}
|
||
};
|
||
}, [docId]);
|
||
|
||
// Fetch isolated document state on mount
|
||
useEffect(() => {
|
||
let isCancelled = false;
|
||
if (!initialDoc) {
|
||
setLoading(true);
|
||
}
|
||
setError(null);
|
||
|
||
api
|
||
.getDocument(docId)
|
||
.then((fetched) => {
|
||
if (isCancelled) return;
|
||
const normalized = ensureNonEmptyBlocks(docId, fetched.blocks);
|
||
setDoc(fetched);
|
||
setTitle(fetched.title || 'Unbenannte Notiz');
|
||
setBlocks(normalized);
|
||
setLoading(false);
|
||
onDocMetadataChange(fetched);
|
||
|
||
const initialTags: string[] = [];
|
||
for (const b of normalized) {
|
||
const matches = b.content?.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g);
|
||
if (matches) {
|
||
for (const m of matches) {
|
||
const tag = m.slice(1).toLowerCase();
|
||
if (!initialTags.includes(tag)) initialTags.push(tag);
|
||
}
|
||
}
|
||
}
|
||
lastSavedSnapshotRef.current = JSON.stringify({
|
||
title: fetched.title || 'Unbenannte Notiz',
|
||
blocks: normalized,
|
||
tags: initialTags,
|
||
});
|
||
isInitialLoadRef.current = false;
|
||
setSaveStatus('saved');
|
||
onSaveStatusChange?.('saved');
|
||
})
|
||
.catch((err: any) => {
|
||
if (isCancelled) return;
|
||
setError(err?.message || 'Dokument konnte nicht geladen werden');
|
||
setLoading(false);
|
||
});
|
||
|
||
return () => {
|
||
isCancelled = true;
|
||
if (titleDebounceRef.current !== null) {
|
||
window.clearTimeout(titleDebounceRef.current);
|
||
titleDebounceRef.current = null;
|
||
}
|
||
};
|
||
}, [docId]);
|
||
|
||
// Keep parent sidebar metadata in sync when blocks/tags change
|
||
useEffect(() => {
|
||
if (!docRef.current) return;
|
||
const updatedDoc: Document = {
|
||
...docRef.current,
|
||
title,
|
||
blocks,
|
||
tags: extractedTags,
|
||
updatedAt: new Date().toISOString(),
|
||
};
|
||
docRef.current = updatedDoc;
|
||
onDocMetadataChange(updatedDoc);
|
||
}, [blocks, extractedTags, title]);
|
||
|
||
// Active remote users in this room (excluding self)
|
||
const activeUsers = useMemo(() => {
|
||
const list: RemoteCursor[] = [];
|
||
const seen = new Set<string>();
|
||
for (const c of Object.values(remoteCursors)) {
|
||
const key = c.userId || c.clientId;
|
||
if (!seen.has(key)) {
|
||
seen.add(key);
|
||
list.push(c);
|
||
}
|
||
}
|
||
return list;
|
||
}, [remoteCursors]);
|
||
|
||
const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
if (!effectiveCanEdit) return;
|
||
const nextTitle = e.target.value;
|
||
setTitle(nextTitle);
|
||
|
||
if (titleDebounceRef.current !== null) {
|
||
window.clearTimeout(titleDebounceRef.current);
|
||
}
|
||
titleDebounceRef.current = window.setTimeout(() => {
|
||
titleDebounceRef.current = null;
|
||
if (isLiveEnabled) {
|
||
sendTitleUpdated(nextTitle);
|
||
}
|
||
}, 250);
|
||
};
|
||
|
||
const handleTitleBlur = () => {
|
||
if (titleDebounceRef.current !== null) {
|
||
window.clearTimeout(titleDebounceRef.current);
|
||
titleDebounceRef.current = null;
|
||
if (isLiveEnabled) {
|
||
sendTitleUpdated(title);
|
||
}
|
||
}
|
||
executeAutoSave();
|
||
};
|
||
|
||
// Flush title debouncing on window blur so background window never holds stale pending title
|
||
useEffect(() => {
|
||
const handleWindowBlur = () => {
|
||
if (titleDebounceRef.current !== null) {
|
||
window.clearTimeout(titleDebounceRef.current);
|
||
titleDebounceRef.current = null;
|
||
if (isLiveEnabled) {
|
||
sendTitleUpdated(title);
|
||
}
|
||
}
|
||
};
|
||
window.addEventListener('blur', handleWindowBlur);
|
||
return () => window.removeEventListener('blur', handleWindowBlur);
|
||
}, [title, isLiveEnabled, sendTitleUpdated]);
|
||
|
||
const handleUpdateBlock = useCallback(
|
||
(updatedBlock: DocumentBlock) => {
|
||
if (!effectiveCanEdit) return;
|
||
setBlocks((prev) =>
|
||
prev.map((b) => (b.id === updatedBlock.id ? updatedBlock : b))
|
||
);
|
||
if (isLiveEnabled) {
|
||
sendBlockUpdated(updatedBlock);
|
||
}
|
||
},
|
||
[effectiveCanEdit, isLiveEnabled, sendBlockUpdated]
|
||
);
|
||
|
||
const handleCreateBlock = useCallback(
|
||
(newBlock: DocumentBlock, afterBlockId?: string) => {
|
||
if (!effectiveCanEdit) return;
|
||
setBlocks((prev) => {
|
||
const next = [...prev];
|
||
if (afterBlockId) {
|
||
const idx = next.findIndex((b) => b.id === afterBlockId);
|
||
if (idx >= 0) {
|
||
next.splice(idx + 1, 0, newBlock);
|
||
const normalized = ensureNonEmptyBlocks(docId, next);
|
||
if (isLiveEnabled) {
|
||
sendBlockCreated(newBlock, afterBlockId, normalized);
|
||
}
|
||
return normalized;
|
||
}
|
||
}
|
||
next.push(newBlock);
|
||
const normalized = ensureNonEmptyBlocks(docId, next);
|
||
if (isLiveEnabled) {
|
||
sendBlockCreated(newBlock, afterBlockId, normalized);
|
||
}
|
||
return normalized;
|
||
});
|
||
},
|
||
[docId, effectiveCanEdit, isLiveEnabled, sendBlockCreated]
|
||
);
|
||
|
||
const handleDeleteBlock = useCallback(
|
||
(blockId: string) => {
|
||
if (!effectiveCanEdit) return;
|
||
setBlocks((prev) => {
|
||
const filtered = prev.filter((b) => b.id !== blockId);
|
||
const normalized = ensureNonEmptyBlocks(docId, filtered);
|
||
if (isLiveEnabled) {
|
||
sendBlockDeleted(blockId, normalized);
|
||
}
|
||
return normalized;
|
||
});
|
||
},
|
||
[docId, effectiveCanEdit, isLiveEnabled, sendBlockDeleted]
|
||
);
|
||
|
||
const handleMoveBlock = useCallback(
|
||
(blockId: string, newIndex: number, level?: number) => {
|
||
if (!effectiveCanEdit) return;
|
||
setBlocks((prev) => {
|
||
const fromIdx = prev.findIndex((b) => b.id === blockId);
|
||
if (fromIdx === -1) return prev;
|
||
const copy = [...prev];
|
||
const [moving] = copy.splice(fromIdx, 1);
|
||
const updatedMoving: DocumentBlock = {
|
||
...moving,
|
||
level: level !== undefined ? level : moving.level,
|
||
};
|
||
const boundedIdx = Math.max(0, Math.min(newIndex, copy.length));
|
||
copy.splice(boundedIdx, 0, updatedMoving);
|
||
const normalized = ensureNonEmptyBlocks(docId, copy);
|
||
if (isLiveEnabled) {
|
||
sendBlockMoved(blockId, boundedIdx, updatedMoving.level, normalized);
|
||
}
|
||
return normalized;
|
||
});
|
||
},
|
||
[docId, effectiveCanEdit, isLiveEnabled, sendBlockMoved]
|
||
);
|
||
|
||
const handleTogglePin = async () => {
|
||
if (!doc) return;
|
||
try {
|
||
const updated = await api.updateDocument(doc.id, { isPinned: !doc.isPinned });
|
||
setDoc(updated);
|
||
onDocMetadataChange(updated);
|
||
} catch {
|
||
// ignore
|
||
}
|
||
};
|
||
|
||
const handleSelectIcon = async (icon: string) => {
|
||
setShowEmojiPicker(false);
|
||
if (!doc || !effectiveCanEdit) return;
|
||
try {
|
||
const updated = await api.updateDocument(doc.id, { icon });
|
||
setDoc(updated);
|
||
onDocMetadataChange(updated);
|
||
} catch {
|
||
// ignore
|
||
}
|
||
};
|
||
|
||
const handleExportMarkdown = () => {
|
||
const lines: string[] = [`# ${title || 'Unbenannte Notiz'}`, ''];
|
||
for (const b of blocks) {
|
||
const indent = ' '.repeat(Math.max(0, b.level || 0));
|
||
if (b.type === 'todo') {
|
||
lines.push(`${indent}- [${b.checked ? 'x' : ' '}] ${b.content}`);
|
||
} else if (b.type === 'h1') {
|
||
lines.push(`${indent}- # ${b.content}`);
|
||
} else if (b.type === 'h2') {
|
||
lines.push(`${indent}- ## ${b.content}`);
|
||
} else if (b.type === 'h3') {
|
||
lines.push(`${indent}- ### ${b.content}`);
|
||
} else if (b.type === 'code') {
|
||
lines.push(`${indent}- \`\`\`${b.lang || ''}\n${b.content}\n${indent} \`\`\``);
|
||
} else if (b.type === 'divider') {
|
||
lines.push(`${indent}- ---`);
|
||
} else {
|
||
lines.push(`${indent}- ${b.content}`);
|
||
}
|
||
}
|
||
const blob = new Blob([lines.join('\n')], { type: 'text/markdown;charset=utf-8' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `${(title || 'notiz').toLowerCase().replace(/[^a-z0-9äöüß_-]+/gi, '-')}.md`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
};
|
||
|
||
if (loading) {
|
||
return (
|
||
<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 Status & Action Buttons */}
|
||
<div className="flex items-center gap-1.5">
|
||
{/* Active Presence Avatars (Immer rund: rounded-full mit Überlappung) */}
|
||
{activeUsers.length > 0 && (
|
||
<div className="flex items-center -space-x-1.5 mr-1" title="Aktive Bearbeiter im Dokument">
|
||
{activeUsers.map((u) => {
|
||
const initials = (u.userName || 'A')
|
||
.split(' ')
|
||
.map((n) => n[0])
|
||
.slice(0, 2)
|
||
.join('')
|
||
.toUpperCase();
|
||
return (
|
||
<div key={u.clientId} className="relative group/avatar cursor-default">
|
||
<div
|
||
className="w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold text-white shadow-sm ring-2 ring-canvas"
|
||
style={{ backgroundColor: u.userColor }}
|
||
>
|
||
{initials}
|
||
</div>
|
||
{/* Tooltip */}
|
||
<div className="pointer-events-none absolute bottom-full mb-1 left-1/2 -translate-x-1/2 opacity-0 group-hover/avatar:opacity-100 transition-opacity whitespace-nowrap bg-canvas text-main text-[10px] px-1.5 py-0.5 rounded-btn border border-border shadow-md z-30">
|
||
{u.userName}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{/* Sharing Section (1:1 styling from SharedListsView) */}
|
||
{doc && (() => {
|
||
const isOwner = doc.isOwner !== false && (doc.userId === currentUser?.id || !doc.userId || currentUser?.role === 'admin');
|
||
const sharesCount = doc.sharedWith?.length || 0;
|
||
const isShared = sharesCount > 0 || !isOwner || Boolean(doc.sharedFolderId);
|
||
const ownerDisplayName = doc.ownerName || 'Eigentümer';
|
||
|
||
return (
|
||
<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>
|
||
|
||
{/* Live Session Active Banner (when another collaborator is live) */}
|
||
{isLockedByOtherLive && (
|
||
<div className="flex items-center gap-2 px-4 sm:px-6 py-2.5 bg-status-warn/15 border-b border-status-warn/30 text-status-warn text-xs font-medium">
|
||
<Lock className="w-4 h-4 shrink-0" />
|
||
<span>
|
||
Live-Sitzung aktiv (Bearbeitet von <strong>{liveSession?.initiatorName || 'anderer Nutzer'}</strong>) – Dokument schreibgeschützt, um Versionskonflikte zu vermeiden.
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Main Content Area */}
|
||
<div className="max-w-4xl w-full mx-auto px-4 sm:px-8 py-5 flex-1">
|
||
{zoomedBlock ? (
|
||
/* =========================================================================
|
||
FOKUS-MODUS & ZOOM-IN (HOISTED SUBTREE VIEW)
|
||
========================================================================= */
|
||
<div className="space-y-3">
|
||
{/* Prominent Zoomed Ast-Heading */}
|
||
<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={!effectiveCanEdit}
|
||
className="w-full text-2xl sm:text-3xl font-extrabold text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight leading-tight"
|
||
/>
|
||
</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>
|
||
|
||
{/* Subtree Children or Empty State */}
|
||
{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>
|
||
{effectiveCanEdit && (
|
||
<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={effectiveCanEdit}
|
||
levelOffset={levelOffset}
|
||
onZoomBlock={setZoomedBlockId}
|
||
remoteCursors={remoteCursors}
|
||
sendCursorMoved={sendCursorMoved}
|
||
onUpdateBlock={handleUpdateBlock}
|
||
onCreateBlock={handleCreateBlock}
|
||
onDeleteBlock={handleDeleteBlock}
|
||
onMoveBlock={handleMoveBlock}
|
||
onTagClick={onSelectTag}
|
||
onWikiLinkClick={onWikiLinkClick}
|
||
initialFocusedBlockId={initialFocusedBlockId}
|
||
activeBlockLocks={activeBlockLocks}
|
||
onFocusBlock={handleFocusBlock}
|
||
onBlurBlock={handleBlurBlock}
|
||
currentUserId={currentUser?.id}
|
||
/>
|
||
)}
|
||
</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={() => effectiveCanEdit && 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 && effectiveCanEdit && (
|
||
<div className="absolute left-0 mt-1 p-2 panel-surface shadow-panel border border-border rounded-card grid grid-cols-6 gap-1 z-30 w-52">
|
||
{EMOJI_PALETTE.map((emoji) => (
|
||
<button
|
||
key={emoji}
|
||
type="button"
|
||
onClick={() => handleSelectIcon(emoji)}
|
||
className="p-1.5 rounded-btn hover:bg-panel-elevated text-base flex items-center justify-center cursor-pointer"
|
||
>
|
||
{emoji}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<input
|
||
ref={titleInputRef}
|
||
type="text"
|
||
value={title}
|
||
readOnly={!effectiveCanEdit}
|
||
autoFocus={autoFocusTitle}
|
||
onChange={handleTitleChange}
|
||
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"
|
||
/>
|
||
</div>
|
||
|
||
{/* Logseq Outliner Tree */}
|
||
<OutlinerTree
|
||
docId={docId}
|
||
blocks={blocks}
|
||
canEdit={effectiveCanEdit}
|
||
levelOffset={0}
|
||
onZoomBlock={setZoomedBlockId}
|
||
remoteCursors={remoteCursors}
|
||
sendCursorMoved={sendCursorMoved}
|
||
onUpdateBlock={handleUpdateBlock}
|
||
onCreateBlock={handleCreateBlock}
|
||
onDeleteBlock={handleDeleteBlock}
|
||
onMoveBlock={handleMoveBlock}
|
||
onTagClick={onSelectTag}
|
||
onWikiLinkClick={onWikiLinkClick}
|
||
initialFocusedBlockId={initialFocusedBlockId}
|
||
activeBlockLocks={activeBlockLocks}
|
||
onFocusBlock={handleFocusBlock}
|
||
onBlurBlock={handleBlurBlock}
|
||
currentUserId={currentUser?.id}
|
||
/>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|