MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
1123 lines
41 KiB
TypeScript
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>
|
|
);
|
|
};
|