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