MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m31s
5857 lines
237 KiB
TypeScript
5857 lines
237 KiB
TypeScript
import React, { useEffect, useLayoutEffect, useState, useMemo, useRef } from 'react';
|
|
import {
|
|
FilePlus,
|
|
FolderPlus,
|
|
Folder,
|
|
FolderOpen,
|
|
ChevronRight,
|
|
ChevronDown,
|
|
Search,
|
|
Pin,
|
|
Trash2,
|
|
Download,
|
|
CheckSquare,
|
|
Heading1,
|
|
Heading2,
|
|
Heading3,
|
|
List,
|
|
Code,
|
|
Sparkles,
|
|
AlignLeft,
|
|
Check,
|
|
X,
|
|
ArrowUp,
|
|
ArrowDown,
|
|
Bold,
|
|
Italic,
|
|
Strikethrough,
|
|
Link,
|
|
FileText,
|
|
Table as TableIcon,
|
|
Percent,
|
|
Minus,
|
|
ExternalLink,
|
|
ArrowLeft,
|
|
GripVertical,
|
|
Layers,
|
|
Filter,
|
|
CheckCircle2,
|
|
Circle,
|
|
FolderTree,
|
|
Plus,
|
|
HelpCircle,
|
|
Users,
|
|
Lock,
|
|
Unlock,
|
|
Eye,
|
|
Edit3,
|
|
Hash,
|
|
User as UserIcon,
|
|
Radio,
|
|
} from 'lucide-react';
|
|
import { BlockType, Document, DocumentBlock, DocBlocksPatchEvent, DocLockInfo, DocShare, FolderShare, SharedFolder, TagDetails, TagSummary, Team, User } from '../../lib/types';
|
|
import { api, getToken, triggerHaptic, onWebSocketStatus, isWebSocketConnected, sendWebSocketMessage, createRealtimeConnection, RealtimeTransport } from '../../lib/api';
|
|
import { CodeBlockItem } from './CodeBlockItem';
|
|
import { UserAutocomplete } from '../UserAutocomplete';
|
|
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
|
import { useMobileActions } from '../../context/MobileActionContext';
|
|
import { DocumentCRDTSession, CollaboratorAwareness, getCollaboratorColor } from '../../lib/crdt';
|
|
|
|
interface WorkspaceViewProps {
|
|
currentUser?: User | null;
|
|
initialDocId?: string | null;
|
|
onStatsChange: () => void;
|
|
}
|
|
|
|
const ICONS = ['📄', '📝', '💡', '🚀', '🏡', '⚡', '🎯', '🍕', '🛠️', '🧠', '🔥', '🏔️', '📦', '📑', '⚙️', '📊'];
|
|
|
|
interface FolderTreeItem {
|
|
path: string;
|
|
name: string;
|
|
depth: number;
|
|
subfolders: FolderTreeItem[];
|
|
docs: Document[];
|
|
sharedFolder?: SharedFolder;
|
|
isShared?: boolean;
|
|
isSharedRoot?: boolean;
|
|
}
|
|
|
|
interface SlashCommandItem {
|
|
type?: BlockType;
|
|
action?: 'insert_link' | 'insert_note' | 'insert_weblink';
|
|
label: string;
|
|
desc: string;
|
|
icon: string;
|
|
}
|
|
|
|
const SLASH_COMMANDS: SlashCommandItem[] = [
|
|
{ action: 'insert_link', label: '/link', desc: 'Webseite oder Notiz verlinken', icon: '🔗' },
|
|
{ action: 'insert_note', label: '/notiz', desc: 'Notiz verlinken [[Notiz]]', icon: '📄' },
|
|
{ action: 'insert_weblink', label: '/web', desc: 'Externe Webseite verlinken [Name](url)', icon: '🌐' },
|
|
{ type: 'todo', label: '/todo', desc: 'Aufgabe mit Checkbox', icon: '☑️' },
|
|
{ type: 'h1', label: '/h1', desc: 'Große Überschrift', icon: 'H1' },
|
|
{ type: 'h2', label: '/h2', desc: 'Abschnitts-Überschrift', icon: 'H2' },
|
|
{ type: 'h3', label: '/h3', desc: 'Unterabschnitt', icon: 'H3' },
|
|
{ type: 'bullet', label: '/bullet', desc: 'Standard Listenpunkt', icon: '•' },
|
|
{ type: 'callout', label: '/callout', desc: 'Hervorgehobene Hinweisbox', icon: '💡' },
|
|
{ type: 'code', label: '/code', desc: 'Code-Block mit Syntax', icon: '💻' },
|
|
{ type: 'table', label: '/table', desc: 'Markdown-Tabelle', icon: '📊' },
|
|
{ type: 'progress', label: '/progress', desc: 'Fortschrittsbalken', icon: '📈' },
|
|
{ type: 'divider', label: '/divider', desc: 'Horizontale Trennlinie', icon: '—' },
|
|
];
|
|
|
|
export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|
currentUser,
|
|
initialDocId,
|
|
onStatsChange,
|
|
}) => {
|
|
const { setGroups, setPrimaryAction } = useMobileActions();
|
|
const [showMobileFileDrawer, setShowMobileFileDrawer] = useState(false);
|
|
|
|
// Top Workspace Tab Mode: 'editor' vs 'todos' vs 'tags'
|
|
const [workspaceMode, setWorkspaceMode] = useState<'editor' | 'todos' | 'tags'>('editor');
|
|
const [selectedTag, setSelectedTag] = useState<string | null>(null);
|
|
const [tagDetails, setTagDetails] = useState<TagDetails | null>(null);
|
|
const [workspaceTags, setWorkspaceTags] = useState<TagSummary[]>([]);
|
|
const [isLoadingTag, setIsLoadingTag] = useState(false);
|
|
const [tagSearchQuery, setTagSearchQuery] = useState('');
|
|
const [tagNoteContent, setTagNoteContent] = useState('');
|
|
const [tagNoteSaveState, setTagNoteSaveState] = useState<'saved' | 'saving'>('saved');
|
|
const tagNoteSaveTimerRef = useRef<any>(null);
|
|
|
|
// Collapsible sidebar sections
|
|
const [isPinnedCollapsed, setIsPinnedCollapsed] = useState(false);
|
|
const [isTagsCollapsed, setIsTagsCollapsed] = useState(false);
|
|
|
|
const [docs, setDocs] = useState<Document[]>([]);
|
|
const [selectedDoc, setSelectedDoc] = useState<Document | null>(null);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [saveState, setSaveState] = useState<'saved' | 'saving'>('saved');
|
|
const [showIconPicker, setShowIconPicker] = useState(false);
|
|
const [showWikiLinkMenu, setShowWikiLinkMenu] = useState(false);
|
|
|
|
// Realtime connection & status for live indicator
|
|
const realtimeConnRef = useRef<any>(null);
|
|
const [wsConnected, setWsConnected] = useState<boolean>(false);
|
|
const [realtimeTransport, setRealtimeTransport] = useState<RealtimeTransport>('none');
|
|
const [wsDisconnectReason, setWsDisconnectReason] = useState<string | null>(null);
|
|
const onStatsChangeRef = useRef(onStatsChange);
|
|
onStatsChangeRef.current = onStatsChange;
|
|
const lastLocalEditTimestampRef = useRef<number>(0);
|
|
const isSavingToBackendRef = useRef<boolean>(false);
|
|
|
|
// Folder state (synchronized with server)
|
|
const [customFolders, setCustomFolders] = useState<string[]>([]);
|
|
useEffect(() => {
|
|
try {
|
|
localStorage.removeItem('mindsift_workspace_folders');
|
|
} catch {}
|
|
}, []);
|
|
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
|
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
|
|
const [newFolderParent, setNewFolderParent] = useState<string | null>(null);
|
|
const [newFolderName, setNewFolderName] = useState('');
|
|
|
|
// Sidebar Document & Folder Drag and Drop state
|
|
const [draggingDocId, setDraggingDocId] = useState<string | null>(null);
|
|
const [draggingFolderPath, setDraggingFolderPath] = useState<string | null>(null);
|
|
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
|
|
|
|
// Active focused block ref
|
|
const [lastFocusedBlockId, setLastFocusedBlockId] = useState<string | null>(null);
|
|
|
|
// --- LOGSEQ OUTLINER SPECIFIC STATE ---
|
|
// Zoom-in / Focus mode (Hoisting to a bullet)
|
|
const [zoomedBlockId, setZoomedBlockId] = useState<string | null>(null);
|
|
// Floating Slash Command menu
|
|
const [slashBlockId, setSlashBlockId] = useState<string | null>(null);
|
|
const [slashSearch, setSlashSearch] = useState('');
|
|
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0);
|
|
const slashMenuRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
// Link Insertion Modal state (/link, /notiz, /web)
|
|
const [linkModalBlockId, setLinkModalBlockId] = useState<string | null>(null);
|
|
const [linkModalTab, setLinkModalTab] = useState<'note' | 'web'>('note');
|
|
const [linkSearchQuery, setLinkSearchQuery] = useState('');
|
|
const [linkWebTitle, setLinkWebTitle] = useState('');
|
|
const [linkWebUrl, setLinkWebUrl] = useState('');
|
|
const [activeEditingBlockId, setActiveEditingBlockId] = useState<string | null>(null);
|
|
const [activeCursorOffset, setActiveCursorOffset] = useState<number>(0);
|
|
const activeCursorOffsetRef = useRef<number>(0);
|
|
const [editingLinkOriginalRaw, setEditingLinkOriginalRaw] = useState<string | null>(null);
|
|
|
|
const setBlockCursor = (blockId: string | null, offset: number) => {
|
|
activeCursorOffsetRef.current = offset;
|
|
setActiveEditingBlockId(blockId);
|
|
setActiveCursorOffset(offset);
|
|
};
|
|
|
|
const autoResizeTextarea = (el: HTMLTextAreaElement | null) => {
|
|
if (!el) return;
|
|
el.style.height = 'auto';
|
|
el.style.height = `${el.scrollHeight}px`;
|
|
};
|
|
|
|
// Drag and Drop reordering state for blocks
|
|
const [draggingBlockId, setDraggingBlockId] = useState<string | null>(null);
|
|
const [dragOverBlockTarget, setDragOverBlockTarget] = useState<{ blockId: string; position: 'before' | 'after' | 'inside' } | null>(null);
|
|
|
|
// Modal for deleting block with sub-bulletpoints (X button)
|
|
const [deleteSubpointsModal, setDeleteSubpointsModal] = useState<{
|
|
blockId: string;
|
|
count: number;
|
|
snippet: string;
|
|
} | null>(null);
|
|
|
|
// --- GLOBAL TO-DO ROLLUP STATE ---
|
|
const [todoFilter, setTodoFilter] = useState<'all' | 'open' | 'done'>('open');
|
|
const [todoFolderDrilldown, setTodoFolderDrilldown] = useState<string | null>(null);
|
|
const [collapsedTodoFolders, setCollapsedTodoFolders] = useState<Set<string>>(new Set());
|
|
|
|
// --- DOCUMENT SHARING STATE ---
|
|
const [showShareModal, setShowShareModal] = useState(false);
|
|
const [docShares, setDocShares] = useState<DocShare[]>([]);
|
|
const [collaborators, setCollaborators] = useState<User[]>([]);
|
|
const [shareTeams, setShareTeams] = useState<Team[]>([]);
|
|
const [addShareType, setAddShareType] = useState<'team' | 'user'>('team');
|
|
const [selectedShareTeamId, setSelectedShareTeamId] = useState('');
|
|
const [selectedShareUser, setSelectedShareUser] = useState<User | null>(null);
|
|
const [sharePermission, setSharePermission] = useState<'read' | 'write'>('read');
|
|
const [sharingLoading, setSharingLoading] = useState(false);
|
|
const [shareError, setShareError] = useState<string | null>(null);
|
|
|
|
// --- FOLDER SHARING STATE ---
|
|
const [sharedFolders, setSharedFolders] = useState<SharedFolder[]>([]);
|
|
const [sharingFolder, setSharingFolder] = useState<SharedFolder | null>(null);
|
|
const [showFolderShareModal, setShowFolderShareModal] = useState(false);
|
|
const [folderShares, setFolderShares] = useState<FolderShare[]>([]);
|
|
|
|
// --- DOCUMENT CRDT & COLLABORATION STATE ---
|
|
const crdtSessionRef = useRef<DocumentCRDTSession | null>(null);
|
|
const [activePeers, setActivePeers] = useState<CollaboratorAwareness[]>([]);
|
|
|
|
// --- DOCUMENT LOCKING STATE (FALLBACK) ---
|
|
const [activeLock, setActiveLock] = useState<DocLockInfo | null>(null);
|
|
const [holdsLock, setHoldsLock] = useState(false);
|
|
const holdsLockRef = useRef(false);
|
|
const heartbeatTimerRef = useRef<any>(null);
|
|
const idleUnlockTimerRef = useRef<any>(null);
|
|
const currentDocIdRef = useRef<string | null>(null);
|
|
const isEditorFocusedRef = useRef(false);
|
|
const isDraggingBlockRef = useRef(false);
|
|
|
|
const blockInputRefs = useRef<Record<string, HTMLTextAreaElement | HTMLInputElement | null>>({});
|
|
|
|
// With CRDT, multiple users can write simultaneously without lockouts
|
|
const isLockedByOther = false;
|
|
const isReadOnly = selectedDoc?.permission === 'read';
|
|
|
|
// Track server-synced blocks & metadata per document, plus local unsaved block deltas
|
|
const syncedDocMetaRef = useRef<Map<string, { title: string; icon: string; tagsJson: string; isPinned: boolean }>>(new Map());
|
|
const syncedBlocksRef = useRef<Map<string, Map<string, DocumentBlock>>>(new Map());
|
|
const syncedOrderRef = useRef<Map<string, string[]>>(new Map());
|
|
const localDirtyBlocksRef = useRef<Map<string, Map<string, DocumentBlock>>>(new Map());
|
|
const localDeletedBlocksRef = useRef<Map<string, Set<string>>>(new Map());
|
|
|
|
const blocksAreEqual = (a?: DocumentBlock, b?: DocumentBlock): boolean => {
|
|
if (!a || !b) return false;
|
|
return (
|
|
a.id === b.id &&
|
|
(a.type || 'bullet') === (b.type || 'bullet') &&
|
|
(a.content || '') === (b.content || '') &&
|
|
(a.level || 0) === (b.level || 0) &&
|
|
!!a.checked === !!b.checked &&
|
|
!!a.collapsed === !!b.collapsed &&
|
|
(a.lang || '') === (b.lang || '')
|
|
);
|
|
};
|
|
|
|
const recordSyncedDoc = (doc: Document) => {
|
|
if (!doc || !doc.id) return;
|
|
syncedDocMetaRef.current.set(doc.id, {
|
|
title: doc.title || '',
|
|
icon: doc.icon || '📄',
|
|
tagsJson: JSON.stringify((doc.tags || []).filter((t) => !t.startsWith('folder:'))),
|
|
isPinned: !!doc.isPinned,
|
|
});
|
|
const bMap = new Map<string, DocumentBlock>();
|
|
const order: string[] = [];
|
|
(doc.blocks || []).forEach((b) => {
|
|
bMap.set(b.id, { ...b });
|
|
order.push(b.id);
|
|
});
|
|
syncedBlocksRef.current.set(doc.id, bMap);
|
|
syncedOrderRef.current.set(doc.id, order);
|
|
};
|
|
|
|
const mergeIncomingBlocks = (
|
|
docId: string,
|
|
currentBlocks: DocumentBlock[],
|
|
patch: { upserted?: DocumentBlock[]; deletedIds?: string[]; blockOrder?: string[] }
|
|
): DocumentBlock[] => {
|
|
const dirtyMap = localDirtyBlocksRef.current.get(docId);
|
|
const localDeleted = localDeletedBlocksRef.current.get(docId);
|
|
|
|
// Update server baseline refs
|
|
let srvMap = syncedBlocksRef.current.get(docId);
|
|
if (!srvMap) {
|
|
srvMap = new Map<string, DocumentBlock>();
|
|
currentBlocks.forEach((b) => srvMap!.set(b.id, { ...b }));
|
|
syncedBlocksRef.current.set(docId, srvMap);
|
|
}
|
|
const deletedSet = new Set<string>(patch.deletedIds || []);
|
|
deletedSet.forEach((delId) => srvMap!.delete(delId));
|
|
(patch.upserted || []).forEach((b) => srvMap!.set(b.id, { ...b }));
|
|
if (patch.blockOrder && patch.blockOrder.length > 0) {
|
|
syncedOrderRef.current.set(docId, [...patch.blockOrder]);
|
|
}
|
|
|
|
// Build merged block map preserving any block with unsaved local edits
|
|
const blockMap = new Map<string, DocumentBlock>();
|
|
currentBlocks.forEach((b) => {
|
|
if (!deletedSet.has(b.id) || dirtyMap?.has(b.id)) {
|
|
blockMap.set(b.id, b);
|
|
}
|
|
});
|
|
|
|
(patch.upserted || []).forEach((b) => {
|
|
if (localDeleted?.has(b.id)) {
|
|
return;
|
|
}
|
|
if (dirtyMap?.has(b.id)) {
|
|
blockMap.set(b.id, dirtyMap.get(b.id)!);
|
|
} else {
|
|
blockMap.set(b.id, b);
|
|
}
|
|
});
|
|
|
|
// Reconcile ordering
|
|
const mergedOrder: string[] = [];
|
|
const seen = new Set<string>();
|
|
|
|
if (patch.blockOrder && patch.blockOrder.length > 0) {
|
|
patch.blockOrder.forEach((id) => {
|
|
if (blockMap.has(id) && !seen.has(id) && !localDeleted?.has(id)) {
|
|
mergedOrder.push(id);
|
|
seen.add(id);
|
|
}
|
|
});
|
|
// Preserve any locally created unsaved blocks in their relative position
|
|
currentBlocks.forEach((b, idx) => {
|
|
if (!seen.has(b.id) && blockMap.has(b.id) && !deletedSet.has(b.id)) {
|
|
let insertIdx = 0;
|
|
for (let j = idx - 1; j >= 0; j--) {
|
|
const predPos = mergedOrder.indexOf(currentBlocks[j].id);
|
|
if (predPos !== -1) {
|
|
insertIdx = predPos + 1;
|
|
break;
|
|
}
|
|
}
|
|
mergedOrder.splice(insertIdx, 0, b.id);
|
|
seen.add(b.id);
|
|
}
|
|
});
|
|
} else {
|
|
currentBlocks.forEach((b) => {
|
|
if (blockMap.has(b.id) && !seen.has(b.id)) {
|
|
mergedOrder.push(b.id);
|
|
seen.add(b.id);
|
|
}
|
|
});
|
|
(patch.upserted || []).forEach((b) => {
|
|
if (blockMap.has(b.id) && !seen.has(b.id)) {
|
|
mergedOrder.push(b.id);
|
|
seen.add(b.id);
|
|
}
|
|
});
|
|
}
|
|
|
|
return mergedOrder.map((id) => blockMap.get(id)!).filter(Boolean);
|
|
};
|
|
|
|
const stopHeartbeat = () => {
|
|
if (heartbeatTimerRef.current) {
|
|
clearInterval(heartbeatTimerRef.current);
|
|
heartbeatTimerRef.current = null;
|
|
}
|
|
};
|
|
|
|
const startHeartbeat = (docId: string) => {
|
|
stopHeartbeat();
|
|
heartbeatTimerRef.current = setInterval(async () => {
|
|
if (!holdsLockRef.current) {
|
|
stopHeartbeat();
|
|
return;
|
|
}
|
|
const res = await api.heartbeatDocLock(docId);
|
|
if (!res.extended) {
|
|
holdsLockRef.current = false;
|
|
setHoldsLock(false);
|
|
stopHeartbeat();
|
|
}
|
|
}, 4000);
|
|
};
|
|
|
|
const releaseMyLock = async (docId?: string) => {
|
|
const targetDocId = docId || currentDocIdRef.current;
|
|
if (!targetDocId) return;
|
|
const wasHolding = holdsLockRef.current;
|
|
stopHeartbeat();
|
|
if (idleUnlockTimerRef.current) {
|
|
clearTimeout(idleUnlockTimerRef.current);
|
|
idleUnlockTimerRef.current = null;
|
|
}
|
|
holdsLockRef.current = false;
|
|
setHoldsLock(false);
|
|
if (wasHolding || docId) {
|
|
try {
|
|
await api.unlockDocument(targetDocId);
|
|
} catch {}
|
|
}
|
|
};
|
|
|
|
const ensureLock = async (force = false): Promise<boolean> => {
|
|
if (!selectedDoc || selectedDoc.permission === 'read') return false;
|
|
if (holdsLockRef.current && !force) {
|
|
return true;
|
|
}
|
|
try {
|
|
const res = await api.lockDocument(selectedDoc.id);
|
|
if (res.acquired) {
|
|
holdsLockRef.current = true;
|
|
setHoldsLock(true);
|
|
setActiveLock(null);
|
|
startHeartbeat(selectedDoc.id);
|
|
return true;
|
|
} else if (res.lock) {
|
|
setActiveLock(res.lock);
|
|
return false;
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to acquire lock', e);
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const broadcastAwareness = (blockId: string | null) => {
|
|
if (!selectedDoc) return;
|
|
const payload = {
|
|
userId: currentUser?.id,
|
|
userName: currentUser?.displayName || currentUser?.username || 'Kollege',
|
|
activeBlockId: blockId,
|
|
color: getCollaboratorColor(currentUser?.id || ''),
|
|
lastActive: Date.now(),
|
|
};
|
|
sendWebSocketMessage({
|
|
event: 'CRDT_AWARENESS',
|
|
docId: selectedDoc.id,
|
|
payload,
|
|
});
|
|
};
|
|
|
|
const handleBlockFocus = (blockIdOrEvent?: string | React.FocusEvent<any>) => {
|
|
const blockId = typeof blockIdOrEvent === 'string' ? blockIdOrEvent : undefined;
|
|
isEditorFocusedRef.current = true;
|
|
if (idleUnlockTimerRef.current) {
|
|
clearTimeout(idleUnlockTimerRef.current);
|
|
idleUnlockTimerRef.current = null;
|
|
}
|
|
if (blockId) {
|
|
broadcastAwareness(blockId);
|
|
}
|
|
if (!isReadOnly) {
|
|
ensureLock();
|
|
}
|
|
};
|
|
|
|
const handleBlockBlur = () => {
|
|
isEditorFocusedRef.current = false;
|
|
broadcastAwareness(null);
|
|
if (idleUnlockTimerRef.current) {
|
|
clearTimeout(idleUnlockTimerRef.current);
|
|
}
|
|
if (isDraggingBlockRef.current) {
|
|
return;
|
|
}
|
|
idleUnlockTimerRef.current = setTimeout(async () => {
|
|
if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) {
|
|
await flushPendingSave();
|
|
await releaseMyLock();
|
|
}
|
|
}, 8000);
|
|
};
|
|
|
|
// Debounced auto-save refs (500ms delay)
|
|
const debounceSaveTimerRef = useRef<any>(null);
|
|
const pendingSaveDocRef = useRef<Document | null>(null);
|
|
|
|
const executeSaveToBackend = async (docToSave: Document) => {
|
|
if (isReadOnly) return;
|
|
let saveSucceeded = false;
|
|
isSavingToBackendRef.current = true;
|
|
try {
|
|
const docId = docToSave.id;
|
|
const dirtyMap = localDirtyBlocksRef.current.get(docId);
|
|
const deletedSet = localDeletedBlocksRef.current.get(docId);
|
|
const srvBlocks = syncedBlocksRef.current.get(docId);
|
|
const srvOrder = syncedOrderRef.current.get(docId) || [];
|
|
|
|
const upsertedMap = new Map<string, DocumentBlock>();
|
|
if (dirtyMap) {
|
|
dirtyMap.forEach((b, id) => upsertedMap.set(id, b));
|
|
}
|
|
(docToSave.blocks || []).forEach((b) => {
|
|
const srv = srvBlocks?.get(b.id);
|
|
if (!srv || !blocksAreEqual(b, srv)) {
|
|
upsertedMap.set(b.id, b);
|
|
}
|
|
});
|
|
|
|
const upsertedToSend = Array.from(upsertedMap.values());
|
|
const deletedToSend = Array.from(deletedSet?.values() || []);
|
|
const currentOrder = (docToSave.blocks || []).map((b) => b.id);
|
|
const orderChanged =
|
|
currentOrder.length !== srvOrder.length ||
|
|
currentOrder.some((id, idx) => id !== srvOrder[idx]);
|
|
|
|
const srvMeta = syncedDocMetaRef.current.get(docId);
|
|
const cleanTags = (docToSave.tags || []).filter((t) => !t.startsWith('folder:'));
|
|
const cleanTagsJson = JSON.stringify(cleanTags);
|
|
const metaChanged =
|
|
!srvMeta ||
|
|
srvMeta.title !== docToSave.title ||
|
|
srvMeta.icon !== docToSave.icon ||
|
|
srvMeta.tagsJson !== cleanTagsJson ||
|
|
srvMeta.isPinned !== !!docToSave.isPinned;
|
|
|
|
// Clear the sent snapshot from dirty/deleted maps before awaiting network call
|
|
if (dirtyMap) {
|
|
upsertedToSend.forEach((b) => {
|
|
const cur = dirtyMap.get(b.id);
|
|
if (cur && blocksAreEqual(cur, b)) {
|
|
dirtyMap.delete(b.id);
|
|
}
|
|
});
|
|
}
|
|
if (deletedSet) {
|
|
deletedToSend.forEach((id) => deletedSet.delete(id));
|
|
}
|
|
|
|
if (upsertedToSend.length > 0 || deletedToSend.length > 0 || orderChanged) {
|
|
const patchedDoc = await api.patchDocumentBlocks(docId, {
|
|
upserted: upsertedToSend,
|
|
deletedIds: deletedToSend,
|
|
blockOrder: currentOrder,
|
|
});
|
|
// Update server block baseline with patched result
|
|
const nextSrvMap = new Map<string, DocumentBlock>();
|
|
const nextSrvOrder: string[] = [];
|
|
(patchedDoc.blocks || []).forEach((b) => {
|
|
nextSrvMap.set(b.id, { ...b });
|
|
nextSrvOrder.push(b.id);
|
|
});
|
|
syncedBlocksRef.current.set(docId, nextSrvMap);
|
|
syncedOrderRef.current.set(docId, nextSrvOrder);
|
|
}
|
|
|
|
if (metaChanged) {
|
|
await api.updateDocument(docId, {
|
|
title: docToSave.title,
|
|
icon: docToSave.icon,
|
|
tags: cleanTags,
|
|
isPinned: docToSave.isPinned,
|
|
});
|
|
syncedDocMetaRef.current.set(docId, {
|
|
title: docToSave.title,
|
|
icon: docToSave.icon,
|
|
tagsJson: cleanTagsJson,
|
|
isPinned: !!docToSave.isPinned,
|
|
});
|
|
}
|
|
|
|
saveSucceeded = true;
|
|
onStatsChangeRef.current();
|
|
api.getWorkspaceTags().then(setWorkspaceTags).catch(() => {});
|
|
if (selectedTag) {
|
|
api.getTagDetails(selectedTag).then(setTagDetails).catch(() => {});
|
|
}
|
|
} catch (e: any) {
|
|
console.error('Failed to save document to backend', e);
|
|
} finally {
|
|
isSavingToBackendRef.current = false;
|
|
if (saveSucceeded) {
|
|
if (pendingSaveDocRef.current === null || pendingSaveDocRef.current === docToSave) {
|
|
pendingSaveDocRef.current = null;
|
|
setSaveState('saved');
|
|
}
|
|
} else {
|
|
setSaveState('saving');
|
|
}
|
|
if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) {
|
|
if (idleUnlockTimerRef.current) {
|
|
clearTimeout(idleUnlockTimerRef.current);
|
|
}
|
|
idleUnlockTimerRef.current = setTimeout(async () => {
|
|
if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) {
|
|
await releaseMyLock();
|
|
}
|
|
}, 8000);
|
|
}
|
|
}
|
|
};
|
|
|
|
const flushPendingSave = async () => {
|
|
if (debounceSaveTimerRef.current) {
|
|
clearTimeout(debounceSaveTimerRef.current);
|
|
debounceSaveTimerRef.current = null;
|
|
}
|
|
if (pendingSaveDocRef.current) {
|
|
const doc = pendingSaveDocRef.current;
|
|
pendingSaveDocRef.current = null;
|
|
await executeSaveToBackend(doc);
|
|
}
|
|
};
|
|
|
|
const persistDocument = async (updatedDoc: Document, immediate = false) => {
|
|
if (isReadOnly) return;
|
|
ensureLock();
|
|
lastLocalEditTimestampRef.current = Date.now();
|
|
|
|
// Record block-level delta against previous local state and server baseline
|
|
const docId = updatedDoc.id;
|
|
let dirtyMap = localDirtyBlocksRef.current.get(docId);
|
|
if (!dirtyMap) {
|
|
dirtyMap = new Map<string, DocumentBlock>();
|
|
localDirtyBlocksRef.current.set(docId, dirtyMap);
|
|
}
|
|
let deletedSet = localDeletedBlocksRef.current.get(docId);
|
|
if (!deletedSet) {
|
|
deletedSet = new Set<string>();
|
|
localDeletedBlocksRef.current.set(docId, deletedSet);
|
|
}
|
|
const srvBlocks = syncedBlocksRef.current.get(docId);
|
|
const prevLocalBlocks =
|
|
selectedDoc && selectedDoc.id === docId ? selectedDoc.blocks || [] : [];
|
|
|
|
const nextIds = new Set<string>();
|
|
(updatedDoc.blocks || []).forEach((b) => {
|
|
nextIds.add(b.id);
|
|
deletedSet!.delete(b.id);
|
|
const srv = srvBlocks?.get(b.id);
|
|
if (!srv || !blocksAreEqual(b, srv)) {
|
|
dirtyMap!.set(b.id, { ...b });
|
|
}
|
|
});
|
|
|
|
prevLocalBlocks.forEach((prevB) => {
|
|
if (!nextIds.has(prevB.id)) {
|
|
dirtyMap!.delete(prevB.id);
|
|
deletedSet!.add(prevB.id);
|
|
}
|
|
});
|
|
|
|
// Sync into active CRDT session
|
|
if (crdtSessionRef.current && crdtSessionRef.current.docId === updatedDoc.id) {
|
|
crdtSessionRef.current.syncFullBlocks(updatedDoc.blocks || []);
|
|
}
|
|
|
|
// 1. Update local UI state immediately for responsive, instant typing
|
|
setSelectedDoc(updatedDoc);
|
|
setDocs((prev) => prev.map((d) => (d.id === updatedDoc.id ? updatedDoc : d)));
|
|
setSaveState('saving');
|
|
|
|
pendingSaveDocRef.current = updatedDoc;
|
|
|
|
if (debounceSaveTimerRef.current) {
|
|
clearTimeout(debounceSaveTimerRef.current);
|
|
debounceSaveTimerRef.current = null;
|
|
}
|
|
|
|
if (immediate) {
|
|
await flushPendingSave();
|
|
} else {
|
|
debounceSaveTimerRef.current = setTimeout(async () => {
|
|
debounceSaveTimerRef.current = null;
|
|
if (pendingSaveDocRef.current) {
|
|
const doc = pendingSaveDocRef.current;
|
|
pendingSaveDocRef.current = null;
|
|
await executeSaveToBackend(doc);
|
|
}
|
|
}, 350);
|
|
}
|
|
};
|
|
|
|
const loadDocs = async (selectId?: string | null) => {
|
|
try {
|
|
const [list, serverFolders, tags, sFolders] = await Promise.all([
|
|
api.getDocuments(),
|
|
api.getFolders(),
|
|
api.getWorkspaceTags().catch(() => []),
|
|
api.getSharedFolders().catch(() => []),
|
|
]);
|
|
list.forEach(recordSyncedDoc);
|
|
setDocs(list);
|
|
setCustomFolders(serverFolders);
|
|
setWorkspaceTags(tags);
|
|
setSharedFolders(sFolders);
|
|
if (selectId) {
|
|
const found = list.find((d) => d.id === selectId);
|
|
if (found) {
|
|
setSelectedDoc(found);
|
|
return;
|
|
}
|
|
}
|
|
if (!selectedDoc && list.length > 0) {
|
|
setSelectedDoc(list[0]);
|
|
} else if (selectedDoc) {
|
|
const updated = list.find((d) => d.id === selectedDoc.id);
|
|
if (updated) setSelectedDoc(updated);
|
|
}
|
|
} catch (err) {
|
|
console.error('Error loading documents or folders:', err);
|
|
}
|
|
};
|
|
|
|
const openTagPage = async (tag: string) => {
|
|
const clean = tag.replace(/^#/, '').toLowerCase();
|
|
if (tagNoteSaveTimerRef.current) {
|
|
clearTimeout(tagNoteSaveTimerRef.current);
|
|
tagNoteSaveTimerRef.current = null;
|
|
}
|
|
setSelectedTag(clean);
|
|
setWorkspaceMode('tags');
|
|
setIsLoadingTag(true);
|
|
try {
|
|
const details = await api.getTagDetails(clean);
|
|
setTagDetails(details);
|
|
setTagNoteContent(details.noteContent || '');
|
|
setTagNoteSaveState('saved');
|
|
} catch (err) {
|
|
console.error('Failed to load tag details', err);
|
|
} finally {
|
|
setIsLoadingTag(false);
|
|
}
|
|
};
|
|
|
|
const handleTagNoteChange = (newContent: string) => {
|
|
setTagNoteContent(newContent);
|
|
if (!selectedTag) return;
|
|
const targetTag = selectedTag;
|
|
setTagNoteSaveState('saving');
|
|
if (tagNoteSaveTimerRef.current) {
|
|
clearTimeout(tagNoteSaveTimerRef.current);
|
|
}
|
|
tagNoteSaveTimerRef.current = setTimeout(async () => {
|
|
try {
|
|
await api.updateTagPageNote(targetTag, newContent);
|
|
setTagDetails((prev) =>
|
|
prev && prev.tag === targetTag ? { ...prev, noteContent: newContent } : prev
|
|
);
|
|
setTagNoteSaveState('saved');
|
|
} catch (err) {
|
|
console.error('Failed to save tag note', err);
|
|
setTagNoteSaveState('saved');
|
|
}
|
|
}, 450);
|
|
};
|
|
|
|
const handleExportTagMarkdown = () => {
|
|
if (!selectedTag) return;
|
|
const md = `# #${selectedTag}\n\n${tagNoteContent || ''}\n`;
|
|
const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `tag-${selectedTag}.md`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
useEffect(() => {
|
|
loadDocs(initialDocId);
|
|
}, [initialDocId]);
|
|
|
|
const selectedDocIdRef = useRef<string | null>(null);
|
|
selectedDocIdRef.current = selectedDoc?.id || null;
|
|
const currentUserIdRef = useRef<string | null>(null);
|
|
currentUserIdRef.current = currentUser?.id || null;
|
|
|
|
// Handle document switch, CRDT session lifecycle, and lock status checks
|
|
useEffect(() => {
|
|
const prevId = currentDocIdRef.current;
|
|
if (prevId && prevId !== selectedDoc?.id) {
|
|
flushPendingSave();
|
|
releaseMyLock(prevId);
|
|
sendWebSocketMessage({ event: 'CRDT_LEAVE', docId: prevId });
|
|
if (crdtSessionRef.current) {
|
|
crdtSessionRef.current.destroy();
|
|
crdtSessionRef.current = null;
|
|
}
|
|
setActivePeers([]);
|
|
}
|
|
currentDocIdRef.current = selectedDoc?.id || null;
|
|
|
|
if (selectedDoc) {
|
|
const targetDoc = selectedDoc;
|
|
const session = new DocumentCRDTSession(targetDoc.id);
|
|
session.initFromBlocks(targetDoc.blocks || []);
|
|
session.onLocalUpdate = (b64Update) => {
|
|
sendWebSocketMessage({
|
|
event: 'CRDT_UPDATE',
|
|
docId: targetDoc.id,
|
|
payload: { update: b64Update },
|
|
});
|
|
};
|
|
session.onRemoteChange = (crdtBlocks) => {
|
|
const recentlyEdited = Date.now() - lastLocalEditTimestampRef.current < 2500;
|
|
const hasPendingSave = pendingSaveDocRef.current !== null || isSavingToBackendRef.current;
|
|
const hasDirtyBlocks = (localDirtyBlocksRef.current.get(targetDoc.id)?.size || 0) > 0;
|
|
if (!recentlyEdited && !hasPendingSave && !hasDirtyBlocks) {
|
|
setSelectedDoc((prev) => (prev && prev.id === targetDoc.id ? { ...prev, blocks: crdtBlocks } : prev));
|
|
setDocs((prev) => prev.map((d) => (d.id === targetDoc.id ? { ...d, blocks: crdtBlocks } : d)));
|
|
}
|
|
};
|
|
crdtSessionRef.current = session;
|
|
|
|
sendWebSocketMessage({ event: 'CRDT_JOIN', docId: targetDoc.id });
|
|
|
|
api
|
|
.getDocumentCRDT(targetDoc.id)
|
|
.then((res) => {
|
|
if (res && crdtSessionRef.current && crdtSessionRef.current.docId === targetDoc.id) {
|
|
if (res.state) {
|
|
crdtSessionRef.current.applyRemoteUpdate(res.state);
|
|
}
|
|
if (res.updates && Array.isArray(res.updates)) {
|
|
crdtSessionRef.current.applyRemoteUpdates(res.updates);
|
|
}
|
|
const blocks = crdtSessionRef.current.getBlocks();
|
|
const recentlyEdited = Date.now() - lastLocalEditTimestampRef.current < 2500;
|
|
const hasPendingSave = pendingSaveDocRef.current !== null || isSavingToBackendRef.current;
|
|
const hasDirtyBlocks = (localDirtyBlocksRef.current.get(targetDoc.id)?.size || 0) > 0;
|
|
if (blocks.length > 0 && !recentlyEdited && !hasPendingSave && !hasDirtyBlocks) {
|
|
setSelectedDoc((prev) => (prev && prev.id === targetDoc.id ? { ...prev, blocks } : prev));
|
|
setDocs((prev) => prev.map((d) => (d.id === targetDoc.id ? { ...d, blocks } : d)));
|
|
}
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
|
|
api
|
|
.getDocLock(selectedDoc.id)
|
|
.then((res) => {
|
|
if (res.locked && res.lock && res.lock.userId !== currentUser?.id) {
|
|
setActiveLock(res.lock);
|
|
} else {
|
|
setActiveLock(null);
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
} else {
|
|
setActiveLock(null);
|
|
}
|
|
|
|
return () => {
|
|
flushPendingSave();
|
|
if (currentDocIdRef.current) {
|
|
sendWebSocketMessage({ event: 'CRDT_LEAVE', docId: currentDocIdRef.current });
|
|
if (crdtSessionRef.current) {
|
|
crdtSessionRef.current.destroy();
|
|
crdtSessionRef.current = null;
|
|
}
|
|
releaseMyLock(currentDocIdRef.current);
|
|
}
|
|
};
|
|
}, [selectedDoc?.id]);
|
|
|
|
// Window blur / visibility change listener: release lock when user leaves tab/window
|
|
useEffect(() => {
|
|
const handleWindowBlur = () => {
|
|
if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) {
|
|
flushPendingSave();
|
|
releaseMyLock();
|
|
}
|
|
};
|
|
const handleVisibilityChange = () => {
|
|
if (document.visibilityState === 'hidden' && !isDraggingBlockRef.current) {
|
|
isEditorFocusedRef.current = false;
|
|
flushPendingSave();
|
|
releaseMyLock();
|
|
}
|
|
};
|
|
window.addEventListener('blur', handleWindowBlur);
|
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
return () => {
|
|
window.removeEventListener('blur', handleWindowBlur);
|
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
};
|
|
}, []);
|
|
|
|
// Flush pending save and release lock before tab closes or unmounts
|
|
useEffect(() => {
|
|
const handleBeforeUnload = () => {
|
|
flushPendingSave();
|
|
if (currentDocIdRef.current && holdsLockRef.current) {
|
|
try {
|
|
const token = getToken();
|
|
fetch(`/api/v1/documents/${currentDocIdRef.current}/lock`, {
|
|
method: 'DELETE',
|
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
keepalive: true,
|
|
}).catch(() => {});
|
|
} catch {}
|
|
}
|
|
};
|
|
window.addEventListener('beforeunload', handleBeforeUnload);
|
|
return () => {
|
|
window.removeEventListener('beforeunload', handleBeforeUnload);
|
|
flushPendingSave();
|
|
if (currentDocIdRef.current && holdsLockRef.current) {
|
|
releaseMyLock(currentDocIdRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
// Dedicated Real-time WebSocket connection for Document, Block Delta, Lock & Folder events
|
|
useEffect(() => {
|
|
let isMounted = true;
|
|
|
|
const handleWsMsg = (msg: any) => {
|
|
if (!msg || !msg.event) return;
|
|
|
|
if (msg.event === 'CRDT_INIT') {
|
|
const payload = msg.payload as { docId: string; state?: string; updates?: string[] };
|
|
if (payload && payload.docId === selectedDocIdRef.current && crdtSessionRef.current) {
|
|
if (payload.state) {
|
|
crdtSessionRef.current.applyRemoteUpdate(payload.state);
|
|
}
|
|
if (payload.updates && Array.isArray(payload.updates)) {
|
|
crdtSessionRef.current.applyRemoteUpdates(payload.updates);
|
|
}
|
|
const blocks = crdtSessionRef.current.getBlocks();
|
|
const recentlyEdited = Date.now() - lastLocalEditTimestampRef.current < 2500;
|
|
const hasPendingSave = pendingSaveDocRef.current !== null || isSavingToBackendRef.current;
|
|
const hasDirtyBlocks = (localDirtyBlocksRef.current.get(payload.docId)?.size || 0) > 0;
|
|
if (blocks.length > 0 && !recentlyEdited && !hasPendingSave && !hasDirtyBlocks) {
|
|
setSelectedDoc((prev) => (prev && prev.id === payload.docId ? { ...prev, blocks } : prev));
|
|
setDocs((prev) => prev.map((d) => (d.id === payload.docId ? { ...d, blocks } : d)));
|
|
}
|
|
}
|
|
} else if (msg.event === 'CRDT_UPDATE') {
|
|
const payload = msg.payload as { docId: string; update: string; fromUserId?: string };
|
|
if (payload && payload.docId === selectedDocIdRef.current && crdtSessionRef.current) {
|
|
if (payload.fromUserId !== currentUserIdRef.current) {
|
|
crdtSessionRef.current.applyRemoteUpdate(payload.update);
|
|
const blocks = crdtSessionRef.current.getBlocks();
|
|
const recentlyEdited = Date.now() - lastLocalEditTimestampRef.current < 2500;
|
|
const hasPendingSave = pendingSaveDocRef.current !== null || isSavingToBackendRef.current;
|
|
const hasDirtyBlocks = (localDirtyBlocksRef.current.get(payload.docId)?.size || 0) > 0;
|
|
if (blocks && blocks.length > 0 && !recentlyEdited && !hasPendingSave && !hasDirtyBlocks) {
|
|
setSelectedDoc((prev) => (prev && prev.id === payload.docId ? { ...prev, blocks } : prev));
|
|
setDocs((prev) => prev.map((d) => (d.id === payload.docId ? { ...d, blocks } : d)));
|
|
}
|
|
}
|
|
}
|
|
} else if (msg.event === 'CRDT_AWARENESS') {
|
|
const payload = (msg.payload?.payload || msg.payload) as CollaboratorAwareness;
|
|
if (payload && payload.userId && payload.userId !== currentUserIdRef.current) {
|
|
setActivePeers((prev) => {
|
|
const filtered = prev.filter((c) => c.userId !== payload.userId);
|
|
return [...filtered, { ...payload, color: getCollaboratorColor(payload.userId), lastActive: Date.now() }];
|
|
});
|
|
}
|
|
} else if (msg.event === 'CRDT_AWARENESS_LEAVE') {
|
|
const payload = msg.payload as { docId?: string; userId?: string };
|
|
if (payload && payload.userId) {
|
|
setActivePeers((prev) => prev.filter((c) => c.userId !== payload.userId));
|
|
}
|
|
} else if (msg.event === 'DOC_LOCKED') {
|
|
const payload = msg.payload as DocLockInfo;
|
|
if (payload && payload.docId === selectedDocIdRef.current) {
|
|
if (payload.userId !== currentUserIdRef.current) {
|
|
setActiveLock(payload);
|
|
}
|
|
}
|
|
} else if (msg.event === 'DOC_UNLOCKED') {
|
|
const payload = msg.payload as { docId?: string; doc_id?: string; id?: string };
|
|
const unlockedDocId = payload?.docId || payload?.doc_id || payload?.id;
|
|
if (unlockedDocId && unlockedDocId === selectedDocIdRef.current) {
|
|
setActiveLock(null);
|
|
}
|
|
} else if (msg.event === 'DOC_BLOCKS_PATCHED') {
|
|
const patch = msg.payload as DocBlocksPatchEvent;
|
|
if (!patch || !patch.docId) return;
|
|
|
|
setDocs((prev) =>
|
|
prev.map((d) => {
|
|
if (d.id !== patch.docId) return d;
|
|
const merged = mergeIncomingBlocks(d.id, d.blocks || [], {
|
|
upserted: patch.upserted,
|
|
deletedIds: patch.deletedIds,
|
|
blockOrder: patch.blockOrder,
|
|
});
|
|
return { ...d, blocks: merged, updatedAt: patch.updatedAt || d.updatedAt };
|
|
})
|
|
);
|
|
|
|
if (selectedDocIdRef.current === patch.docId) {
|
|
setSelectedDoc((prev) => {
|
|
if (!prev || prev.id !== patch.docId) return prev;
|
|
const merged = mergeIncomingBlocks(prev.id, prev.blocks || [], {
|
|
upserted: patch.upserted,
|
|
deletedIds: patch.deletedIds,
|
|
blockOrder: patch.blockOrder,
|
|
});
|
|
const nextDoc = { ...prev, blocks: merged, updatedAt: patch.updatedAt || prev.updatedAt };
|
|
if (pendingSaveDocRef.current && pendingSaveDocRef.current.id === patch.docId) {
|
|
pendingSaveDocRef.current = { ...pendingSaveDocRef.current, blocks: merged };
|
|
}
|
|
return nextDoc;
|
|
});
|
|
}
|
|
} else if (msg.event === 'DOC_CREATED') {
|
|
const created = msg.payload as Document;
|
|
if (created && created.id) {
|
|
recordSyncedDoc(created);
|
|
setDocs((prev) => (prev.some((d) => d.id === created.id) ? prev : [created, ...prev]));
|
|
onStatsChangeRef.current();
|
|
}
|
|
} else if (msg.event === 'DOC_UPDATED') {
|
|
const updated = msg.payload as Document;
|
|
if (updated && updated.id) {
|
|
const incomingOrder = (updated.blocks || []).map((b) => b.id);
|
|
setDocs((prev) =>
|
|
prev.map((d) => {
|
|
if (d.id !== updated.id) return d;
|
|
const mergedBlocks = mergeIncomingBlocks(d.id, d.blocks || [], {
|
|
upserted: updated.blocks || [],
|
|
blockOrder: incomingOrder,
|
|
});
|
|
return {
|
|
...d,
|
|
...updated,
|
|
blocks: mergedBlocks,
|
|
isPinned: d.isPinned,
|
|
};
|
|
})
|
|
);
|
|
if (selectedDocIdRef.current === updated.id) {
|
|
setSelectedDoc((prev) => {
|
|
if (!prev || prev.id !== updated.id) return prev;
|
|
const mergedBlocks = mergeIncomingBlocks(prev.id, prev.blocks || [], {
|
|
upserted: updated.blocks || [],
|
|
blockOrder: incomingOrder,
|
|
});
|
|
const nextDoc = {
|
|
...prev,
|
|
...updated,
|
|
blocks: mergedBlocks,
|
|
isPinned: prev.isPinned,
|
|
};
|
|
if (pendingSaveDocRef.current && pendingSaveDocRef.current.id === updated.id) {
|
|
pendingSaveDocRef.current = {
|
|
...pendingSaveDocRef.current,
|
|
blocks: mergedBlocks,
|
|
};
|
|
}
|
|
return nextDoc;
|
|
});
|
|
}
|
|
onStatsChangeRef.current();
|
|
}
|
|
} else if (msg.event === 'DOC_DELETED') {
|
|
const deletedId = typeof msg.payload === 'string' ? msg.payload : msg.payload?.id;
|
|
if (deletedId) {
|
|
setDocs((prev) => prev.filter((d) => d.id !== deletedId));
|
|
if (selectedDocIdRef.current === deletedId) {
|
|
setSelectedDoc(null);
|
|
}
|
|
onStatsChangeRef.current();
|
|
}
|
|
} else if (msg.event === 'WORKSPACE_FOLDERS_UPDATED') {
|
|
api.getFolders().then(setCustomFolders).catch(() => {});
|
|
api.getSharedFolders().then(setSharedFolders).catch(() => {});
|
|
api.getDocuments().then((freshList) => {
|
|
freshList.forEach(recordSyncedDoc);
|
|
setDocs(freshList);
|
|
if (selectedDocIdRef.current) {
|
|
const found = freshList.find((d) => d.id === selectedDocIdRef.current);
|
|
if (found) {
|
|
setSelectedDoc((prev) =>
|
|
prev && prev.id === found.id
|
|
? {
|
|
...prev,
|
|
folderPath: found.folderPath,
|
|
sharedFolderId: found.sharedFolderId,
|
|
subFolderPath: found.subFolderPath,
|
|
isSharedFolder: found.isSharedFolder,
|
|
permission: found.permission,
|
|
isOwner: found.isOwner,
|
|
}
|
|
: prev
|
|
);
|
|
}
|
|
}
|
|
}).catch(() => {});
|
|
}
|
|
};
|
|
|
|
const conn = createRealtimeConnection({
|
|
docId: selectedDocIdRef.current || undefined,
|
|
onMessage: handleWsMsg,
|
|
onStatusChange: (status) => {
|
|
if (!isMounted) return;
|
|
setWsConnected(status.connected);
|
|
setRealtimeTransport(status.transport);
|
|
if (status.connected) {
|
|
setWsDisconnectReason(null);
|
|
if (selectedDocIdRef.current) {
|
|
conn.send({ event: 'CRDT_JOIN', docId: selectedDocIdRef.current });
|
|
}
|
|
api.getFolders().then(setCustomFolders).catch(() => {});
|
|
api.getSharedFolders().then(setSharedFolders).catch(() => {});
|
|
} else {
|
|
setWsDisconnectReason(status.reason || 'Verbindung getrennt');
|
|
}
|
|
},
|
|
});
|
|
realtimeConnRef.current = conn;
|
|
|
|
// Also synchronize with global WebSocket status
|
|
const unsubGlobalStatus = onWebSocketStatus((connected) => {
|
|
if (!isMounted) return;
|
|
if (connected) {
|
|
setWsConnected(true);
|
|
setWsDisconnectReason(null);
|
|
}
|
|
});
|
|
|
|
// Fallback: also listen for window custom event 'mindsift:ws'
|
|
const handleGlobalCustomEvent = (e: any) => {
|
|
if (e.detail) {
|
|
handleWsMsg(e.detail);
|
|
}
|
|
};
|
|
window.addEventListener('mindsift:ws', handleGlobalCustomEvent);
|
|
|
|
return () => {
|
|
isMounted = false;
|
|
unsubGlobalStatus();
|
|
if (realtimeConnRef.current) {
|
|
try {
|
|
realtimeConnRef.current.close();
|
|
} catch {}
|
|
realtimeConnRef.current = null;
|
|
}
|
|
window.removeEventListener('mindsift:ws', handleGlobalCustomEvent);
|
|
};
|
|
}, []);
|
|
|
|
// Fallback HTTP polling when WebSocket is disconnected (e.g. corporate proxy / firewall blocking WebSockets)
|
|
useEffect(() => {
|
|
if (wsConnected) return;
|
|
|
|
let isMounted = true;
|
|
const pollInterval = setInterval(async () => {
|
|
if (!isMounted || document.visibilityState !== 'visible') return;
|
|
|
|
// 1. Poll lock status of currently selected document
|
|
if (selectedDocIdRef.current) {
|
|
try {
|
|
const res = await api.getDocLock(selectedDocIdRef.current);
|
|
if (res.locked && res.lock && res.lock.userId !== currentUserIdRef.current) {
|
|
setActiveLock(res.lock);
|
|
} else {
|
|
setActiveLock(null);
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
// 2. Poll document content if not actively typing and document has changed on server
|
|
const recentlyEdited = Date.now() - lastLocalEditTimestampRef.current < 4000;
|
|
const hasPendingSave = pendingSaveDocRef.current !== null || isSavingToBackendRef.current;
|
|
const hasDirtyBlocks = (localDirtyBlocksRef.current.get(selectedDocIdRef.current || '')?.size || 0) > 0;
|
|
if (
|
|
selectedDocIdRef.current &&
|
|
!isEditorFocusedRef.current &&
|
|
!hasPendingSave &&
|
|
!hasDirtyBlocks &&
|
|
!recentlyEdited
|
|
) {
|
|
try {
|
|
const fresh = await api.getDocument(selectedDocIdRef.current);
|
|
if (fresh && (!selectedDoc || fresh.updatedAt !== selectedDoc.updatedAt)) {
|
|
setSelectedDoc(fresh);
|
|
setDocs((prev) => prev.map((d) => (d.id === fresh.id ? fresh : d)));
|
|
}
|
|
} catch {}
|
|
}
|
|
}, 4000);
|
|
|
|
return () => {
|
|
isMounted = false;
|
|
clearInterval(pollInterval);
|
|
};
|
|
}, [wsConnected, selectedDoc?.updatedAt]);
|
|
|
|
const openShareModal = async () => {
|
|
if (!selectedDoc) return;
|
|
setShowShareModal(true);
|
|
setShareError(null);
|
|
setSharingLoading(true);
|
|
setSelectedShareUser(null);
|
|
setSelectedShareTeamId('');
|
|
try {
|
|
const [sharesList, usersList, teamsList] = await Promise.all([
|
|
api.getDocShares(selectedDoc.id).catch(() => []),
|
|
api.getUsersDirectory ? api.getUsersDirectory().catch(() => api.getCollaborators().catch(() => [])) : api.getCollaborators().catch(() => []),
|
|
api.getUserTeams().catch(() => []),
|
|
]);
|
|
setDocShares(sharesList);
|
|
setCollaborators(usersList);
|
|
setShareTeams(teamsList);
|
|
} finally {
|
|
setSharingLoading(false);
|
|
}
|
|
};
|
|
|
|
const openFolderShareModal = async (node: FolderTreeItem) => {
|
|
try {
|
|
let sf = node.sharedFolder;
|
|
if (!sf) {
|
|
sf = await api.createSharedFolder({ name: node.name, mountPath: node.path });
|
|
const sfList = await api.getSharedFolders();
|
|
setSharedFolders(sfList);
|
|
}
|
|
const [shares, usersList, teamsList] = await Promise.all([
|
|
api.getFolderShares(sf.id).catch(() => []),
|
|
api.getUsersDirectory ? api.getUsersDirectory().catch(() => api.getCollaborators().catch(() => [])) : api.getCollaborators().catch(() => []),
|
|
api.getUserTeams().catch(() => []),
|
|
]);
|
|
setFolderShares(shares);
|
|
setCollaborators(usersList);
|
|
setShareTeams(teamsList);
|
|
setSharingFolder(sf);
|
|
setShowFolderShareModal(true);
|
|
} catch (err) {
|
|
console.error('Failed to open folder share modal', err);
|
|
alert('Fehler beim Öffnen der Ordnerfreigabe');
|
|
}
|
|
};
|
|
|
|
const handleAddShare = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!selectedDoc) return;
|
|
setSharingLoading(true);
|
|
setShareError(null);
|
|
try {
|
|
if (addShareType === 'team') {
|
|
if (!selectedShareTeamId) {
|
|
setShareError('Bitte ein Team oder eine Gruppe auswählen');
|
|
setSharingLoading(false);
|
|
return;
|
|
}
|
|
await api.createDocShare(selectedDoc.id, {
|
|
teamId: selectedShareTeamId,
|
|
permission: sharePermission,
|
|
});
|
|
setSelectedShareTeamId('');
|
|
} else {
|
|
if (!selectedShareUser) {
|
|
setShareError('Bitte einen bestehenden Benutzer auswählen');
|
|
setSharingLoading(false);
|
|
return;
|
|
}
|
|
await api.createDocShare(selectedDoc.id, {
|
|
userId: selectedShareUser.id,
|
|
permission: sharePermission,
|
|
});
|
|
setSelectedShareUser(null);
|
|
}
|
|
|
|
const sharesList = await api.getDocShares(selectedDoc.id).catch(() => []);
|
|
setDocShares(sharesList);
|
|
setSelectedDoc((prev) =>
|
|
prev
|
|
? {
|
|
...prev,
|
|
sharedWith: sharesList,
|
|
}
|
|
: prev
|
|
);
|
|
} catch (err: any) {
|
|
setShareError(err.message || 'Freigabe konnte nicht erstellt werden');
|
|
} finally {
|
|
setSharingLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleUpdateSharePerm = async (targetUserId: string, newPerm: 'read' | 'write') => {
|
|
if (!selectedDoc) return;
|
|
setSharingLoading(true);
|
|
try {
|
|
const updated = await api.createDocShare(selectedDoc.id, {
|
|
userId: targetUserId,
|
|
permission: newPerm,
|
|
});
|
|
setDocShares((prev) => prev.map((s) => (s.userId === targetUserId ? updated : s)));
|
|
setSelectedDoc((prev) =>
|
|
prev
|
|
? {
|
|
...prev,
|
|
sharedWith: (prev.sharedWith || []).map((s) =>
|
|
s.userId === targetUserId ? updated : s
|
|
),
|
|
}
|
|
: prev
|
|
);
|
|
} catch (err: any) {
|
|
setShareError(err.message || 'Berechtigung konnte nicht geändert werden');
|
|
} finally {
|
|
setSharingLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleRemoveShare = async (targetId: string) => {
|
|
if (!selectedDoc) return;
|
|
if (!window.confirm('Freigabe wirklich aufheben?')) return;
|
|
setSharingLoading(true);
|
|
try {
|
|
await api.deleteDocShare(selectedDoc.id, targetId);
|
|
setDocShares((prev) => prev.filter((s) => (s.targetId || s.userId || s.id) !== targetId && s.userId !== targetId));
|
|
setSelectedDoc((prev) =>
|
|
prev
|
|
? {
|
|
...prev,
|
|
sharedWith: (prev.sharedWith || []).filter((s) => (s.targetId || s.userId || s.id) !== targetId && s.userId !== targetId),
|
|
}
|
|
: prev
|
|
);
|
|
} catch (err: any) {
|
|
setShareError(err.message || 'Freigabe konnte nicht entfernt werden');
|
|
} finally {
|
|
setSharingLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleLeaveShare = async () => {
|
|
if (!selectedDoc || !currentUser) return;
|
|
const totalBlocks = selectedDoc.blocks?.length || 0;
|
|
const subpointsCount = (selectedDoc.blocks || []).filter((b) => (b.level || 0) > 0).length;
|
|
const title = selectedDoc.title || 'Geteilte Notiz';
|
|
|
|
let confirmMsg = 'Möchtest du diese freigegebene Notiz wirklich aus deinem Workspace entfernen?';
|
|
if (totalBlocks > 0 && subpointsCount > 0) {
|
|
confirmMsg = `Möchtest du die geteilte Notiz "${title}" mit allen ${totalBlocks} Punkten (inklusive ${subpointsCount} Unterpunkten) wirklich aus deinem Workspace entfernen?`;
|
|
} else if (totalBlocks > 0) {
|
|
confirmMsg = `Möchtest du die geteilte Notiz "${title}" wirklich aus deinem Workspace entfernen?`;
|
|
}
|
|
|
|
if (!window.confirm(confirmMsg)) return;
|
|
try {
|
|
await api.deleteDocument(selectedDoc.id);
|
|
setShowShareModal(false);
|
|
const remaining = docs.filter((d) => d.id !== selectedDoc.id);
|
|
setDocs(remaining);
|
|
setSelectedDoc(remaining[0] || null);
|
|
onStatsChange();
|
|
} catch (err: any) {
|
|
alert(err.message || 'Konnte Freigabe nicht verlassen');
|
|
}
|
|
};
|
|
|
|
|
|
const getDocFolder = (doc: Document): string => {
|
|
return doc.folderPath || '';
|
|
};
|
|
|
|
// Compute all unique folder paths
|
|
const allFolderPaths = useMemo(() => {
|
|
const set = new Set<string>(customFolders);
|
|
sharedFolders.forEach((sf) => {
|
|
if (sf.mountPath) {
|
|
const parts = sf.mountPath.split('/');
|
|
let cur = '';
|
|
for (const p of parts) {
|
|
cur = cur ? `${cur}/${p}` : p;
|
|
set.add(cur);
|
|
}
|
|
}
|
|
});
|
|
docs.forEach((doc) => {
|
|
const f = getDocFolder(doc);
|
|
if (f) {
|
|
const parts = f.split('/');
|
|
let cur = '';
|
|
for (const p of parts) {
|
|
cur = cur ? `${cur}/${p}` : p;
|
|
set.add(cur);
|
|
}
|
|
}
|
|
});
|
|
return Array.from(set).sort((a, b) => a.localeCompare(b));
|
|
}, [customFolders, sharedFolders, docs]);
|
|
|
|
// Keep folders expanded by default
|
|
useEffect(() => {
|
|
setExpandedFolders((prev) => {
|
|
const next = new Set(prev);
|
|
allFolderPaths.forEach((p) => next.add(p));
|
|
return next;
|
|
});
|
|
}, [allFolderPaths.join(',')]);
|
|
|
|
const handleCreateDoc = async (folderPath?: string | null) => {
|
|
triggerHaptic(15);
|
|
const tags = ['notiz'];
|
|
const path = folderPath || '';
|
|
const created = await api.createDocument({
|
|
title: 'Neue Notiz',
|
|
icon: '📄',
|
|
folderPath: path,
|
|
blocks: [
|
|
{
|
|
id: `blk_${Date.now()}_1`,
|
|
type: 'bullet',
|
|
content: '',
|
|
level: 0,
|
|
},
|
|
],
|
|
tags,
|
|
isPinned: false,
|
|
});
|
|
|
|
await loadDocs(created.id);
|
|
onStatsChange();
|
|
setWorkspaceMode('editor');
|
|
};
|
|
|
|
const handleDeleteDoc = async (id: string) => {
|
|
const docToDelete = docs.find((d) => d.id === id) || (selectedDoc?.id === id ? selectedDoc : null);
|
|
const title = docToDelete?.title || selectedDoc?.title || 'Notiz';
|
|
const totalBlocks = docToDelete?.blocks?.length || 0;
|
|
const subpointsCount = (docToDelete?.blocks || []).filter((b) => (b.level || 0) > 0).length;
|
|
|
|
let confirmMsg = `Möchtest du die Notiz "${title}" wirklich löschen?`;
|
|
if (totalBlocks > 0) {
|
|
if (subpointsCount > 0) {
|
|
confirmMsg = `Möchtest du die Notiz "${title}" mit allen ${totalBlocks} Aufzählungspunkten (inklusive ${subpointsCount} Unterpunkten) wirklich unwiderruflich löschen?`;
|
|
} else {
|
|
confirmMsg = `Möchtest du die Notiz "${title}" mit allen ${totalBlocks} Aufzählungspunkten wirklich löschen?`;
|
|
}
|
|
}
|
|
|
|
if (!window.confirm(confirmMsg)) return;
|
|
triggerHaptic(20);
|
|
await api.deleteDocument(id);
|
|
const remaining = docs.filter((d) => d.id !== id);
|
|
setDocs(remaining);
|
|
setSelectedDoc(remaining[0] || null);
|
|
onStatsChange();
|
|
};
|
|
|
|
const handleChangeDocFolder = async (folderPath: string) => {
|
|
if (!selectedDoc) return;
|
|
const cleanPath = folderPath.trim();
|
|
try {
|
|
const updatedDoc = await api.moveDocument(selectedDoc.id, cleanPath);
|
|
setDocs((prev) =>
|
|
prev.map((d) =>
|
|
d.id === selectedDoc.id
|
|
? {
|
|
...d,
|
|
folderPath: updatedDoc.folderPath,
|
|
sharedFolderId: updatedDoc.sharedFolderId,
|
|
subFolderPath: updatedDoc.subFolderPath,
|
|
isSharedFolder: updatedDoc.isSharedFolder,
|
|
}
|
|
: d
|
|
)
|
|
);
|
|
setSelectedDoc((prev) =>
|
|
prev && prev.id === selectedDoc.id
|
|
? {
|
|
...prev,
|
|
folderPath: updatedDoc.folderPath,
|
|
sharedFolderId: updatedDoc.sharedFolderId,
|
|
subFolderPath: updatedDoc.subFolderPath,
|
|
isSharedFolder: updatedDoc.isSharedFolder,
|
|
}
|
|
: prev
|
|
);
|
|
api.getFolders().then(setCustomFolders).catch(() => {});
|
|
} catch (err: any) {
|
|
console.error('Failed to change document folder', err);
|
|
alert(err.message || 'Fehler beim Verschieben der Notiz');
|
|
}
|
|
};
|
|
|
|
// Global drag cleanup so drag states never get stuck if dragend is lost during re-render
|
|
useEffect(() => {
|
|
const handleGlobalDragEnd = () => {
|
|
setDraggingDocId(null);
|
|
setDraggingFolderPath(null);
|
|
setDragOverFolder(null);
|
|
setDraggingBlockId(null);
|
|
};
|
|
window.addEventListener('dragend', handleGlobalDragEnd);
|
|
window.addEventListener('drop', handleGlobalDragEnd);
|
|
return () => {
|
|
window.removeEventListener('dragend', handleGlobalDragEnd);
|
|
window.removeEventListener('drop', handleGlobalDragEnd);
|
|
};
|
|
}, []);
|
|
|
|
const handleMoveFolder = async (sourcePath: string, targetParentPath: string) => {
|
|
const cleanSource = sourcePath.trim();
|
|
const cleanTarget = targetParentPath.trim();
|
|
|
|
// Cannot move to self or into own subfolder
|
|
if (!cleanSource || cleanSource === cleanTarget || cleanTarget.startsWith(cleanSource + '/')) {
|
|
return;
|
|
}
|
|
|
|
triggerHaptic(15);
|
|
try {
|
|
const res = await api.moveFolder(cleanSource, cleanTarget);
|
|
await Promise.all([
|
|
api.getFolders().then(setCustomFolders),
|
|
api.getSharedFolders().then(setSharedFolders),
|
|
loadDocs(selectedDoc?.id),
|
|
]);
|
|
setExpandedFolders((prev) => {
|
|
const next = new Set(prev);
|
|
if (res.newPath) next.add(res.newPath);
|
|
if (cleanTarget) next.add(cleanTarget);
|
|
return next;
|
|
});
|
|
} catch (err: any) {
|
|
console.error('Failed to move folder', err);
|
|
alert(err.message || 'Fehler beim Verschieben des Ordners');
|
|
}
|
|
};
|
|
|
|
const handleDropDocToFolder = async (folderPath: string, e: React.DragEvent) => {
|
|
const docId = e.dataTransfer.getData('application/mindsift-doc-id') || draggingDocId;
|
|
setDraggingDocId(null);
|
|
setDragOverFolder(null);
|
|
if (!docId) return;
|
|
const doc = docs.find((d) => d.id === docId);
|
|
if (!doc) return;
|
|
const cleanPath = folderPath.trim();
|
|
if (getDocFolder(doc) === cleanPath) return;
|
|
|
|
triggerHaptic(15);
|
|
try {
|
|
const updatedDoc = await api.moveDocument(doc.id, cleanPath);
|
|
setDocs((prev) =>
|
|
prev.map((d) =>
|
|
d.id === doc.id
|
|
? {
|
|
...d,
|
|
folderPath: updatedDoc.folderPath,
|
|
sharedFolderId: updatedDoc.sharedFolderId,
|
|
subFolderPath: updatedDoc.subFolderPath,
|
|
isSharedFolder: updatedDoc.isSharedFolder,
|
|
}
|
|
: d
|
|
)
|
|
);
|
|
if (selectedDoc?.id === doc.id) {
|
|
setSelectedDoc((prev) =>
|
|
prev
|
|
? {
|
|
...prev,
|
|
folderPath: updatedDoc.folderPath,
|
|
sharedFolderId: updatedDoc.sharedFolderId,
|
|
subFolderPath: updatedDoc.subFolderPath,
|
|
isSharedFolder: updatedDoc.isSharedFolder,
|
|
}
|
|
: null
|
|
);
|
|
}
|
|
if (cleanPath) {
|
|
setExpandedFolders((prev) => new Set([...prev, cleanPath]));
|
|
}
|
|
api.getFolders().then(setCustomFolders).catch(() => {});
|
|
} catch (err: any) {
|
|
console.error('Failed to move doc', err);
|
|
alert(err.message || 'Fehler beim Verschieben der Notiz');
|
|
}
|
|
};
|
|
|
|
const handleStartCreateFolder = (parentPath?: string | null) => {
|
|
setNewFolderParent(parentPath || null);
|
|
setNewFolderName('');
|
|
setIsCreatingFolder(true);
|
|
if (parentPath) {
|
|
setExpandedFolders((prev) => new Set([...prev, parentPath]));
|
|
}
|
|
};
|
|
|
|
const handleConfirmCreateFolder = async () => {
|
|
const trimmed = newFolderName.trim();
|
|
if (!trimmed) {
|
|
setIsCreatingFolder(false);
|
|
return;
|
|
}
|
|
const fullPath = newFolderParent ? `${newFolderParent}/${trimmed}` : trimmed;
|
|
try {
|
|
await api.createFolder(fullPath);
|
|
const serverFolders = await api.getFolders();
|
|
setCustomFolders(serverFolders);
|
|
} catch (err) {
|
|
console.error('Failed to create folder on server', err);
|
|
if (!customFolders.includes(fullPath)) {
|
|
setCustomFolders((prev) => [...prev, fullPath].sort((a, b) => a.localeCompare(b)));
|
|
}
|
|
}
|
|
setExpandedFolders((prev) => new Set([...prev, fullPath]));
|
|
setIsCreatingFolder(false);
|
|
setNewFolderName('');
|
|
setNewFolderParent(null);
|
|
};
|
|
|
|
const handleDeleteFolder = async (folderPath: string, folderName: string) => {
|
|
const matchingSF = sharedFolders.find((sf) => sf.mountPath === folderPath);
|
|
if (matchingSF) {
|
|
if (matchingSF.isOwner) {
|
|
if (!window.confirm(`Möchtest du den geteilten Ordner "${folderName}" wirklich löschen? Die Freigabe wird für alle Teilnehmer aufgehoben.`)) {
|
|
return;
|
|
}
|
|
} else {
|
|
if (!window.confirm(`Möchtest du den geteilten Ordner "${folderName}" aus deinem Workspace entfernen? (Die Quell-Dateien des Eigentümers bleiben erhalten).`)) {
|
|
return;
|
|
}
|
|
}
|
|
try {
|
|
await api.deleteSharedFolder(matchingSF.id);
|
|
const sfList = await api.getSharedFolders();
|
|
setSharedFolders(sfList);
|
|
await loadDocs(selectedDoc?.id);
|
|
return;
|
|
} catch (err: any) {
|
|
console.error('Failed to delete shared folder', err);
|
|
alert(err.message || 'Fehler beim Löschen des geteilten Ordners');
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Find all documents in this folder and subfolders
|
|
const docsInFolder = docs.filter((d) => {
|
|
const f = getDocFolder(d);
|
|
return f === folderPath || f.startsWith(`${folderPath}/`);
|
|
});
|
|
|
|
const confirmMsg =
|
|
docsInFolder.length > 0
|
|
? `Möchtest du den Ordner "${folderName}" wirklich löschen? Alle ${docsInFolder.length} darin enthaltenen Notizen werden unwiderruflich gelöscht!`
|
|
: `Möchtest du den leeren Ordner "${folderName}" wirklich löschen?`;
|
|
|
|
if (!window.confirm(confirmMsg)) return;
|
|
|
|
try {
|
|
const res = await api.deleteFolder(folderPath);
|
|
const deletedIds = new Set(res.deletedDocIds || docsInFolder.map((d) => d.id));
|
|
|
|
const nextDocs = docs.filter((d) => !deletedIds.has(d.id));
|
|
setDocs(nextDocs);
|
|
|
|
if (selectedDoc && deletedIds.has(selectedDoc.id)) {
|
|
setSelectedDoc(nextDocs.length > 0 ? nextDocs[0] : null);
|
|
}
|
|
|
|
const serverFolders = await api.getFolders();
|
|
setCustomFolders(serverFolders);
|
|
onStatsChange();
|
|
} catch (err) {
|
|
console.error('Failed to delete folder', err);
|
|
alert('Fehler beim Löschen des Ordners');
|
|
}
|
|
};
|
|
|
|
const toggleFolder = (folderPath: string) => {
|
|
setExpandedFolders((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(folderPath)) {
|
|
next.delete(folderPath);
|
|
} else {
|
|
next.add(folderPath);
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
// ============================================================================
|
|
// LOGSEQ OUTLINER CORE LOGIC
|
|
// ============================================================================
|
|
|
|
// Zoom-in / Hoisting Calculation
|
|
const { visibleBlocks, breadcrumbBlocks, zoomedBlock } = useMemo(() => {
|
|
if (!selectedDoc) return { visibleBlocks: [], breadcrumbBlocks: [], zoomedBlock: null };
|
|
const all = selectedDoc.blocks;
|
|
|
|
if (!zoomedBlockId) {
|
|
// Normal view: filter out children of collapsed blocks
|
|
const list: { block: DocumentBlock; originalIndex: number }[] = [];
|
|
let hideUntilLevel = -1;
|
|
|
|
for (let i = 0; i < all.length; i++) {
|
|
const b = all[i];
|
|
const lvl = b.level || 0;
|
|
|
|
if (hideUntilLevel !== -1) {
|
|
if (lvl > hideUntilLevel) {
|
|
continue; // Skip collapsed child
|
|
} else {
|
|
hideUntilLevel = -1; // End of collapsed subtree
|
|
}
|
|
}
|
|
|
|
list.push({ block: b, originalIndex: i });
|
|
|
|
if (b.collapsed) {
|
|
hideUntilLevel = lvl;
|
|
}
|
|
}
|
|
|
|
return { visibleBlocks: list, breadcrumbBlocks: [], zoomedBlock: null };
|
|
}
|
|
|
|
// Zoomed view: find zoomed block and its descendants
|
|
const zoomIdx = all.findIndex((b) => b.id === zoomedBlockId);
|
|
if (zoomIdx === -1) {
|
|
return { visibleBlocks: [], breadcrumbBlocks: [], zoomedBlock: null };
|
|
}
|
|
|
|
const target = all[zoomIdx];
|
|
const targetLevel = target.level || 0;
|
|
|
|
// Build breadcrumbs: find parents with strictly decreasing levels
|
|
const crumbs: DocumentBlock[] = [];
|
|
let curLevel = targetLevel;
|
|
for (let i = zoomIdx - 1; i >= 0; i--) {
|
|
const b = all[i];
|
|
const lvl = b.level || 0;
|
|
if (lvl < curLevel) {
|
|
crumbs.unshift(b);
|
|
curLevel = lvl;
|
|
}
|
|
}
|
|
|
|
// Descendants of zoomed block (respecting collapsed sub-branches inside the zoomed view)
|
|
const descendants: { block: DocumentBlock; originalIndex: number }[] = [];
|
|
let hideUntilLevel = -1;
|
|
for (let i = zoomIdx + 1; i < all.length; i++) {
|
|
const b = all[i];
|
|
const lvl = b.level || 0;
|
|
if (lvl <= targetLevel) {
|
|
break;
|
|
}
|
|
if (hideUntilLevel !== -1) {
|
|
if (lvl > hideUntilLevel) {
|
|
continue;
|
|
} else {
|
|
hideUntilLevel = -1;
|
|
}
|
|
}
|
|
descendants.push({ block: b, originalIndex: i });
|
|
if (b.collapsed) {
|
|
hideUntilLevel = lvl;
|
|
}
|
|
}
|
|
|
|
return { visibleBlocks: descendants, breadcrumbBlocks: crumbs, zoomedBlock: target };
|
|
}, [selectedDoc, zoomedBlockId]);
|
|
|
|
// Helper: check if a block has children (i.e. next block has greater level)
|
|
const hasChildren = (blocks: DocumentBlock[], index: number): boolean => {
|
|
if (index + 1 >= blocks.length) return false;
|
|
const currentLevel = blocks[index].level || 0;
|
|
return (blocks[index + 1].level || 0) > currentLevel;
|
|
};
|
|
|
|
// Zoom into a bullet (or back out to parent/root) and ensure childless bullets get an editable sub-bullet
|
|
const handleZoomTo = (targetBlockId: string | null) => {
|
|
if (!selectedDoc) {
|
|
setZoomedBlockId(targetBlockId);
|
|
return;
|
|
}
|
|
|
|
let workingBlocks = [...selectedDoc.blocks];
|
|
let blocksChanged = false;
|
|
|
|
// If zooming out/away from a zoomed block whose only descendant is a completely empty bullet, clean it up
|
|
if (!isReadOnly && zoomedBlock && targetBlockId !== zoomedBlock.id) {
|
|
const curZoomIdx = workingBlocks.findIndex((b) => b.id === zoomedBlock.id);
|
|
if (curZoomIdx !== -1) {
|
|
const curZoomLevel = workingBlocks[curZoomIdx].level || 0;
|
|
const descIndices: number[] = [];
|
|
for (let i = curZoomIdx + 1; i < workingBlocks.length; i++) {
|
|
if ((workingBlocks[i].level || 0) > curZoomLevel) {
|
|
descIndices.push(i);
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
if (descIndices.length === 1) {
|
|
const onlyChild = workingBlocks[descIndices[0]];
|
|
if (
|
|
onlyChild.id !== targetBlockId &&
|
|
onlyChild.type === 'bullet' &&
|
|
(!onlyChild.content || onlyChild.content.trim() === '')
|
|
) {
|
|
workingBlocks.splice(descIndices[0], 1);
|
|
blocksChanged = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (targetBlockId && !isReadOnly) {
|
|
const targetIdx = workingBlocks.findIndex((b) => b.id === targetBlockId);
|
|
if (targetIdx !== -1 && !hasChildren(workingBlocks, targetIdx)) {
|
|
const childLevel = (workingBlocks[targetIdx].level || 0) + 1;
|
|
const newChild: DocumentBlock = {
|
|
id: `blk_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
|
|
type: 'bullet',
|
|
content: '',
|
|
level: childLevel,
|
|
checked: false,
|
|
};
|
|
workingBlocks.splice(targetIdx + 1, 0, newChild);
|
|
persistDocument({ ...selectedDoc, blocks: workingBlocks });
|
|
setZoomedBlockId(targetBlockId);
|
|
setLastFocusedBlockId(newChild.id);
|
|
setBlockCursor(newChild.id, 0);
|
|
setTimeout(() => {
|
|
const el = blockInputRefs.current[newChild.id];
|
|
if (el) {
|
|
el.focus();
|
|
if ('setSelectionRange' in el && typeof (el as any).value === 'string') {
|
|
(el as any).setSelectionRange(0, 0);
|
|
}
|
|
}
|
|
}, 30);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (blocksChanged) {
|
|
persistDocument({ ...selectedDoc, blocks: workingBlocks });
|
|
}
|
|
setZoomedBlockId(targetBlockId);
|
|
};
|
|
|
|
// Insert a new bullet immediately after index, inheriting level
|
|
const insertNewBulletAfter = (
|
|
afterIndex: number,
|
|
level: number,
|
|
type: BlockType = 'bullet',
|
|
initialContent: string = '',
|
|
updatedCurrentBlock?: { id: string; content: string }
|
|
) => {
|
|
if (isReadOnly || !selectedDoc) return;
|
|
const newBlock: DocumentBlock = {
|
|
id: `blk_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
|
|
type,
|
|
content: initialContent,
|
|
level,
|
|
checked: false,
|
|
};
|
|
|
|
const nextBlocks = selectedDoc.blocks.map((b) =>
|
|
updatedCurrentBlock && b.id === updatedCurrentBlock.id
|
|
? { ...b, content: updatedCurrentBlock.content }
|
|
: b
|
|
);
|
|
nextBlocks.splice(afterIndex + 1, 0, newBlock);
|
|
lastLocalEditTimestampRef.current = Date.now();
|
|
persistDocument({ ...selectedDoc, blocks: nextBlocks });
|
|
focusBlock(newBlock.id, 0);
|
|
};
|
|
|
|
// Indent block (Tab): increase level by 1 and move all descendant sub-bullets along
|
|
const indentBlock = (index: number) => {
|
|
if (isReadOnly || !selectedDoc || index === 0) return;
|
|
const blocks = selectedDoc.blocks;
|
|
const block = blocks[index];
|
|
const prevBlock = blocks[index - 1];
|
|
if (!block || !prevBlock) return;
|
|
const currentLevel = block.level || 0;
|
|
const maxLevel = (prevBlock.level || 0) + 1;
|
|
if (currentLevel >= maxLevel) return;
|
|
|
|
// Find all descendant sub-bullets directly or indirectly nested under this block
|
|
const descendantIndices: number[] = [];
|
|
for (let i = index + 1; i < blocks.length; i++) {
|
|
if ((blocks[i].level || 0) <= currentLevel) break;
|
|
descendantIndices.push(i);
|
|
}
|
|
|
|
const nextBlocks = blocks.map((b, idx) => {
|
|
if (idx === index) {
|
|
return { ...b, level: currentLevel + 1 };
|
|
}
|
|
if (descendantIndices.includes(idx)) {
|
|
return { ...b, level: (b.level || 0) + 1 };
|
|
}
|
|
return b;
|
|
});
|
|
|
|
persistDocument({ ...selectedDoc, blocks: nextBlocks });
|
|
};
|
|
|
|
// Outdent block (Shift+Tab): decrease level by 1 and move all descendant sub-bullets along
|
|
const outdentBlock = (blockId: string) => {
|
|
if (isReadOnly || !selectedDoc) return;
|
|
const blocks = selectedDoc.blocks;
|
|
const index = blocks.findIndex((b) => b.id === blockId);
|
|
if (index === -1) return;
|
|
const block = blocks[index];
|
|
const currentLevel = block.level || 0;
|
|
const minLevel = zoomedBlock ? (zoomedBlock.level || 0) + 1 : 0;
|
|
if (currentLevel <= minLevel) return;
|
|
|
|
// Find all descendant sub-bullets directly or indirectly nested under this block
|
|
const descendantIndices: number[] = [];
|
|
for (let i = index + 1; i < blocks.length; i++) {
|
|
if ((blocks[i].level || 0) <= currentLevel) break;
|
|
descendantIndices.push(i);
|
|
}
|
|
|
|
const nextBlocks = blocks.map((b, idx) => {
|
|
if (idx === index) {
|
|
return { ...b, level: currentLevel - 1 };
|
|
}
|
|
if (descendantIndices.includes(idx)) {
|
|
return { ...b, level: Math.max(minLevel, (b.level || 0) - 1) };
|
|
}
|
|
return b;
|
|
});
|
|
|
|
persistDocument({ ...selectedDoc, blocks: nextBlocks });
|
|
};
|
|
|
|
// Delete a single block without deleting its sub-bullets (sub-bullets are promoted 1 level up)
|
|
const deleteSingleBlock = (blockId: string, focusPrev = true) => {
|
|
if (isReadOnly || !selectedDoc) return;
|
|
const blocks = selectedDoc.blocks;
|
|
const index = blocks.findIndex((b) => b.id === blockId);
|
|
if (index === -1) return;
|
|
const targetBlock = blocks[index];
|
|
const targetLevel = targetBlock.level || 0;
|
|
const minLevel = zoomedBlock ? (zoomedBlock.level || 0) + 1 : 0;
|
|
|
|
// Find all descendant sub-bullets directly or indirectly nested under this block
|
|
const descendantIndices: number[] = [];
|
|
for (let i = index + 1; i < blocks.length; i++) {
|
|
if ((blocks[i].level || 0) <= targetLevel) break;
|
|
descendantIndices.push(i);
|
|
}
|
|
|
|
triggerHaptic(10);
|
|
|
|
// Target block is removed, but descendants are PRESERVED!
|
|
// Descendants are shifted up by 1 level to adopt the target block's level
|
|
let nextBlocks: DocumentBlock[] = [];
|
|
for (let i = 0; i < blocks.length; i++) {
|
|
if (i === index) continue;
|
|
if (descendantIndices.includes(i)) {
|
|
const curLevel = blocks[i].level || 0;
|
|
nextBlocks.push({
|
|
...blocks[i],
|
|
level: Math.max(minLevel, curLevel - 1),
|
|
});
|
|
} else {
|
|
nextBlocks.push(blocks[i]);
|
|
}
|
|
}
|
|
|
|
if (nextBlocks.length === 0) {
|
|
nextBlocks = [
|
|
{
|
|
id: `blk_${Date.now()}_1`,
|
|
type: 'bullet',
|
|
content: '',
|
|
level: minLevel,
|
|
},
|
|
];
|
|
}
|
|
|
|
let prevBlock: DocumentBlock | undefined;
|
|
if (focusPrev) {
|
|
if (zoomedBlock) {
|
|
const zoomIdx = blocks.findIndex((b) => b.id === zoomedBlock.id);
|
|
if (index > zoomIdx + 1) {
|
|
prevBlock = blocks[index - 1];
|
|
} else {
|
|
const nextFirstChild = nextBlocks[zoomIdx + 1];
|
|
if (nextFirstChild && (nextFirstChild.level || 0) > (zoomedBlock.level || 0)) {
|
|
prevBlock = nextFirstChild;
|
|
}
|
|
}
|
|
} else {
|
|
prevBlock = index > 0 ? blocks[index - 1] : nextBlocks[0];
|
|
}
|
|
}
|
|
|
|
persistDocument({ ...selectedDoc, blocks: nextBlocks });
|
|
|
|
if (prevBlock) {
|
|
setLastFocusedBlockId(prevBlock.id);
|
|
setBlockCursor(prevBlock.id, (prevBlock.content || '').length);
|
|
setTimeout(() => {
|
|
const el = blockInputRefs.current[prevBlock.id];
|
|
if (el) {
|
|
el.focus();
|
|
if ('setSelectionRange' in el && typeof el.value === 'string') {
|
|
el.setSelectionRange(el.value.length, el.value.length);
|
|
}
|
|
}
|
|
}, 30);
|
|
}
|
|
};
|
|
|
|
// Delete a block AND all its descendant sub-bullets
|
|
const deleteBlockWithDescendants = (blockId: string) => {
|
|
if (isReadOnly || !selectedDoc) return;
|
|
const blocks = selectedDoc.blocks;
|
|
const index = blocks.findIndex((b) => b.id === blockId);
|
|
if (index === -1) return;
|
|
const targetBlock = blocks[index];
|
|
const targetLevel = targetBlock.level || 0;
|
|
const minLevel = zoomedBlock ? (zoomedBlock.level || 0) + 1 : 0;
|
|
|
|
const descendantIds: string[] = [];
|
|
for (let i = index + 1; i < blocks.length; i++) {
|
|
if ((blocks[i].level || 0) <= targetLevel) break;
|
|
descendantIds.push(blocks[i].id);
|
|
}
|
|
|
|
triggerHaptic(15);
|
|
const idsToDelete = new Set([blockId, ...descendantIds]);
|
|
let nextBlocks = blocks.filter((b) => !idsToDelete.has(b.id));
|
|
|
|
if (nextBlocks.length === 0) {
|
|
nextBlocks = [
|
|
{
|
|
id: `blk_${Date.now()}_1`,
|
|
type: 'bullet',
|
|
content: '',
|
|
level: minLevel,
|
|
},
|
|
];
|
|
}
|
|
|
|
let prevBlock: DocumentBlock | undefined;
|
|
if (zoomedBlock) {
|
|
const zoomIdx = blocks.findIndex((b) => b.id === zoomedBlock.id);
|
|
if (index > zoomIdx + 1) {
|
|
prevBlock = blocks[index - 1];
|
|
} else {
|
|
const nextFirstChild = nextBlocks[zoomIdx + 1];
|
|
if (nextFirstChild && (nextFirstChild.level || 0) > (zoomedBlock.level || 0)) {
|
|
prevBlock = nextFirstChild;
|
|
}
|
|
}
|
|
} else {
|
|
prevBlock = index > 0 ? blocks[index - 1] : nextBlocks[0];
|
|
}
|
|
|
|
persistDocument({ ...selectedDoc, blocks: nextBlocks });
|
|
|
|
if (prevBlock) {
|
|
setLastFocusedBlockId(prevBlock.id);
|
|
setBlockCursor(prevBlock.id, (prevBlock.content || '').length);
|
|
setTimeout(() => {
|
|
const el = blockInputRefs.current[prevBlock.id];
|
|
if (el) {
|
|
el.focus();
|
|
if ('setSelectionRange' in el && typeof el.value === 'string') {
|
|
el.setSelectionRange(el.value.length, el.value.length);
|
|
}
|
|
}
|
|
}, 30);
|
|
}
|
|
};
|
|
|
|
// Handler for delete click on the X icon: prompts if sub-bullets exist
|
|
const handleDeleteButtonClick = (blockId: string) => {
|
|
if (isReadOnly || !selectedDoc) return;
|
|
const blocks = selectedDoc.blocks;
|
|
const index = blocks.findIndex((b) => b.id === blockId);
|
|
if (index === -1) return;
|
|
const targetBlock = blocks[index];
|
|
const targetLevel = targetBlock.level || 0;
|
|
|
|
let count = 0;
|
|
for (let i = index + 1; i < blocks.length; i++) {
|
|
if ((blocks[i].level || 0) <= targetLevel) break;
|
|
count++;
|
|
}
|
|
|
|
if (count > 0) {
|
|
setDeleteSubpointsModal({
|
|
blockId,
|
|
count,
|
|
snippet: targetBlock.content?.trim() ? `"${targetBlock.content.trim().slice(0, 35)}..."` : '',
|
|
});
|
|
} else {
|
|
deleteSingleBlock(blockId, false);
|
|
}
|
|
};
|
|
|
|
const updateBlock = (blockId: string, patch: Partial<DocumentBlock>) => {
|
|
if (isReadOnly || !selectedDoc) return;
|
|
const nextBlocks = selectedDoc.blocks.map((b) =>
|
|
b.id === blockId ? { ...b, ...patch } : b
|
|
);
|
|
persistDocument({ ...selectedDoc, blocks: nextBlocks });
|
|
};
|
|
|
|
const toggleCollapse = (blockId: string) => {
|
|
if (!selectedDoc) return;
|
|
const block = selectedDoc.blocks.find((b) => b.id === blockId);
|
|
if (block) {
|
|
updateBlock(blockId, { collapsed: !block.collapsed });
|
|
}
|
|
};
|
|
|
|
useLayoutEffect(() => {
|
|
if (!activeEditingBlockId) return;
|
|
const el = blockInputRefs.current[activeEditingBlockId];
|
|
if (!el) return;
|
|
if (el instanceof HTMLTextAreaElement) {
|
|
autoResizeTextarea(el);
|
|
}
|
|
// Only programmatically adjust selection if the element is NOT already the actively focused element!
|
|
if (document.activeElement !== el && 'setSelectionRange' in el && typeof el.value === 'string') {
|
|
const segStartAttr = el.getAttribute('data-seg-start');
|
|
const segStart = segStartAttr !== null ? parseInt(segStartAttr, 10) || 0 : 0;
|
|
const desiredOffset = activeCursorOffsetRef.current;
|
|
const localPos = Math.max(0, Math.min(el.value.length, desiredOffset - segStart));
|
|
el.focus();
|
|
try {
|
|
el.setSelectionRange(localPos, localPos);
|
|
} catch {
|
|
// Ignore selection range errors on unsupported inputs
|
|
}
|
|
}
|
|
}, [activeEditingBlockId]);
|
|
|
|
// Keyboard Navigation: Enter, Shift+Enter, Tab, Shift+Tab, Backspace, / Slash Commands
|
|
const handleKeyDown = (
|
|
e: React.KeyboardEvent,
|
|
block: DocumentBlock,
|
|
index: number,
|
|
segStart = 0
|
|
) => {
|
|
if (isReadOnly) return;
|
|
ensureLock();
|
|
|
|
if (slashBlockId === block.id) {
|
|
if (filteredSlashCommands.length === 0) {
|
|
if (e.key === 'Escape') {
|
|
setSlashBlockId(null);
|
|
}
|
|
return;
|
|
}
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
setSlashSelectedIndex((prev) => (prev + 1) % filteredSlashCommands.length);
|
|
return;
|
|
}
|
|
if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
setSlashSelectedIndex((prev) =>
|
|
prev === 0 ? filteredSlashCommands.length - 1 : prev - 1
|
|
);
|
|
return;
|
|
}
|
|
if (e.key === 'Enter' || e.key === 'Tab') {
|
|
e.preventDefault();
|
|
const selected = filteredSlashCommands[slashSelectedIndex];
|
|
if (selected) {
|
|
applySlashCommand(block.id, selected);
|
|
}
|
|
return;
|
|
}
|
|
if (e.key === 'Escape') {
|
|
setSlashBlockId(null);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (e.key === 'Enter' && e.shiftKey) {
|
|
// Shift+Enter: insert a newline within the current bullet point
|
|
if (block.type === 'bullet' || block.type === 'paragraph' || block.type === 'todo') {
|
|
e.preventDefault();
|
|
setSlashBlockId(null);
|
|
const target = e.target as HTMLTextAreaElement | HTMLInputElement;
|
|
const localPos =
|
|
target && typeof target.selectionStart === 'number'
|
|
? target.selectionStart
|
|
: (target?.value || '').length;
|
|
const cursor = segStart + localPos;
|
|
const fullContent = block.content || '';
|
|
const nextContent = fullContent.slice(0, cursor) + '\n' + fullContent.slice(cursor);
|
|
const nextCursor = cursor + 1;
|
|
setBlockCursor(block.id, nextCursor);
|
|
updateBlock(block.id, { content: nextContent });
|
|
if (target instanceof HTMLTextAreaElement) {
|
|
setTimeout(() => autoResizeTextarea(target), 0);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
setSlashBlockId(null);
|
|
const target = e.target as HTMLTextAreaElement | HTMLInputElement;
|
|
const fullContent = block.content || '';
|
|
let afterContent = '';
|
|
let updatedCurrent: { id: string; content: string } | undefined = undefined;
|
|
|
|
if (target && typeof target.selectionStart === 'number') {
|
|
const cursor = segStart + target.selectionStart;
|
|
const before = fullContent.slice(0, cursor);
|
|
const after = fullContent.slice(cursor);
|
|
if (after.length > 0) {
|
|
updatedCurrent = { id: block.id, content: before };
|
|
afterContent = after;
|
|
}
|
|
} else if (fullContent.endsWith('\n')) {
|
|
const trimmedTrailing = fullContent.replace(/\n+$/, '');
|
|
if (trimmedTrailing !== fullContent) {
|
|
updatedCurrent = { id: block.id, content: trimmedTrailing };
|
|
}
|
|
}
|
|
|
|
insertNewBulletAfter(
|
|
index,
|
|
block.level || 0,
|
|
block.type === 'todo' ? 'todo' : 'bullet',
|
|
afterContent,
|
|
updatedCurrent
|
|
);
|
|
return;
|
|
} else if (e.key === 'Tab') {
|
|
e.preventDefault();
|
|
if (e.shiftKey) {
|
|
outdentBlock(block.id);
|
|
} else {
|
|
indentBlock(index);
|
|
}
|
|
} else if (e.key === 'Backspace') {
|
|
const target = e.target as HTMLTextAreaElement | HTMLInputElement;
|
|
const fullContent = block.content || '';
|
|
if (!fullContent || fullContent === '') {
|
|
e.preventDefault();
|
|
setSlashBlockId(null);
|
|
deleteSingleBlock(block.id, true);
|
|
return;
|
|
} else if (
|
|
target &&
|
|
target.selectionStart === 0 &&
|
|
target.selectionEnd === 0
|
|
) {
|
|
e.preventDefault();
|
|
if ((block.level || 0) > 0) {
|
|
outdentBlock(block.id);
|
|
return;
|
|
}
|
|
if (selectedDoc && index > 0) {
|
|
const prevBlock = selectedDoc.blocks[index - 1];
|
|
if (prevBlock && prevBlock.type !== 'divider') {
|
|
const prevLen = (prevBlock.content || '').length;
|
|
const mergedContent = (prevBlock.content || '') + fullContent;
|
|
const nextBlocks = selectedDoc.blocks
|
|
.map((b) => (b.id === prevBlock.id ? { ...b, content: mergedContent } : b))
|
|
.filter((b) => b.id !== block.id);
|
|
persistDocument({ ...selectedDoc, blocks: nextBlocks }, true);
|
|
focusBlock(prevBlock.id, prevLen);
|
|
}
|
|
}
|
|
}
|
|
} else if (!e.shiftKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
|
|
const target = e.target as HTMLTextAreaElement | HTMLInputElement;
|
|
const fullContent = block.content || '';
|
|
if (target && typeof target.selectionStart === 'number') {
|
|
const cursor = target.selectionStart;
|
|
if (e.key === 'ArrowUp') {
|
|
const firstNewline = fullContent.indexOf('\n');
|
|
if (firstNewline === -1 || cursor <= firstNewline) {
|
|
if (index > 0 && selectedDoc) {
|
|
const prev = selectedDoc.blocks[index - 1];
|
|
if (prev) {
|
|
e.preventDefault();
|
|
focusBlock(prev.id);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
} else if (e.key === 'ArrowDown') {
|
|
const lastNewline = fullContent.lastIndexOf('\n');
|
|
if (lastNewline === -1 || cursor > lastNewline) {
|
|
if (selectedDoc && index < selectedDoc.blocks.length - 1) {
|
|
const next = selectedDoc.blocks[index + 1];
|
|
if (next) {
|
|
e.preventDefault();
|
|
focusBlock(next.id, 0);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else if (e.key === 'Escape') {
|
|
setSlashBlockId(null);
|
|
setActiveEditingBlockId(null);
|
|
(e.target as HTMLElement)?.blur();
|
|
}
|
|
};
|
|
|
|
const handleContentChange = (block: DocumentBlock, newContent: string) => {
|
|
if (isReadOnly) return;
|
|
ensureLock();
|
|
|
|
// Markdown shortcut: typing '---' creates a divider and inserts a new bullet below
|
|
if (newContent.trim() === '---') {
|
|
const blkIdx = selectedDoc ? selectedDoc.blocks.findIndex((b) => b.id === block.id) : -1;
|
|
if (selectedDoc && blkIdx !== -1) {
|
|
const curLevel = block.level || 0;
|
|
const newBlockId = `blk_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
|
const newBullet: DocumentBlock = {
|
|
id: newBlockId,
|
|
type: 'bullet',
|
|
content: '',
|
|
level: curLevel,
|
|
checked: false,
|
|
};
|
|
const nextBlocks = [...selectedDoc.blocks];
|
|
nextBlocks[blkIdx] = {
|
|
...block,
|
|
type: 'divider',
|
|
content: '',
|
|
};
|
|
nextBlocks.splice(blkIdx + 1, 0, newBullet);
|
|
persistDocument({ ...selectedDoc, blocks: nextBlocks });
|
|
setSlashBlockId(null);
|
|
focusBlock(newBlockId);
|
|
return;
|
|
}
|
|
}
|
|
|
|
updateBlock(block.id, { content: newContent });
|
|
|
|
// Check if user just typed '/'
|
|
if (newContent.endsWith('/') || newContent.includes('/')) {
|
|
const match = newContent.match(/\/([a-z0-9]*)$/i);
|
|
if (match) {
|
|
setSlashBlockId(block.id);
|
|
setSlashSearch(match[1]);
|
|
setSlashSelectedIndex(0);
|
|
return;
|
|
}
|
|
}
|
|
if (slashBlockId === block.id) {
|
|
setSlashBlockId(null);
|
|
}
|
|
};
|
|
|
|
const filteredSlashCommands = useMemo(() => {
|
|
if (!slashSearch) return SLASH_COMMANDS;
|
|
const q = slashSearch.toLowerCase();
|
|
return SLASH_COMMANDS.filter(
|
|
(c) => c.label.toLowerCase().includes(q) || c.desc.toLowerCase().includes(q)
|
|
);
|
|
}, [slashSearch]);
|
|
|
|
useEffect(() => {
|
|
if (slashBlockId && slashMenuRef.current) {
|
|
const container = slashMenuRef.current;
|
|
const activeBtn = container.querySelector<HTMLElement>(
|
|
`[data-slash-index="${slashSelectedIndex}"]`
|
|
);
|
|
if (activeBtn) {
|
|
const itemTop = activeBtn.offsetTop;
|
|
const itemBottom = itemTop + activeBtn.offsetHeight;
|
|
const containerTop = container.scrollTop;
|
|
const containerBottom = containerTop + container.clientHeight;
|
|
|
|
if (itemTop < containerTop) {
|
|
container.scrollTop = itemTop;
|
|
} else if (itemBottom > containerBottom) {
|
|
container.scrollTop = itemBottom - container.clientHeight;
|
|
}
|
|
}
|
|
}
|
|
}, [slashSelectedIndex, slashBlockId]);
|
|
|
|
const focusBlock = (blockId: string, customOffset?: number) => {
|
|
setLastFocusedBlockId(blockId);
|
|
const blk = selectedDoc?.blocks.find((b) => b.id === blockId);
|
|
const targetOffset = customOffset !== undefined ? customOffset : (blk?.content || '').length;
|
|
setBlockCursor(blockId, targetOffset);
|
|
|
|
const tryFocus = () => {
|
|
const el = blockInputRefs.current[blockId];
|
|
if (el) {
|
|
if (el instanceof HTMLTextAreaElement) {
|
|
autoResizeTextarea(el);
|
|
}
|
|
el.focus();
|
|
if ('setSelectionRange' in el && typeof (el as any).value === 'string') {
|
|
const segStartAttr = el.getAttribute('data-seg-start');
|
|
const segStart = segStartAttr !== null ? parseInt(segStartAttr, 10) || 0 : 0;
|
|
const len = (el as any).value.length;
|
|
const localPos = Math.max(0, Math.min(len, targetOffset - segStart));
|
|
(el as any).setSelectionRange(localPos, localPos);
|
|
}
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
setTimeout(() => {
|
|
if (!tryFocus()) {
|
|
setTimeout(tryFocus, 30);
|
|
}
|
|
}, 20);
|
|
};
|
|
|
|
const handleCanvasBottomClick = () => {
|
|
if (isReadOnly || !selectedDoc) return;
|
|
|
|
if (zoomedBlock) {
|
|
const zoomIdx = selectedDoc.blocks.findIndex((b) => b.id === zoomedBlock.id);
|
|
if (zoomIdx === -1) return;
|
|
const minChildLevel = (zoomedBlock.level || 0) + 1;
|
|
|
|
let lastDescendantIndex = zoomIdx;
|
|
for (let i = zoomIdx + 1; i < selectedDoc.blocks.length; i++) {
|
|
if ((selectedDoc.blocks[i].level || 0) >= minChildLevel) {
|
|
lastDescendantIndex = i;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (lastDescendantIndex === zoomIdx) {
|
|
insertNewBulletAfter(zoomIdx, minChildLevel);
|
|
return;
|
|
}
|
|
|
|
const lastVisible = visibleBlocks[visibleBlocks.length - 1];
|
|
if (
|
|
lastVisible &&
|
|
lastVisible.block.type !== 'divider' &&
|
|
(!lastVisible.block.content || lastVisible.block.content.trim() === '')
|
|
) {
|
|
focusBlock(lastVisible.block.id);
|
|
} else {
|
|
insertNewBulletAfter(lastDescendantIndex, minChildLevel);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (selectedDoc.blocks.length === 0) {
|
|
insertNewBulletAfter(-1, 0);
|
|
return;
|
|
}
|
|
const lastIndex = selectedDoc.blocks.length - 1;
|
|
const lastBlock = selectedDoc.blocks[lastIndex];
|
|
if (lastBlock && lastBlock.type !== 'divider' && (!lastBlock.content || lastBlock.content.trim() === '')) {
|
|
focusBlock(lastBlock.id);
|
|
} else {
|
|
insertNewBulletAfter(lastIndex, 0);
|
|
}
|
|
};
|
|
|
|
const applySlashCommand = (blockId: string, cmd: SlashCommandItem) => {
|
|
if (isReadOnly || !selectedDoc) return;
|
|
const block = selectedDoc.blocks.find((b) => b.id === blockId);
|
|
if (!block) return;
|
|
const cleanContent = block.content.replace(/\/([a-z0-9]*)$/i, '').trim();
|
|
|
|
setSlashBlockId(null);
|
|
setSlashSearch('');
|
|
|
|
if (cmd.action) {
|
|
updateBlock(blockId, { content: cleanContent });
|
|
setEditingLinkOriginalRaw(null);
|
|
setLinkModalBlockId(blockId);
|
|
setLinkModalTab(cmd.action === 'insert_weblink' ? 'web' : 'note');
|
|
setLinkSearchQuery('');
|
|
setLinkWebTitle('');
|
|
setLinkWebUrl('');
|
|
return;
|
|
}
|
|
|
|
if (cmd.type) {
|
|
if (cmd.type === 'divider') {
|
|
const blkIdx = selectedDoc.blocks.findIndex((b) => b.id === blockId);
|
|
const curLevel = block.level || 0;
|
|
const newBlockId = `blk_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
|
const newBullet: DocumentBlock = {
|
|
id: newBlockId,
|
|
type: 'bullet',
|
|
content: '',
|
|
level: curLevel,
|
|
checked: false,
|
|
};
|
|
const nextBlocks = [...selectedDoc.blocks];
|
|
nextBlocks[blkIdx] = {
|
|
...block,
|
|
type: 'divider',
|
|
content: '',
|
|
};
|
|
nextBlocks.splice(blkIdx + 1, 0, newBullet);
|
|
persistDocument({ ...selectedDoc, blocks: nextBlocks });
|
|
focusBlock(newBlockId);
|
|
return;
|
|
}
|
|
|
|
updateBlock(blockId, {
|
|
type: cmd.type,
|
|
content: cleanContent,
|
|
checked: cmd.type === 'todo' ? false : undefined,
|
|
lang: cmd.type === 'code' ? (block.lang || 'javascript') : block.lang,
|
|
});
|
|
focusBlock(blockId);
|
|
}
|
|
};
|
|
|
|
const handleInsertNoteLink = (docTitle: string) => {
|
|
if (!linkModalBlockId || !selectedDoc) return;
|
|
const targetBlockId = linkModalBlockId;
|
|
const block = selectedDoc.blocks.find((b) => b.id === targetBlockId);
|
|
let nextCursor = 0;
|
|
if (block) {
|
|
let newContent: string;
|
|
if (editingLinkOriginalRaw && block.content.includes(editingLinkOriginalRaw)) {
|
|
newContent = block.content.replace(editingLinkOriginalRaw, `[[${docTitle}]]`);
|
|
if (!newContent.endsWith(' ')) newContent += ' ';
|
|
} else {
|
|
const base = block.content || '';
|
|
const space = base.length > 0 && !base.endsWith(' ') ? ' ' : '';
|
|
newContent = `${base}${space}[[${docTitle}]] `;
|
|
}
|
|
nextCursor = newContent.length;
|
|
updateBlock(targetBlockId, { content: newContent });
|
|
}
|
|
setLinkModalBlockId(null);
|
|
setEditingLinkOriginalRaw(null);
|
|
focusBlock(targetBlockId, nextCursor);
|
|
};
|
|
|
|
const handleInsertWebLink = () => {
|
|
if (!linkModalBlockId || !selectedDoc) return;
|
|
const targetBlockId = linkModalBlockId;
|
|
const block = selectedDoc.blocks.find((b) => b.id === targetBlockId);
|
|
let title = linkWebTitle.trim();
|
|
let url = linkWebUrl.trim();
|
|
if (!url) return;
|
|
if (!/^https?:\/\//i.test(url)) {
|
|
url = `https://${url}`;
|
|
}
|
|
if (!title) {
|
|
try {
|
|
title = new URL(url).hostname;
|
|
} catch {
|
|
title = url;
|
|
}
|
|
}
|
|
let nextCursor = 0;
|
|
if (block) {
|
|
let newContent: string;
|
|
if (editingLinkOriginalRaw && block.content.includes(editingLinkOriginalRaw)) {
|
|
newContent = block.content.replace(editingLinkOriginalRaw, `[${title}](${url})`);
|
|
if (!newContent.endsWith(' ')) newContent += ' ';
|
|
} else {
|
|
const base = block.content || '';
|
|
const space = base.length > 0 && !base.endsWith(' ') ? ' ' : '';
|
|
newContent = `${base}${space}[${title}](${url}) `;
|
|
}
|
|
nextCursor = newContent.length;
|
|
updateBlock(targetBlockId, { content: newContent });
|
|
}
|
|
setLinkModalBlockId(null);
|
|
setEditingLinkOriginalRaw(null);
|
|
setLinkWebTitle('');
|
|
setLinkWebUrl('');
|
|
focusBlock(targetBlockId, nextCursor);
|
|
};
|
|
|
|
const getDirectChildTodos = (blocks: DocumentBlock[], parentIndex: number): DocumentBlock[] => {
|
|
const parent = blocks[parentIndex];
|
|
if (!parent) return [];
|
|
const parentLevel = parent.level || 0;
|
|
const childTodos: DocumentBlock[] = [];
|
|
for (let i = parentIndex + 1; i < blocks.length; i++) {
|
|
const cur = blocks[i];
|
|
const curLevel = cur.level || 0;
|
|
if (curLevel <= parentLevel) break;
|
|
if (cur.type === 'todo') {
|
|
childTodos.push(cur);
|
|
}
|
|
}
|
|
return childTodos;
|
|
};
|
|
|
|
// Helper: Get subtree count (root + all indented children)
|
|
const getBlockSubtreeCount = (blocks: DocumentBlock[], startIndex: number): number => {
|
|
if (startIndex < 0 || startIndex >= blocks.length) return 0;
|
|
const rootLevel = blocks[startIndex].level || 0;
|
|
let count = 1;
|
|
for (let i = startIndex + 1; i < blocks.length; i++) {
|
|
if ((blocks[i].level || 0) > rootLevel) {
|
|
count++;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
return count;
|
|
};
|
|
|
|
// Helper: Check if target block is a descendant of source block
|
|
const isDescendantBlock = (blocks: DocumentBlock[], sourceIdx: number, targetBlockId: string): boolean => {
|
|
if (sourceIdx < 0 || sourceIdx >= blocks.length) return false;
|
|
const subtreeCount = getBlockSubtreeCount(blocks, sourceIdx);
|
|
for (let i = sourceIdx; i < sourceIdx + subtreeCount; i++) {
|
|
if (blocks[i]?.id === targetBlockId) return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
// Drag & Drop Handlers on the Bullet row / Grip / Dot
|
|
const handleBlockDragStart = (e: React.DragEvent, blockId: string) => {
|
|
e.stopPropagation();
|
|
if (isReadOnly) {
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
isDraggingBlockRef.current = true;
|
|
if (idleUnlockTimerRef.current) {
|
|
clearTimeout(idleUnlockTimerRef.current);
|
|
idleUnlockTimerRef.current = null;
|
|
}
|
|
// Activate write lock immediately upon dragging (optimistic UI + backend broadcast)
|
|
setHoldsLock(true);
|
|
ensureLock(true);
|
|
|
|
e.dataTransfer.setData('application/mindsift-block-id', blockId);
|
|
e.dataTransfer.setData('text/plain', blockId);
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
setDraggingBlockId(blockId);
|
|
};
|
|
|
|
const handleBlockDragEnd = () => {
|
|
isDraggingBlockRef.current = false;
|
|
setDraggingBlockId(null);
|
|
setDragOverBlockTarget(null);
|
|
if (idleUnlockTimerRef.current) {
|
|
clearTimeout(idleUnlockTimerRef.current);
|
|
}
|
|
idleUnlockTimerRef.current = setTimeout(async () => {
|
|
if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) {
|
|
await flushPendingSave();
|
|
await releaseMyLock();
|
|
}
|
|
}, 2000);
|
|
};
|
|
|
|
const handleBlockDragOver = (e: React.DragEvent, targetBlockId: string) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (!draggingBlockId || draggingBlockId === targetBlockId) return;
|
|
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const relY = e.clientY - rect.top;
|
|
const height = rect.height;
|
|
|
|
let position: 'before' | 'after' | 'inside';
|
|
if (relY < height * 0.25) {
|
|
position = 'before';
|
|
} else if (relY > height * 0.75) {
|
|
position = 'after';
|
|
} else {
|
|
position = 'inside';
|
|
}
|
|
|
|
if (
|
|
!dragOverBlockTarget ||
|
|
dragOverBlockTarget.blockId !== targetBlockId ||
|
|
dragOverBlockTarget.position !== position
|
|
) {
|
|
setDragOverBlockTarget({ blockId: targetBlockId, position });
|
|
}
|
|
};
|
|
|
|
const handleBlockDragLeave = (e: React.DragEvent, targetBlockId: string) => {
|
|
e.stopPropagation();
|
|
if (dragOverBlockTarget?.blockId === targetBlockId) {
|
|
setDragOverBlockTarget(null);
|
|
}
|
|
};
|
|
|
|
const handleBlockDrop = (e: React.DragEvent, targetBlockId: string, explicitPosition?: 'before' | 'after' | 'inside') => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const sourceId =
|
|
e.dataTransfer.getData('application/mindsift-block-id') ||
|
|
e.dataTransfer.getData('text/plain') ||
|
|
draggingBlockId;
|
|
|
|
const position = explicitPosition || dragOverBlockTarget?.position || 'after';
|
|
isDraggingBlockRef.current = false;
|
|
setDraggingBlockId(null);
|
|
setDragOverBlockTarget(null);
|
|
|
|
if (!selectedDoc || !sourceId || sourceId === targetBlockId) {
|
|
return;
|
|
}
|
|
|
|
const blocks = [...selectedDoc.blocks];
|
|
const sourceIdx = blocks.findIndex((b) => b.id === sourceId);
|
|
if (sourceIdx === -1) return;
|
|
|
|
// Prevent circular drag into one's own children
|
|
if (isDescendantBlock(blocks, sourceIdx, targetBlockId)) {
|
|
return;
|
|
}
|
|
|
|
// Extract the entire subtree (parent + all indented children)
|
|
const subtreeCount = getBlockSubtreeCount(blocks, sourceIdx);
|
|
const subtree = blocks.splice(sourceIdx, subtreeCount);
|
|
|
|
// Find new index of target block after splice
|
|
const newTargetIdx = blocks.findIndex((b) => b.id === targetBlockId);
|
|
if (newTargetIdx === -1) return;
|
|
|
|
const targetBlock = blocks[newTargetIdx];
|
|
|
|
if (position === 'inside') {
|
|
// Nest directly under targetBlock as its sub-bullet
|
|
const targetLevel = (targetBlock.level || 0) + 1;
|
|
const oldRootLevel = subtree[0].level || 0;
|
|
const levelDiff = targetLevel - oldRootLevel;
|
|
|
|
for (const b of subtree) {
|
|
b.level = Math.max(0, (b.level || 0) + levelDiff);
|
|
}
|
|
|
|
// Automatically uncollapse targetBlock so the newly added sub-bullet is visible
|
|
if (targetBlock.collapsed) {
|
|
blocks[newTargetIdx] = { ...targetBlock, collapsed: false };
|
|
}
|
|
|
|
// Insert immediately below targetBlock
|
|
blocks.splice(newTargetIdx + 1, 0, ...subtree);
|
|
} else {
|
|
const targetLevel = targetBlock.level || 0;
|
|
const oldRootLevel = subtree[0].level || 0;
|
|
const levelDiff = targetLevel - oldRootLevel;
|
|
|
|
// Adjust levels of all blocks in subtree to match target hierarchy
|
|
for (const b of subtree) {
|
|
b.level = Math.max(0, (b.level || 0) + levelDiff);
|
|
}
|
|
|
|
// Calculate insertion index
|
|
let insertIdx = newTargetIdx;
|
|
if (position === 'after') {
|
|
const targetSubtreeCount = getBlockSubtreeCount(blocks, newTargetIdx);
|
|
insertIdx = newTargetIdx + targetSubtreeCount;
|
|
}
|
|
|
|
blocks.splice(insertIdx, 0, ...subtree);
|
|
}
|
|
|
|
persistDocument({ ...selectedDoc, blocks }, true);
|
|
|
|
if (idleUnlockTimerRef.current) {
|
|
clearTimeout(idleUnlockTimerRef.current);
|
|
}
|
|
idleUnlockTimerRef.current = setTimeout(async () => {
|
|
if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) {
|
|
await flushPendingSave();
|
|
await releaseMyLock();
|
|
}
|
|
}, 2500);
|
|
};
|
|
|
|
// Export full document as portable Logseq-indented Markdown
|
|
const handleExportMarkdown = () => {
|
|
if (!selectedDoc) return;
|
|
const lines: string[] = [`# ${selectedDoc.icon} ${selectedDoc.title}`, ''];
|
|
for (const b of selectedDoc.blocks) {
|
|
const indent = ' '.repeat(b.level || 0);
|
|
switch (b.type) {
|
|
case 'h1':
|
|
lines.push(`${indent}- # ${b.content}`);
|
|
break;
|
|
case 'h2':
|
|
lines.push(`${indent}- ## ${b.content}`);
|
|
break;
|
|
case 'h3':
|
|
lines.push(`${indent}- ### ${b.content}`);
|
|
break;
|
|
case 'todo':
|
|
lines.push(`${indent}- [${b.checked ? 'x' : ' '}] ${b.content}`);
|
|
break;
|
|
case 'callout':
|
|
lines.push(`${indent}- > 💡 ${b.content}`);
|
|
break;
|
|
case 'code':
|
|
lines.push(`${indent}- \`\`\`${b.lang || ''}\n${indent} ${b.content}\n${indent} \`\`\``);
|
|
break;
|
|
case 'table':
|
|
case 'bullet':
|
|
case 'paragraph':
|
|
default:
|
|
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 = `${selectedDoc.title.toLowerCase().replace(/[^a-z0-9äöüß]+/g, '-')}.md`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
const findRichTokens = (text: string): { start: number; end: number; raw: string }[] => {
|
|
if (!text) return [];
|
|
const regex = /(\[\[[^\]]+\]\]|\[[^\]]*\]\(https?:\/\/[^\s)]+\)|#[a-zA-Z0-9äöüÄÖÜß_-]+)/g;
|
|
const tokens: { start: number; end: number; raw: string }[] = [];
|
|
let match: RegExpExecArray | null;
|
|
while ((match = regex.exec(text)) !== null) {
|
|
tokens.push({
|
|
start: match.index,
|
|
end: match.index + match[0].length,
|
|
raw: match[0],
|
|
});
|
|
}
|
|
return tokens;
|
|
};
|
|
|
|
const renderSingleRichToken = (part: string, blockId: string | undefined, key: React.Key) => {
|
|
if (part.startsWith('[[') && part.endsWith(']]')) {
|
|
const linkTitle = part.slice(2, -2).trim();
|
|
const target = docs.find((d) => d.title.toLowerCase() === linkTitle.toLowerCase());
|
|
return (
|
|
<span
|
|
key={key}
|
|
className={`inline-flex items-center gap-1 px-1.5 py-0.5 mx-0.5 border text-xs font-medium rounded-btn transition-colors align-baseline ${
|
|
target
|
|
? 'bg-accent/20 border-accent/40 text-accent hover:bg-accent/30'
|
|
: 'badge-neutral hover:text-main'
|
|
}`}
|
|
>
|
|
<span
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
if (target) {
|
|
setSelectedDoc(target);
|
|
setZoomedBlockId(null);
|
|
setWorkspaceMode('editor');
|
|
}
|
|
}}
|
|
className="inline-flex items-center gap-1 cursor-pointer"
|
|
title={target ? `Öffne: ${target.title}` : `Dokument "${linkTitle}" existiert noch nicht`}
|
|
>
|
|
<span>📄</span>
|
|
<span>{linkTitle}</span>
|
|
<ExternalLink className="w-2.5 h-2.5 opacity-60" />
|
|
</span>
|
|
{!isReadOnly && blockId && (
|
|
<button
|
|
type="button"
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setEditingLinkOriginalRaw(part);
|
|
setLinkModalBlockId(blockId);
|
|
setLinkModalTab('note');
|
|
setLinkSearchQuery(linkTitle);
|
|
}}
|
|
className="ml-0.5 p-0.5 rounded hover:bg-black/10 dark:hover:bg-white/10 opacity-60 hover:opacity-100 transition-opacity cursor-pointer"
|
|
title="Notiz-Link bearbeiten"
|
|
>
|
|
<Edit3 className="w-2.5 h-2.5" />
|
|
</button>
|
|
)}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
const webMatch = part.match(/^\[(.*?)\]\((https?:\/\/[^\s)]+)\)$/);
|
|
if (webMatch) {
|
|
const title = webMatch[1] || webMatch[2];
|
|
const url = webMatch[2];
|
|
return (
|
|
<span
|
|
key={key}
|
|
className="inline-flex items-center gap-1 px-1.5 py-0.5 mx-0.5 bg-sky-500/10 border border-sky-500/30 text-sky-500 dark:text-sky-400 hover:bg-sky-500/20 text-xs font-medium rounded-btn transition-colors align-baseline"
|
|
>
|
|
<a
|
|
href={url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="inline-flex items-center gap-1 cursor-pointer"
|
|
title={`Öffne: ${url}`}
|
|
>
|
|
<span>🌐</span>
|
|
<span>{title}</span>
|
|
<ExternalLink className="w-2.5 h-2.5 opacity-60" />
|
|
</a>
|
|
{!isReadOnly && blockId && (
|
|
<button
|
|
type="button"
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setEditingLinkOriginalRaw(part);
|
|
setLinkModalBlockId(blockId);
|
|
setLinkModalTab('web');
|
|
setLinkWebTitle(webMatch[1] || '');
|
|
setLinkWebUrl(url);
|
|
}}
|
|
className="ml-0.5 p-0.5 rounded hover:bg-sky-500/20 opacity-60 hover:opacity-100 transition-opacity cursor-pointer"
|
|
title="Link bearbeiten"
|
|
>
|
|
<Edit3 className="w-2.5 h-2.5" />
|
|
</button>
|
|
)}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
if (part.startsWith('#') && part.length > 1) {
|
|
const tagWord = part.slice(1);
|
|
return (
|
|
<span
|
|
key={key}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
openTagPage(tagWord);
|
|
}}
|
|
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 mx-0.5 bg-blue-500/10 border border-blue-500/30 text-blue-500 dark:text-blue-400 hover:bg-blue-500/20 text-xs font-medium cursor-pointer rounded-btn transition-colors align-baseline"
|
|
title={`Öffne Hashtag-Seite: #${tagWord}`}
|
|
>
|
|
<Hash className="w-2.5 h-2.5 opacity-80" />
|
|
<span>{tagWord}</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
return <span key={key} className="whitespace-pre-wrap">{part}</span>;
|
|
};
|
|
|
|
// Helper to render text with clickable Wiki-Links, Web-Links and Hashtags across multiple lines
|
|
const renderFormattedText = (text: string, blockId?: string) => {
|
|
const rawLines = (text || '').split('\n');
|
|
if (rawLines.length <= 1) {
|
|
const parts = (text || '').split(/(\[\[.*?\]\]|\[.*?\]\(https?:\/\/[^\s)]+\)|#[a-zA-Z0-9äöüÄÖÜß_-]+)/g);
|
|
return parts.map((part, i) => renderSingleRichToken(part, blockId, i));
|
|
}
|
|
return (
|
|
<div className="w-full space-y-0.5">
|
|
{rawLines.map((lineText, lineIdx) => {
|
|
const parts = lineText.split(/(\[\[.*?\]\]|\[.*?\]\(https?:\/\/[^\s)]+\)|#[a-zA-Z0-9äöüÄÖÜß_-]+)/g);
|
|
return (
|
|
<div key={lineIdx} className="flex flex-wrap items-center gap-y-0.5 w-full min-h-[22px]">
|
|
{lineText.length === 0 ? (
|
|
<span> </span>
|
|
) : (
|
|
parts.map((part, i) => renderSingleRichToken(part, blockId, `${lineIdx}-${i}`))
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Auto-resizing textarea / formatted view for bullet points and tasks:
|
|
// - When editing: renders an auto-expanding textarea that naturally wraps text (no horizontal scrolling).
|
|
// - When inactive: renders full rich tokens (Wiki-Links, external URLs, #hashtags) with word wrapping.
|
|
const renderLiveInlineBlock = (
|
|
block: DocumentBlock,
|
|
originalIndex: number,
|
|
extraTextClass = 'text-main'
|
|
) => {
|
|
const text = block.content || '';
|
|
if (isReadOnly) {
|
|
return (
|
|
<div className={`w-full text-sm leading-relaxed break-words whitespace-pre-wrap min-h-[22px] cursor-default ${extraTextClass}`}>
|
|
{renderFormattedText(text, block.id)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const isEditing = activeEditingBlockId === block.id;
|
|
|
|
if (!isEditing) {
|
|
return (
|
|
<div
|
|
onClick={(e) => {
|
|
if (isReadOnly) return;
|
|
if ((e.target as HTMLElement).closest('button, a')) return;
|
|
let clickOffset: number | undefined = undefined;
|
|
try {
|
|
if (document.caretRangeFromPoint) {
|
|
const range = document.caretRangeFromPoint(e.clientX, e.clientY);
|
|
if (range && e.currentTarget.contains(range.startContainer)) {
|
|
const preCaretRange = range.cloneRange();
|
|
preCaretRange.selectNodeContents(e.currentTarget);
|
|
preCaretRange.setEnd(range.endContainer, range.endOffset);
|
|
clickOffset = preCaretRange.toString().length;
|
|
}
|
|
} else if ((document as any).caretPositionFromPoint) {
|
|
const pos = (document as any).caretPositionFromPoint(e.clientX, e.clientY);
|
|
if (pos && pos.offsetNode && e.currentTarget.contains(pos.offsetNode)) {
|
|
const range = document.createRange();
|
|
range.selectNodeContents(e.currentTarget);
|
|
range.setEnd(pos.offsetNode, pos.offset);
|
|
clickOffset = range.toString().length;
|
|
}
|
|
}
|
|
} catch {
|
|
// fallback to end of text
|
|
}
|
|
focusBlock(block.id, clickOffset);
|
|
}}
|
|
className={`w-full text-sm leading-relaxed break-words whitespace-pre-wrap min-h-[22px] cursor-text ${extraTextClass}`}
|
|
>
|
|
{text ? (
|
|
renderFormattedText(text, block.id)
|
|
) : (
|
|
<span className="text-faint italic select-none"> </span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<textarea
|
|
ref={(el) => {
|
|
blockInputRefs.current[block.id] = el;
|
|
if (el) {
|
|
autoResizeTextarea(el);
|
|
}
|
|
}}
|
|
rows={1}
|
|
readOnly={isReadOnly}
|
|
value={block.content || ''}
|
|
placeholder="Notiz eingeben… (/ für Befehle)"
|
|
onFocus={() => {
|
|
handleBlockFocus(block.id);
|
|
}}
|
|
onBlur={() => {
|
|
setActiveEditingBlockId((prev) => (prev === block.id ? null : prev));
|
|
handleBlockBlur();
|
|
}}
|
|
onChange={(e) => {
|
|
autoResizeTextarea(e.target);
|
|
const pos = e.target.selectionStart;
|
|
if (typeof pos === 'number') {
|
|
activeCursorOffsetRef.current = pos;
|
|
}
|
|
handleContentChange(block, e.target.value);
|
|
}}
|
|
onSelect={(e) => {
|
|
const target = e.target as HTMLTextAreaElement;
|
|
if (typeof target.selectionStart === 'number') {
|
|
activeCursorOffsetRef.current = target.selectionStart;
|
|
}
|
|
}}
|
|
onClick={(e) => {
|
|
const target = e.target as HTMLTextAreaElement;
|
|
if (typeof target.selectionStart === 'number') {
|
|
activeCursorOffsetRef.current = target.selectionStart;
|
|
}
|
|
}}
|
|
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
|
|
className={`w-full bg-transparent text-sm leading-relaxed focus:outline-none resize-none overflow-hidden break-words whitespace-pre-wrap placeholder:text-faint p-0 m-0 border-0 ${extraTextClass}`}
|
|
/>
|
|
);
|
|
};
|
|
|
|
// --- GLOBAL TO-DO ROLLUP AGGREGATOR ---
|
|
const allWorkspaceTodos = useMemo(() => {
|
|
const items: {
|
|
docId: string;
|
|
docTitle: string;
|
|
docIcon: string;
|
|
folder: string;
|
|
block: DocumentBlock;
|
|
}[] = [];
|
|
|
|
docs.forEach((doc) => {
|
|
const folder = getDocFolder(doc) || 'Root (Unsortiert)';
|
|
(doc.blocks || []).forEach((b) => {
|
|
if (b.type === 'todo' || (b.content && b.content.startsWith('- [ ]'))) {
|
|
items.push({
|
|
docId: doc.id,
|
|
docTitle: doc.title,
|
|
docIcon: doc.icon || '📄',
|
|
folder,
|
|
block: b,
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
return items;
|
|
}, [docs]);
|
|
|
|
const filteredTodos = useMemo(() => {
|
|
return allWorkspaceTodos.filter((item) => {
|
|
if (todoFilter === 'open' && item.block.checked) return false;
|
|
if (todoFilter === 'done' && !item.block.checked) return false;
|
|
if (todoFolderDrilldown && item.folder !== todoFolderDrilldown) return false;
|
|
return true;
|
|
});
|
|
}, [allWorkspaceTodos, todoFilter, todoFolderDrilldown]);
|
|
|
|
// Group filtered todos by folder -> docTitle
|
|
const todosByFolder = useMemo(() => {
|
|
const map = new Map<string, typeof filteredTodos>();
|
|
filteredTodos.forEach((item) => {
|
|
const existing = map.get(item.folder) || [];
|
|
existing.push(item);
|
|
map.set(item.folder, existing);
|
|
});
|
|
return map;
|
|
}, [filteredTodos]);
|
|
|
|
// Direct toggle of a To-Do item in the rollup dashboard
|
|
const handleToggleRollupTodo = async (docId: string, blockId: string) => {
|
|
triggerHaptic(15);
|
|
const targetDoc = docs.find((d) => d.id === docId);
|
|
if (!targetDoc) return;
|
|
|
|
const updatedBlocks = targetDoc.blocks.map((b) =>
|
|
b.id === blockId ? { ...b, checked: !b.checked } : b
|
|
);
|
|
const updatedDoc = { ...targetDoc, blocks: updatedBlocks };
|
|
|
|
// Update in local state
|
|
setDocs((prev) => prev.map((d) => (d.id === docId ? updatedDoc : d)));
|
|
if (selectedDoc?.id === docId) {
|
|
setSelectedDoc(updatedDoc);
|
|
}
|
|
|
|
// Also update tagDetails if currently viewing tag page
|
|
setTagDetails((prev) => {
|
|
if (!prev) return prev;
|
|
return {
|
|
...prev,
|
|
documents: prev.documents.map((dg) => {
|
|
if (dg.docId !== docId) return dg;
|
|
return {
|
|
...dg,
|
|
matchedBlocks: dg.matchedBlocks.map((mb) => ({
|
|
rootBlock:
|
|
mb.rootBlock.id === blockId
|
|
? { ...mb.rootBlock, checked: !mb.rootBlock.checked }
|
|
: mb.rootBlock,
|
|
children: mb.children.map((c) =>
|
|
c.id === blockId ? { ...c, checked: !c.checked } : c
|
|
),
|
|
})),
|
|
};
|
|
}),
|
|
};
|
|
});
|
|
|
|
// Persist immediately via atomic block patch
|
|
const toggledBlock = updatedBlocks.find((b) => b.id === blockId);
|
|
if (toggledBlock) {
|
|
await api.patchDocumentBlocks(docId, {
|
|
upserted: [toggledBlock],
|
|
});
|
|
}
|
|
};
|
|
|
|
// Jump directly to document from rollup
|
|
const handleJumpToDoc = (docId: string) => {
|
|
const found = docs.find((d) => d.id === docId);
|
|
if (found) {
|
|
setSelectedDoc(found);
|
|
setZoomedBlockId(null);
|
|
setWorkspaceMode('editor');
|
|
}
|
|
};
|
|
|
|
// --- FOLDER TREE CALCULATION ---
|
|
const filteredDocs = useMemo(() => {
|
|
if (!searchQuery.trim()) return docs;
|
|
const q = searchQuery.toLowerCase();
|
|
return docs.filter(
|
|
(d) => d.title.toLowerCase().includes(q) || d.content.toLowerCase().includes(q)
|
|
);
|
|
}, [docs, searchQuery]);
|
|
|
|
const { rootTree, rootDocs } = useMemo(() => {
|
|
const folderMap = new Map<string, FolderTreeItem>();
|
|
|
|
allFolderPaths.forEach((path) => {
|
|
const parts = path.split('/');
|
|
const name = parts[parts.length - 1];
|
|
|
|
let matchingSF: SharedFolder | undefined;
|
|
let isRoot = false;
|
|
for (const sf of sharedFolders) {
|
|
if (sf.mountPath === path) {
|
|
matchingSF = sf;
|
|
isRoot = true;
|
|
break;
|
|
} else if (path.startsWith(sf.mountPath + '/')) {
|
|
matchingSF = sf;
|
|
isRoot = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
folderMap.set(path, {
|
|
path,
|
|
name,
|
|
depth: parts.length - 1,
|
|
subfolders: [],
|
|
docs: [],
|
|
sharedFolder: matchingSF,
|
|
isShared: !!matchingSF,
|
|
isSharedRoot: isRoot,
|
|
});
|
|
});
|
|
|
|
filteredDocs.forEach((doc) => {
|
|
const folder = getDocFolder(doc);
|
|
if (folder && folderMap.has(folder)) {
|
|
folderMap.get(folder)!.docs.push(doc);
|
|
}
|
|
});
|
|
|
|
const rootFolders: FolderTreeItem[] = [];
|
|
allFolderPaths.forEach((path) => {
|
|
const item = folderMap.get(path)!;
|
|
const lastSlash = path.lastIndexOf('/');
|
|
if (lastSlash === -1) {
|
|
rootFolders.push(item);
|
|
} else {
|
|
const parentPath = path.substring(0, lastSlash);
|
|
const parent = folderMap.get(parentPath);
|
|
if (parent) {
|
|
parent.subfolders.push(item);
|
|
} else {
|
|
rootFolders.push(item);
|
|
}
|
|
}
|
|
});
|
|
|
|
const rootOnlyDocs = filteredDocs.filter((d) => !getDocFolder(d));
|
|
return { rootTree: rootFolders, rootDocs: rootOnlyDocs };
|
|
}, [allFolderPaths, filteredDocs, sharedFolders]);
|
|
|
|
const pinnedDocs = useMemo(() => {
|
|
return docs.filter((d) => d.isPinned);
|
|
}, [docs]);
|
|
|
|
const filteredWorkspaceTags = useMemo(() => {
|
|
const q = tagSearchQuery.trim().replace(/^#/, '').toLowerCase();
|
|
if (!q) return workspaceTags;
|
|
return workspaceTags.filter((t) => t.tag.toLowerCase().includes(q));
|
|
}, [workspaceTags, tagSearchQuery]);
|
|
|
|
// Register context-sensitive mobile menu groups behind the MindSift logo
|
|
useEffect(() => {
|
|
setPrimaryAction(() => {
|
|
handleCreateDoc(null);
|
|
});
|
|
|
|
const openTodosCount = allWorkspaceTodos.filter((t) => !t.block.checked).length;
|
|
setGroups([
|
|
{
|
|
id: 'ws-view-files',
|
|
title: 'Workspace-Ansicht & Dateien',
|
|
items: [
|
|
{
|
|
id: 'ws-open-tree',
|
|
label: 'Dateibaum & Ordner öffnen',
|
|
icon: <FolderTree className="w-4 h-4 text-accent" />,
|
|
badge: docs.length,
|
|
variant: 'accent',
|
|
active: showMobileFileDrawer,
|
|
onClick: () => {
|
|
setWorkspaceMode('editor');
|
|
setShowMobileFileDrawer(true);
|
|
},
|
|
},
|
|
{
|
|
id: 'ws-mode-editor',
|
|
label: 'Notizen & Editor',
|
|
icon: <Layers className="w-4 h-4" />,
|
|
active: workspaceMode === 'editor' && !showMobileFileDrawer,
|
|
onClick: () => {
|
|
setWorkspaceMode('editor');
|
|
setShowMobileFileDrawer(false);
|
|
},
|
|
},
|
|
{
|
|
id: 'ws-mode-tags',
|
|
label: 'Themen & Hashtags',
|
|
icon: <Hash className="w-4 h-4" />,
|
|
badge: workspaceTags.length,
|
|
active: workspaceMode === 'tags',
|
|
onClick: () => {
|
|
setWorkspaceMode('tags');
|
|
setShowMobileFileDrawer(false);
|
|
},
|
|
},
|
|
{
|
|
id: 'ws-mode-todos',
|
|
label: 'Globale To-Dos',
|
|
icon: <CheckSquare className="w-4 h-4" />,
|
|
badge: openTodosCount,
|
|
active: workspaceMode === 'todos',
|
|
onClick: () => {
|
|
setWorkspaceMode('todos');
|
|
setShowMobileFileDrawer(false);
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 'ws-doc-actions',
|
|
title: 'Erstellen & Dokument-Aktionen',
|
|
items: [
|
|
{
|
|
id: 'ws-new-doc',
|
|
label: 'Neue Datei erstellen',
|
|
icon: <FilePlus className="w-4 h-4 text-accent" />,
|
|
onClick: () => {
|
|
setShowMobileFileDrawer(false);
|
|
handleCreateDoc(null);
|
|
},
|
|
},
|
|
{
|
|
id: 'ws-new-folder',
|
|
label: 'Neuer Ordner',
|
|
icon: <FolderPlus className="w-4 h-4" />,
|
|
onClick: () => {
|
|
setWorkspaceMode('editor');
|
|
setShowMobileFileDrawer(true);
|
|
handleStartCreateFolder(null);
|
|
},
|
|
},
|
|
...(selectedDoc
|
|
? [
|
|
{
|
|
id: 'ws-share-doc',
|
|
label: `Notiz "${selectedDoc.title || 'Unbenannt'}" teilen`,
|
|
icon: <Users className="w-4 h-4" />,
|
|
onClick: () => openShareModal(),
|
|
},
|
|
{
|
|
id: 'ws-pin-doc',
|
|
label: selectedDoc.isPinned ? 'Von Favoriten lösen' : 'Oben in Favoriten anpinnen',
|
|
icon: <Pin className="w-4 h-4" />,
|
|
active: Boolean(selectedDoc.isPinned),
|
|
onClick: () => persistDocument({ ...selectedDoc, isPinned: !selectedDoc.isPinned }, true),
|
|
},
|
|
{
|
|
id: 'ws-export-md',
|
|
label: 'Als Markdown exportieren (.md)',
|
|
icon: <Download className="w-4 h-4" />,
|
|
onClick: () => handleExportMarkdown(),
|
|
},
|
|
{
|
|
id: 'ws-delete-doc',
|
|
label: selectedDoc.isOwner !== false ? 'Notiz löschen' : 'Geteilte Notiz verlassen',
|
|
icon: <Trash2 className="w-4 h-4" />,
|
|
variant: 'danger' as const,
|
|
onClick: () => {
|
|
if (selectedDoc.isOwner !== false) {
|
|
handleDeleteDoc(selectedDoc.id);
|
|
} else {
|
|
handleLeaveShare();
|
|
}
|
|
},
|
|
},
|
|
]
|
|
: []),
|
|
],
|
|
},
|
|
]);
|
|
}, [
|
|
workspaceMode,
|
|
showMobileFileDrawer,
|
|
docs.length,
|
|
workspaceTags.length,
|
|
allWorkspaceTodos,
|
|
selectedDoc,
|
|
setGroups,
|
|
setPrimaryAction,
|
|
]);
|
|
|
|
const renderDocItem = (doc: Document, depth: number) => {
|
|
const isSelected = selectedDoc?.id === doc.id && workspaceMode === 'editor';
|
|
const isDragging = draggingDocId === doc.id;
|
|
return (
|
|
<div
|
|
key={doc.id}
|
|
draggable
|
|
onDragStart={(e) => {
|
|
e.stopPropagation();
|
|
e.dataTransfer.setData('application/mindsift-doc-id', doc.id);
|
|
e.dataTransfer.setData('text/plain', doc.id);
|
|
setDraggingDocId(doc.id);
|
|
}}
|
|
onDragEnd={() => setDraggingDocId(null)}
|
|
onClick={() => {
|
|
setSelectedDoc(doc);
|
|
setZoomedBlockId(null);
|
|
setWorkspaceMode('editor');
|
|
setShowMobileFileDrawer(false);
|
|
}}
|
|
style={{ paddingLeft: `${depth * 14 + 16}px` }}
|
|
className={`group flex items-center justify-between py-2.5 sm:py-1.5 pr-2 border-l-2 transition-all cursor-pointer text-sm sm:text-xs ${
|
|
isDragging ? 'opacity-40 scale-[0.98]' : ''
|
|
} ${
|
|
isSelected
|
|
? 'bg-panel-elevated border-accent text-main font-medium'
|
|
: 'border-transparent text-muted hover:text-main hover:bg-canvas/50'
|
|
}`}
|
|
title={`${doc.title || 'Notiz'} (Ziehen zum Verschieben in Ordner)`}
|
|
>
|
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
|
<span className="text-base sm:text-sm flex-shrink-0">{doc.icon || '📄'}</span>
|
|
<span className="truncate">{doc.title || 'Unbenannt'}</span>
|
|
{doc.isPinned && <Pin className="w-3 h-3 sm:w-2.5 sm:h-2.5 text-accent flex-shrink-0" />}
|
|
{doc.isOwner === false && (
|
|
<span
|
|
title={`Geteilt von ${doc.ownerName || 'anderem Nutzer'} (${doc.permission === 'read' ? 'Nur Lesen' : 'Schreibrecht'})`}
|
|
className="text-accent flex-shrink-0"
|
|
>
|
|
<Users className="w-3.5 h-3.5 sm:w-3 sm:h-3" />
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderFolder = (node: FolderTreeItem): React.ReactNode => {
|
|
const isExpanded = expandedFolders.has(node.path);
|
|
const isCreatingSubfolder = isCreatingFolder && newFolderParent === node.path;
|
|
const isDragOver = dragOverFolder === node.path;
|
|
const isDraggingThisFolder = draggingFolderPath === node.path;
|
|
const totalItems = node.docs.length + node.subfolders.length;
|
|
|
|
return (
|
|
<div
|
|
key={node.path}
|
|
className="select-none"
|
|
onDragOver={(e) => {
|
|
if (
|
|
draggingDocId ||
|
|
(draggingFolderPath &&
|
|
draggingFolderPath !== node.path &&
|
|
!node.path.startsWith(draggingFolderPath + '/'))
|
|
) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setDragOverFolder(node.path);
|
|
}
|
|
}}
|
|
onDragLeave={(e) => {
|
|
e.preventDefault();
|
|
if (dragOverFolder === node.path) {
|
|
setDragOverFolder(null);
|
|
}
|
|
}}
|
|
onDrop={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const droppedFolder =
|
|
e.dataTransfer.getData('application/mindsift-folder-path') || draggingFolderPath;
|
|
setDragOverFolder(null);
|
|
setDraggingDocId(null);
|
|
setDraggingFolderPath(null);
|
|
|
|
if (droppedFolder) {
|
|
handleMoveFolder(droppedFolder, node.path);
|
|
} else {
|
|
handleDropDocToFolder(node.path, e);
|
|
}
|
|
}}
|
|
>
|
|
<div
|
|
draggable
|
|
onDragStart={(e) => {
|
|
e.stopPropagation();
|
|
e.dataTransfer.setData('application/mindsift-folder-path', node.path);
|
|
e.dataTransfer.setData('text/plain', node.path);
|
|
setDraggingFolderPath(node.path);
|
|
}}
|
|
onDragEnd={() => setDraggingFolderPath(null)}
|
|
onClick={() => toggleFolder(node.path)}
|
|
style={{ paddingLeft: `${node.depth * 14 + 8}px` }}
|
|
className={`group flex items-center justify-between py-2.5 sm:py-1.5 pr-2 cursor-pointer transition-colors text-sm sm:text-xs ${
|
|
isDraggingThisFolder ? 'opacity-40 scale-[0.98]' : ''
|
|
} ${
|
|
isDragOver
|
|
? 'bg-accent/20 border-l-2 border-accent text-main font-medium'
|
|
: 'hover:bg-panel-elevated text-muted'
|
|
}`}
|
|
title={`${node.path} (Klick: Öffnen/Schließen · Ziehen: In anderen Ordner verschieben)`}
|
|
>
|
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
<span className="text-faint hover:text-muted">
|
|
{isExpanded ? (
|
|
<ChevronDown className="w-3.5 h-3.5 sm:w-3 sm:h-3 flex-shrink-0" />
|
|
) : (
|
|
<ChevronRight className="w-3.5 h-3.5 sm:w-3 sm:h-3 flex-shrink-0" />
|
|
)}
|
|
</span>
|
|
{isExpanded ? (
|
|
<FolderOpen className="w-4 h-4 sm:w-3.5 sm:h-3.5 text-accent flex-shrink-0" />
|
|
) : (
|
|
<Folder className="w-4 h-4 sm:w-3.5 sm:h-3.5 text-muted flex-shrink-0" />
|
|
)}
|
|
<span className="font-medium text-sm sm:text-xs text-main truncate">{node.name}</span>
|
|
{node.isShared && (
|
|
<span
|
|
title={node.sharedFolder?.isOwner ? 'Geteilter Ordner (Eigentümer)' : 'Geteilter Ordner'}
|
|
className="text-accent flex-shrink-0"
|
|
>
|
|
<Users className="w-3.5 h-3.5 sm:w-3 sm:h-3" />
|
|
</span>
|
|
)}
|
|
<span className="text-[10px] text-faint font-mono">({totalItems})</span>
|
|
</div>
|
|
|
|
<div className="opacity-100 sm:opacity-0 sm:group-hover:opacity-100 flex items-center gap-1 transition-opacity">
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
openFolderShareModal(node);
|
|
}}
|
|
className="p-1.5 sm:p-1 hover:bg-panel-elevated text-muted hover:text-accent rounded-btn"
|
|
title="Ordner freigeben / teilen"
|
|
>
|
|
<Users className="w-3.5 h-3.5 sm:w-3 sm:h-3" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setShowMobileFileDrawer(false);
|
|
handleCreateDoc(node.path);
|
|
}}
|
|
className="p-1.5 sm:p-1 hover:bg-panel-elevated text-muted hover:text-main rounded-btn"
|
|
title="Neue Datei in diesem Ordner"
|
|
>
|
|
<FilePlus className="w-3.5 h-3.5 sm:w-3 sm:h-3" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleStartCreateFolder(node.path);
|
|
}}
|
|
className="p-1.5 sm:p-1 hover:bg-panel-elevated text-muted hover:text-main rounded-btn"
|
|
title="Neuer Unterordner"
|
|
>
|
|
<FolderPlus className="w-3.5 h-3.5 sm:w-3 sm:h-3" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleDeleteFolder(node.path, node.name);
|
|
}}
|
|
className="p-1.5 sm:p-1 hover:bg-red-500/10 text-muted hover:text-red-400 rounded-btn transition-colors"
|
|
title="Ordner löschen"
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5 sm:w-3 sm:h-3" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{isCreatingSubfolder && (
|
|
<div
|
|
style={{ paddingLeft: `${(node.depth + 1) * 14 + 8}px` }}
|
|
className="py-1.5 sm:py-1 pr-2 flex items-center gap-1 bg-canvas"
|
|
>
|
|
<Folder className="w-3.5 h-3.5 sm:w-3 sm:h-3 text-muted flex-shrink-0" />
|
|
<input
|
|
type="text"
|
|
autoFocus
|
|
value={newFolderName}
|
|
onChange={(e) => setNewFolderName(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') handleConfirmCreateFolder();
|
|
if (e.key === 'Escape') setIsCreatingFolder(false);
|
|
}}
|
|
placeholder="Unterordner…"
|
|
className="flex-1 h-8 sm:h-6 px-2 sm:px-1.5 input-field text-xs"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={handleConfirmCreateFolder}
|
|
className="p-1.5 sm:p-1 text-accent hover:text-main rounded-btn"
|
|
title="Bestätigen"
|
|
>
|
|
<Check className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsCreatingFolder(false)}
|
|
className="p-1.5 sm:p-1 text-muted hover:text-main rounded-btn"
|
|
title="Abbrechen"
|
|
>
|
|
<X className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{isExpanded && (
|
|
<div>
|
|
{node.subfolders.map(renderFolder)}
|
|
{node.docs.map((doc) => renderDocItem(doc, node.depth + 1))}
|
|
{node.subfolders.length === 0 && node.docs.length === 0 && !isCreatingSubfolder && (
|
|
<div
|
|
style={{ paddingLeft: `${(node.depth + 1) * 14 + 14}px` }}
|
|
className="py-1 text-[11px] text-faint italic"
|
|
>
|
|
(leer)
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const isEmpty = docs.length === 0 && allFolderPaths.length === 0;
|
|
|
|
const renderExplorerBody = () => (
|
|
<>
|
|
<div className="p-2.5 sm:p-2 panel-header space-y-2 flex-shrink-0">
|
|
<div className="relative">
|
|
<Search className="w-4 h-4 sm:w-3.5 sm:h-3.5 text-muted absolute left-2.5 top-1/2 -translate-y-1/2" />
|
|
<input
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder="Dateien durchsuchen…"
|
|
className="w-full h-9 sm:h-7 pl-8 sm:pl-7 pr-2 input-field text-sm sm:text-xs"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1.5">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setShowMobileFileDrawer(false);
|
|
handleCreateDoc(null);
|
|
}}
|
|
className="flex-1 btn-action min-h-[38px] sm:min-h-0 justify-center cursor-pointer"
|
|
title="Neue Datei erstellen"
|
|
>
|
|
<FilePlus className="w-4 h-4 sm:w-3.5 sm:h-3.5 text-muted" />
|
|
<span>Neue Datei</span>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => handleStartCreateFolder(null)}
|
|
className="flex-1 btn-action min-h-[38px] sm:min-h-0 justify-center cursor-pointer"
|
|
title="Neuen Ordner erstellen"
|
|
>
|
|
<FolderPlus className="w-4 h-4 sm:w-3.5 sm:h-3.5 text-muted" />
|
|
<span>Neuer Ordner</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{isCreatingFolder && newFolderParent === null && (
|
|
<div className="p-2 panel-header bg-canvas flex items-center gap-1.5 flex-shrink-0">
|
|
<Folder className="w-4 h-4 sm:w-3.5 sm:h-3.5 text-muted flex-shrink-0" />
|
|
<input
|
|
type="text"
|
|
autoFocus
|
|
value={newFolderName}
|
|
onChange={(e) => setNewFolderName(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') handleConfirmCreateFolder();
|
|
if (e.key === 'Escape') setIsCreatingFolder(false);
|
|
}}
|
|
placeholder="Neuer Ordnername…"
|
|
className="flex-1 h-8 sm:h-6 px-2 sm:px-1.5 input-field text-xs"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={handleConfirmCreateFolder}
|
|
className="p-1.5 sm:p-1 text-accent hover:text-main rounded-btn cursor-pointer"
|
|
>
|
|
<Check className="w-4 h-4 sm:w-3 sm:h-3" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsCreatingFolder(false)}
|
|
className="p-1.5 sm:p-1 text-muted hover:text-main rounded-btn cursor-pointer"
|
|
>
|
|
<X className="w-4 h-4 sm:w-3 sm:h-3" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Empty workspace state */}
|
|
{isEmpty ? (
|
|
<div className="p-4 text-center text-xs text-muted space-y-2 flex-1">
|
|
<Folder className="w-8 h-8 text-faint mx-auto" />
|
|
<p className="font-semibold text-main">Noch keine Notizen</p>
|
|
<p className="text-[11px] text-faint mb-3 max-w-xs mx-auto leading-relaxed">
|
|
Erstelle eine neue Datei oder lege Ordner an, um deine Dokumente übersichtlich zu strukturieren.
|
|
</p>
|
|
<div className="flex items-center justify-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setShowMobileFileDrawer(false);
|
|
handleCreateDoc(null);
|
|
}}
|
|
className="btn-action min-h-[38px] sm:min-h-0 cursor-pointer"
|
|
>
|
|
<FilePlus className="w-3.5 h-3.5 text-muted" />
|
|
<span>Neue Datei</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleStartCreateFolder(null)}
|
|
className="btn-action min-h-[38px] sm:min-h-0 cursor-pointer"
|
|
>
|
|
<FolderPlus className="w-3.5 h-3.5 text-muted" />
|
|
<span>Neuer Ordner</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="py-1 flex-1 lg:max-h-[calc(100vh-240px)] overflow-y-auto">
|
|
{/* Drag target for Root when dragging a note or a folder */}
|
|
{(draggingDocId || draggingFolderPath) && (
|
|
<div
|
|
onDragOver={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setDragOverFolder('__root__');
|
|
}}
|
|
onDragLeave={(e) => {
|
|
e.preventDefault();
|
|
if (dragOverFolder === '__root__') setDragOverFolder(null);
|
|
}}
|
|
onDrop={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const droppedFolder =
|
|
e.dataTransfer.getData('application/mindsift-folder-path') ||
|
|
draggingFolderPath;
|
|
setDragOverFolder(null);
|
|
setDraggingDocId(null);
|
|
setDraggingFolderPath(null);
|
|
|
|
if (droppedFolder) {
|
|
handleMoveFolder(droppedFolder, '');
|
|
} else {
|
|
handleDropDocToFolder('', e);
|
|
}
|
|
}}
|
|
className={`mx-2 my-1.5 p-2 border-2 border-dashed rounded-btn text-center text-xs transition-colors ${
|
|
dragOverFolder === '__root__'
|
|
? 'border-accent bg-accent/20 text-main font-semibold'
|
|
: 'border-border text-muted bg-canvas'
|
|
}`}
|
|
>
|
|
{draggingFolderPath
|
|
? '📁 Ordner in Hauptverzeichnis verschieben'
|
|
: '📁 In Hauptverzeichnis verschieben (ohne Ordner)'}
|
|
</div>
|
|
)}
|
|
|
|
{/* Angepinnte Notizen (Favorites) - Collapsible */}
|
|
{pinnedDocs.length > 0 && !searchQuery && (
|
|
<div className="mb-2 pb-1.5 border-b border-border">
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsPinnedCollapsed((prev) => !prev)}
|
|
className="w-full px-2 py-1.5 sm:py-1 text-[10px] font-bold text-accent uppercase tracking-wider flex items-center justify-between hover:bg-canvas/40 rounded transition-colors"
|
|
title={isPinnedCollapsed ? 'Angepinnte Notizen aufklappen' : 'Angepinnte Notizen einklappen'}
|
|
>
|
|
<div className="flex items-center gap-1.5">
|
|
<Pin className="w-3 h-3 text-accent" />
|
|
<span>Angepinnte Notizen ({pinnedDocs.length})</span>
|
|
</div>
|
|
<ChevronDown
|
|
className={`w-3 h-3 text-muted transition-transform duration-200 ${
|
|
isPinnedCollapsed ? '-rotate-90' : ''
|
|
}`}
|
|
/>
|
|
</button>
|
|
{!isPinnedCollapsed && (
|
|
<div className="space-y-0.5 px-1 mt-1">
|
|
{pinnedDocs.map((doc) => {
|
|
const isSelected = selectedDoc?.id === doc.id && workspaceMode === 'editor';
|
|
return (
|
|
<div
|
|
key={`pinned-${doc.id}`}
|
|
draggable
|
|
onDragStart={(e) => {
|
|
e.stopPropagation();
|
|
e.dataTransfer.setData('application/mindsift-doc-id', doc.id);
|
|
e.dataTransfer.setData('text/plain', doc.id);
|
|
setDraggingDocId(doc.id);
|
|
}}
|
|
onDragEnd={() => setDraggingDocId(null)}
|
|
onClick={() => {
|
|
setSelectedDoc(doc);
|
|
setZoomedBlockId(null);
|
|
setWorkspaceMode('editor');
|
|
setShowMobileFileDrawer(false);
|
|
}}
|
|
className={`group flex items-center justify-between px-2 py-2.5 sm:py-1.5 rounded-btn transition-colors cursor-pointer text-sm sm:text-xs ${
|
|
isSelected
|
|
? 'bg-panel-elevated border-l-2 border-accent text-main font-medium'
|
|
: 'text-muted hover:text-main hover:bg-canvas/50'
|
|
}`}
|
|
title="Angepinnte Notiz öffnen"
|
|
>
|
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
|
<span className="text-base sm:text-sm flex-shrink-0">{doc.icon || '📄'}</span>
|
|
<span className="truncate">{doc.title || 'Unbenannt'}</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
persistDocument({ ...doc, isPinned: false }, true);
|
|
}}
|
|
className="opacity-100 sm:opacity-0 sm:group-hover:opacity-100 p-1 sm:p-0.5 text-muted hover:text-accent rounded transition-opacity"
|
|
title="Von Angepinnt entfernen"
|
|
>
|
|
<Pin className="w-3.5 h-3.5 sm:w-3 sm:h-3 text-accent" />
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Themen & Hashtags (Collapsible) - directly under Pinned Notes */}
|
|
{workspaceTags.length > 0 && !searchQuery && (
|
|
<div className="mb-2 pb-1.5 border-b border-border">
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsTagsCollapsed((prev) => !prev)}
|
|
className="w-full px-2 py-1.5 sm:py-1 text-[10px] font-bold text-muted uppercase tracking-wider flex items-center justify-between hover:bg-canvas/40 rounded transition-colors"
|
|
title={isTagsCollapsed ? 'Themen & Hashtags aufklappen' : 'Themen & Hashtags einklappen'}
|
|
>
|
|
<div className="flex items-center gap-1.5 text-accent">
|
|
<Hash className="w-3 h-3" />
|
|
<span>Themen & Hashtags ({workspaceTags.length})</span>
|
|
</div>
|
|
<ChevronDown
|
|
className={`w-3 h-3 text-muted transition-transform duration-200 ${
|
|
isTagsCollapsed ? '-rotate-90' : ''
|
|
}`}
|
|
/>
|
|
</button>
|
|
{!isTagsCollapsed && (
|
|
<div className="flex flex-wrap gap-1.5 sm:gap-1 px-1.5 py-1 mt-1 max-h-36 overflow-y-auto">
|
|
{workspaceTags.map((wt) => (
|
|
<button
|
|
key={wt.tag}
|
|
type="button"
|
|
onClick={() => {
|
|
setShowMobileFileDrawer(false);
|
|
openTagPage(wt.tag);
|
|
}}
|
|
className={`px-2.5 py-1 sm:px-2 sm:py-0.5 rounded-pill text-xs sm:text-[11px] font-medium border flex items-center gap-1 transition-colors ${
|
|
selectedTag === wt.tag
|
|
? 'bg-accent/20 border-accent text-accent font-semibold'
|
|
: 'bg-canvas border-border text-muted hover:border-accent/40 hover:text-main'
|
|
}`}
|
|
title={`#${wt.tag} (${wt.count} Abschnitte)`}
|
|
>
|
|
<span>#{wt.tag}</span>
|
|
<span className="text-[9px] opacity-60">({wt.count})</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{rootTree.map(renderFolder)}
|
|
{rootDocs.map((doc) => renderDocItem(doc, 0))}
|
|
|
|
{filteredDocs.length === 0 && searchQuery && (
|
|
<div className="p-4 text-center text-xs text-faint">
|
|
Keine Dateien gefunden für "{searchQuery}"
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<div className="w-full space-y-2">
|
|
|
|
{/* Top Navigation Mode Switcher: Notizen & Outliner vs. Globales To-Do Dashboard (Desktop) */}
|
|
<div className="hidden sm:flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2">
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => setWorkspaceMode('editor')}
|
|
className={`h-8 px-3 text-xs font-medium border rounded-btn flex items-center gap-1.5 transition-colors ${
|
|
workspaceMode === 'editor'
|
|
? 'bg-panel border-accent text-main'
|
|
: 'bg-canvas border-border text-muted hover:text-main'
|
|
}`}
|
|
>
|
|
<Layers className="w-3.5 h-3.5 text-accent" />
|
|
<span>Notizen</span>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => setWorkspaceMode('tags')}
|
|
className={`h-8 px-3 text-xs font-medium border rounded-btn flex items-center gap-1.5 transition-colors ${
|
|
workspaceMode === 'tags'
|
|
? 'bg-panel border-accent text-main'
|
|
: 'bg-canvas border-border text-muted hover:text-main'
|
|
}`}
|
|
>
|
|
<Hash className="w-3.5 h-3.5 text-accent" />
|
|
<span>Themen & Hashtags</span>
|
|
{workspaceTags.length > 0 && (
|
|
<span className="badge-neutral font-mono text-[10px]">
|
|
{workspaceTags.length}
|
|
</span>
|
|
)}
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => setWorkspaceMode('todos')}
|
|
className={`h-8 px-3 text-xs font-medium border rounded-btn flex items-center gap-1.5 transition-colors ${
|
|
workspaceMode === 'todos'
|
|
? 'bg-panel border-accent text-main'
|
|
: 'bg-canvas border-border text-muted hover:text-main'
|
|
}`}
|
|
>
|
|
<CheckSquare className="w-3.5 h-3.5 text-accent" />
|
|
<span>Globale To-Dos</span>
|
|
<span className="badge-neutral font-mono text-[10px]">
|
|
{allWorkspaceTodos.filter((t) => !t.block.checked).length}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 text-xs text-muted">
|
|
{workspaceMode === 'editor' && selectedDoc && (
|
|
<>
|
|
<span className="badge-accent">
|
|
<Check className="w-3 h-3" />
|
|
{saveState === 'saving' ? 'Speichert…' : 'Gespeichert'}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={handleExportMarkdown}
|
|
className="btn-action h-8 px-2.5 text-xs flex items-center gap-1.5"
|
|
title="Als Logseq/Markdown-Datei (.md) herunterladen"
|
|
>
|
|
<Download className="w-3.5 h-3.5 text-accent" />
|
|
<span>Export (.md)</span>
|
|
</button>
|
|
</>
|
|
)}
|
|
<div
|
|
className={`h-8 w-8 flex items-center justify-center rounded-btn border transition-colors ${
|
|
wsConnected
|
|
? realtimeTransport === 'sse'
|
|
? 'bg-teal-500/10 border-teal-500/30 text-teal-400'
|
|
: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-500'
|
|
: 'bg-amber-500/10 border-amber-500/30 text-amber-500'
|
|
}`}
|
|
title={
|
|
wsConnected
|
|
? realtimeTransport === 'sse'
|
|
? 'Live synchronisiert (Server-Sent Events / Proxy-Stream)'
|
|
: 'Live synchronisiert (WebSocket online)'
|
|
: `Proxy/Offline-Fallback: Synchronisation über HTTP aktiv${wsDisconnectReason ? ` (${wsDisconnectReason})` : ''}`
|
|
}
|
|
>
|
|
<Radio className={`w-3.5 h-3.5 ${wsConnected ? 'animate-pulse' : 'opacity-80'}`} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mobile Compact Header Bar (Live-Sync Status, Title, Drawer Switcher) */}
|
|
<div className="flex sm:hidden items-center justify-between gap-2 px-3 py-2 rounded-btn bg-panel border border-border">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowMobileFileDrawer(true)}
|
|
className="flex items-center gap-1.5 text-sm font-semibold text-main truncate hover:text-accent transition-colors cursor-pointer"
|
|
>
|
|
<FolderTree className="w-4 h-4 text-accent flex-shrink-0" />
|
|
<span className="truncate">{selectedDoc ? selectedDoc.title || 'Unbenannte Notiz' : 'Dateibaum & Ordner'}</span>
|
|
</button>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
|
{selectedDoc && (
|
|
<button
|
|
type="button"
|
|
onClick={openShareModal}
|
|
className="btn-icon min-h-[36px] min-w-[36px] flex items-center justify-center"
|
|
title="Notiz teilen"
|
|
>
|
|
<Users className="w-4 h-4 text-accent" />
|
|
</button>
|
|
)}
|
|
<div
|
|
className={`h-9 w-9 flex items-center justify-center rounded-btn border transition-colors ${
|
|
wsConnected
|
|
? realtimeTransport === 'sse'
|
|
? 'bg-teal-500/10 border-teal-500/30 text-teal-400'
|
|
: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-500'
|
|
: 'bg-amber-500/10 border-amber-500/30 text-amber-500'
|
|
}`}
|
|
title={
|
|
wsConnected
|
|
? realtimeTransport === 'sse'
|
|
? 'Live synchronisiert (Server-Sent Events / Proxy-Stream)'
|
|
: 'Live synchronisiert (WebSocket online)'
|
|
: `Proxy/Offline-Fallback: Synchronisation über HTTP aktiv${wsDisconnectReason ? ` (${wsDisconnectReason})` : ''}`
|
|
}
|
|
>
|
|
<Radio className={`w-3.5 h-3.5 ${wsConnected ? 'animate-pulse' : 'opacity-80'}`} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ===================================================================== */}
|
|
{/* VIEW MODE 1: LOGSEQ OUTLINER & EXPLORER */}
|
|
{/* ===================================================================== */}
|
|
{workspaceMode === 'editor' && (
|
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-2">
|
|
{/* Mobile Centered Floating Explorer Dialog ("in der Mitte schweben") */}
|
|
{showMobileFileDrawer && (
|
|
<div
|
|
className="fixed inset-0 !m-0 z-50 flex items-center justify-center p-3.5 sm:p-4 bg-black/60 backdrop-blur-xs lg:hidden animate-in fade-in duration-150"
|
|
onClick={() => setShowMobileFileDrawer(false)}
|
|
>
|
|
<div
|
|
className="w-full max-w-md max-h-[85vh] bg-panel border border-border rounded-2xl shadow-panel flex flex-col overflow-hidden animate-in zoom-in-95 duration-150"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Mobile Dialog Header */}
|
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-panel flex-shrink-0">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-7 h-7 rounded-btn bg-accent/15 text-accent flex items-center justify-center">
|
|
<FolderTree className="w-4 h-4" />
|
|
</div>
|
|
<span className="text-sm font-semibold text-main">Dateibaum & Ordner</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowMobileFileDrawer(false)}
|
|
className="p-1.5 text-faint hover:text-main rounded-btn transition-colors cursor-pointer"
|
|
title="Schließen"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto flex flex-col">
|
|
{renderExplorerBody()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Desktop Left Document & Folder Explorer Tree (Sidebar) */}
|
|
<div className="hidden lg:flex lg:col-span-4 panel-surface flex-col self-start max-h-screen lg:max-h-none">
|
|
{renderExplorerBody()}
|
|
</div>
|
|
{/* Right Logseq Outliner Editor */}
|
|
<div className="lg:col-span-8">
|
|
{selectedDoc ? (
|
|
<div
|
|
className="p-4 panel-surface space-y-3 min-h-[500px]"
|
|
onClick={(e) => {
|
|
if (e.target === e.currentTarget) {
|
|
handleCanvasBottomClick();
|
|
}
|
|
}}
|
|
>
|
|
{/* Document Header & Metadata */}
|
|
<div className="flex flex-wrap items-center justify-between gap-3 pb-3 panel-header">
|
|
<div className="flex flex-wrap items-center gap-2 text-xs text-muted">
|
|
<div className="flex items-center gap-1.5">
|
|
<Folder className="w-3.5 h-3.5 text-faint" />
|
|
<span>{selectedDoc.folderPath || 'Hauptverzeichnis'}</span>
|
|
</div>
|
|
|
|
<span>• {selectedDoc.blocks.length} Bullets</span>
|
|
|
|
{selectedDoc.isOwner === false && (
|
|
<button
|
|
type="button"
|
|
onClick={openShareModal}
|
|
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 {selectedDoc.ownerName || 'Eigentümer'}</span>
|
|
</button>
|
|
)}
|
|
|
|
{selectedDoc.permission === 'read' && (
|
|
<button
|
|
type="button"
|
|
onClick={openShareModal}
|
|
className="px-2 py-0.5 rounded text-[10px] font-semibold border border-amber-500/30 bg-amber-500/15 text-amber-600 dark:text-amber-400 flex items-center gap-1 hover:bg-amber-500/25 transition-colors cursor-pointer"
|
|
title="Berechtigungsdetails öffnen"
|
|
>
|
|
<Lock className="w-2.5 h-2.5" />
|
|
<span>Nur Lesezugriff</span>
|
|
</button>
|
|
)}
|
|
|
|
{activePeers.length > 0 ? (
|
|
<span
|
|
className="px-2 py-0.5 rounded text-[10px] font-semibold border border-emerald-500/30 bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 flex items-center gap-1.5 animate-pulse"
|
|
title={`Live-Kollaboration via CRDT aktiv: ${activePeers.map((c) => c.userName).join(', ')}`}
|
|
>
|
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-ping" />
|
|
<Users className="w-2.5 h-2.5" />
|
|
<span>Live ({activePeers.length})</span>
|
|
</span>
|
|
) : holdsLock ? (
|
|
<span className="px-2 py-0.5 rounded text-[10px] font-semibold border border-emerald-500/30 bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 flex items-center gap-1">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></span>
|
|
<span>CRDT Sync aktiv</span>
|
|
</span>
|
|
) : null}
|
|
|
|
{selectedDoc.sharedWith && selectedDoc.sharedWith.length > 0 && selectedDoc.isOwner !== false && (
|
|
<button
|
|
type="button"
|
|
onClick={openShareModal}
|
|
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 anzeigen & verwalten"
|
|
>
|
|
<Users className="w-2.5 h-2.5" />
|
|
<span>Geteilt ({selectedDoc.sharedWith.length})</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={openShareModal}
|
|
className="btn-icon"
|
|
title="Notiz teilen und Berechtigungen"
|
|
>
|
|
<Users className="w-3.5 h-3.5" />
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
persistDocument({ ...selectedDoc, isPinned: !selectedDoc.isPinned }, true)
|
|
}
|
|
className={
|
|
selectedDoc.isPinned
|
|
? 'btn-icon text-accent border-accent/40 bg-accent/10'
|
|
: 'btn-icon'
|
|
}
|
|
title={selectedDoc.isPinned ? 'Von Favoriten / Angepinnt entfernen' : 'Oben in Favoriten anpinnen'}
|
|
>
|
|
<Pin className="w-3.5 h-3.5" />
|
|
</button>
|
|
|
|
{selectedDoc.isOwner !== false ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => handleDeleteDoc(selectedDoc.id)}
|
|
className="btn-icon btn-icon-danger"
|
|
title="Dokument löschen"
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={handleLeaveShare}
|
|
className="btn-icon text-muted hover:text-status-danger"
|
|
title="Geteilte Notiz verlassen"
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
{selectedDoc.permission === 'read' && (
|
|
<div className="p-2.5 bg-blue-500/10 border border-blue-500/30 text-blue-700 dark:text-blue-300 rounded-card flex items-center gap-2 text-xs">
|
|
<Eye className="w-4 h-4 text-blue-500 flex-shrink-0" />
|
|
<span>Du hast <strong>Nur-Lesezugriff</strong> auf diese Notiz. Änderungen können nicht vorgenommen werden.</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* ZOOM BREADCRUMB BAR (Hoisting Focus Mode) */}
|
|
{zoomedBlock && (
|
|
<div className="p-2.5 bg-canvas border border-border rounded-card flex items-center gap-2 text-xs">
|
|
<div className="flex flex-wrap items-center gap-1.5 min-w-0">
|
|
<button
|
|
type="button"
|
|
onClick={() => handleZoomTo(null)}
|
|
className="text-muted hover:text-main flex items-center gap-1 font-medium transition-colors"
|
|
>
|
|
<span>{selectedDoc.icon || '📄'}</span>
|
|
<span className="truncate">{selectedDoc.title}</span>
|
|
</button>
|
|
|
|
{breadcrumbBlocks.map((b) => (
|
|
<React.Fragment key={b.id}>
|
|
<ChevronRight className="w-3 h-3 text-faint flex-shrink-0" />
|
|
<button
|
|
type="button"
|
|
onClick={() => handleZoomTo(b.id)}
|
|
className="text-muted hover:text-main truncate max-w-[140px] font-medium transition-colors"
|
|
>
|
|
{b.content || 'Bullet'}
|
|
</button>
|
|
</React.Fragment>
|
|
))}
|
|
|
|
<ChevronRight className="w-3 h-3 text-faint flex-shrink-0" />
|
|
<span className="text-muted font-medium truncate max-w-[180px]">
|
|
{zoomedBlock.content || 'Aktiver Ast'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Document Icon & Title (when not zoomed) */}
|
|
{!zoomedBlock && (
|
|
<div className="flex items-center gap-3 relative pb-2 border-b border-border">
|
|
<button
|
|
type="button"
|
|
disabled={isReadOnly}
|
|
onClick={() => !isReadOnly && setShowIconPicker((prev) => !prev)}
|
|
className={`text-2xl p-0.5 flex items-center justify-center flex-shrink-0 transition-transform ${
|
|
isReadOnly ? 'opacity-60 cursor-not-allowed' : 'hover:scale-110 cursor-pointer'
|
|
}`}
|
|
title={isReadOnly ? 'Schreibgeschützt' : 'Icon ändern'}
|
|
>
|
|
{selectedDoc.icon || '📄'}
|
|
</button>
|
|
|
|
{showIconPicker && !isReadOnly && (
|
|
<div className="absolute top-10 left-0 z-20 p-2.5 panel-surface flex flex-wrap gap-1 w-64 shadow-panel border border-border rounded-card">
|
|
{ICONS.map((ic) => (
|
|
<button
|
|
key={ic}
|
|
type="button"
|
|
onClick={() => {
|
|
persistDocument({ ...selectedDoc, icon: ic }, true);
|
|
setShowIconPicker(false);
|
|
}}
|
|
className="w-8 h-8 hover:bg-panel-elevated text-base flex items-center justify-center rounded-btn transition-colors"
|
|
>
|
|
{ic}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<textarea
|
|
rows={1}
|
|
ref={(el) => {
|
|
if (el) autoResizeTextarea(el);
|
|
}}
|
|
readOnly={isReadOnly}
|
|
value={selectedDoc.title}
|
|
onFocus={handleBlockFocus}
|
|
onBlur={handleBlockBlur}
|
|
onChange={(e) => {
|
|
if (!isReadOnly) {
|
|
ensureLock();
|
|
autoResizeTextarea(e.target);
|
|
persistDocument({ ...selectedDoc, title: e.target.value });
|
|
}
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
if (selectedDoc.blocks.length > 0) {
|
|
const firstId = selectedDoc.blocks[0].id;
|
|
blockInputRefs.current[firstId]?.focus();
|
|
} else if (!isReadOnly) {
|
|
insertNewBulletAfter(-1, 0);
|
|
}
|
|
}
|
|
}}
|
|
placeholder="Dokument-Titel…"
|
|
className={`w-full bg-transparent text-xl sm:text-2xl font-bold text-main focus:outline-none resize-none overflow-hidden break-words whitespace-pre-wrap placeholder:text-faint p-0 m-0 border-0 ${
|
|
isReadOnly ? 'cursor-default' : ''
|
|
}`}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* If zoomed, show zoomed block as editable prominent header */}
|
|
{zoomedBlock && (
|
|
<div className="pb-2 border-b border-border flex items-center gap-2.5">
|
|
<span className="w-2.5 h-2.5 rounded-pill bg-accent flex-shrink-0" />
|
|
<textarea
|
|
rows={1}
|
|
ref={(el) => {
|
|
if (el) autoResizeTextarea(el);
|
|
}}
|
|
readOnly={isReadOnly}
|
|
value={zoomedBlock.content}
|
|
onFocus={handleBlockFocus}
|
|
onBlur={handleBlockBlur}
|
|
onChange={(e) => {
|
|
if (!isReadOnly) {
|
|
ensureLock();
|
|
autoResizeTextarea(e.target);
|
|
updateBlock(zoomedBlock.id, { content: e.target.value });
|
|
}
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' && !isReadOnly) {
|
|
e.preventDefault();
|
|
if (
|
|
visibleBlocks.length > 0 &&
|
|
(!visibleBlocks[0].block.content || visibleBlocks[0].block.content.trim() === '')
|
|
) {
|
|
focusBlock(visibleBlocks[0].block.id);
|
|
return;
|
|
}
|
|
const zoomIdx = selectedDoc.blocks.findIndex((b) => b.id === zoomedBlock.id);
|
|
if (zoomIdx !== -1) {
|
|
insertNewBulletAfter(zoomIdx, (zoomedBlock.level || 0) + 1);
|
|
}
|
|
}
|
|
}}
|
|
placeholder="Aufzählungspunkt…"
|
|
className={`w-full bg-transparent text-xl font-bold text-main focus:outline-none resize-none overflow-hidden break-words whitespace-pre-wrap placeholder:text-faint p-0 m-0 border-0 ${
|
|
isReadOnly ? 'cursor-default' : ''
|
|
}`}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* =========================================================== */}
|
|
{/* LOGSEQ HIERARCHICAL BULLET LIST CANVAS */}
|
|
{/* =========================================================== */}
|
|
<div
|
|
className="space-y-1 pt-2 min-h-[500px] pb-16 flex flex-col cursor-text"
|
|
onClick={(e) => {
|
|
if (e.target === e.currentTarget) {
|
|
handleCanvasBottomClick();
|
|
}
|
|
}}
|
|
>
|
|
{zoomedBlock && visibleBlocks.length === 0 && !isReadOnly && (
|
|
<div
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleCanvasBottomClick();
|
|
}}
|
|
className="group flex items-center gap-1 py-1 cursor-text text-faint hover:text-muted transition-colors"
|
|
>
|
|
<div className="w-3 flex-shrink-0" />
|
|
<div className="w-4 flex-shrink-0" />
|
|
<div className="w-4 h-4 rounded-pill flex items-center justify-center flex-shrink-0">
|
|
<span className="w-1.5 h-1.5 rounded-pill bg-muted group-hover:bg-accent transition-colors" />
|
|
</div>
|
|
<span className="text-sm pl-0.5 select-none">
|
|
Klicken, um einen Unterpunkt zu erstellen…
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{visibleBlocks.map(({ block, originalIndex }) => {
|
|
const blockLevel = Math.max(0, (block.level || 0) - (zoomedBlock ? (zoomedBlock.level || 0) + 1 : 0));
|
|
const isChildrenPresent = hasChildren(selectedDoc.blocks, originalIndex);
|
|
const isDraggingThis = draggingBlockId === block.id;
|
|
const isTargetThis = dragOverBlockTarget?.blockId === block.id;
|
|
|
|
return (
|
|
<div
|
|
key={block.id}
|
|
onDragOver={(e) => !isReadOnly && handleBlockDragOver(e, block.id)}
|
|
onDragLeave={(e) => !isReadOnly && handleBlockDragLeave(e, block.id)}
|
|
onDrop={(e) => !isReadOnly && handleBlockDrop(e, block.id)}
|
|
style={{ paddingLeft: `${blockLevel * 20}px` }}
|
|
className={`group flex items-start gap-0.5 py-0.5 relative transition-all rounded ${
|
|
isDraggingThis ? 'opacity-30 border border-dashed border-accent/40' : ''
|
|
} ${
|
|
isTargetThis && dragOverBlockTarget.position === 'inside'
|
|
? 'ring-2 ring-accent/60 bg-accent/10 shadow-sm'
|
|
: ''
|
|
}`}
|
|
>
|
|
{/* Drop indicator lines */}
|
|
{isTargetThis && dragOverBlockTarget.position === 'before' && (
|
|
<div className="absolute top-0 left-0 right-0 h-0.5 bg-accent z-20 pointer-events-none rounded-full shadow-[0_0_8px_rgba(var(--accent-rgb),0.6)]" />
|
|
)}
|
|
{isTargetThis && dragOverBlockTarget.position === 'after' && (
|
|
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-accent z-20 pointer-events-none rounded-full shadow-[0_0_8px_rgba(var(--accent-rgb),0.6)]" />
|
|
)}
|
|
{isTargetThis && dragOverBlockTarget.position === 'inside' && (
|
|
<div className="absolute left-0 top-0 bottom-0 w-1 bg-accent rounded-l z-20 pointer-events-none" />
|
|
)}
|
|
|
|
{/* Drag Handle on hover - tightened spacing to the bullet */}
|
|
<div
|
|
draggable={!isReadOnly}
|
|
onMouseDown={() => {
|
|
if (!isReadOnly) {
|
|
if (idleUnlockTimerRef.current) {
|
|
clearTimeout(idleUnlockTimerRef.current);
|
|
idleUnlockTimerRef.current = null;
|
|
}
|
|
ensureLock(true);
|
|
}
|
|
}}
|
|
onDragStart={(e) => !isReadOnly && handleBlockDragStart(e, block.id)}
|
|
onDragEnd={handleBlockDragEnd}
|
|
className="opacity-0 group-hover:opacity-100 flex items-center pt-1 text-faint hover:text-accent cursor-grab active:cursor-grabbing flex-shrink-0 -ml-1 mr-0.5"
|
|
title="Ziehen zum Verschieben"
|
|
>
|
|
<GripVertical className="w-3 h-3" />
|
|
</div>
|
|
|
|
{/* Collapse Chevron if block has children */}
|
|
{isChildrenPresent ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleCollapse(block.id)}
|
|
className="w-3.5 h-3.5 mt-0.5 flex items-center justify-center text-muted hover:text-main flex-shrink-0"
|
|
title={block.collapsed ? 'Unterpunkte aufklappen' : 'Unterpunkte einklappen'}
|
|
>
|
|
{block.collapsed ? (
|
|
<ChevronRight className="w-2.5 h-2.5 text-accent" />
|
|
) : (
|
|
<ChevronDown className="w-2.5 h-2.5" />
|
|
)}
|
|
</button>
|
|
) : (
|
|
<div className="w-1.5 flex-shrink-0" />
|
|
)}
|
|
|
|
{/* Interactive Bullet Dot (Click: Zoom-in! Drag: Reorder! Drop on it: Nest as child!) */}
|
|
<div
|
|
draggable={!isReadOnly}
|
|
onMouseDown={() => {
|
|
if (!isReadOnly) {
|
|
if (idleUnlockTimerRef.current) {
|
|
clearTimeout(idleUnlockTimerRef.current);
|
|
idleUnlockTimerRef.current = null;
|
|
}
|
|
ensureLock(true);
|
|
}
|
|
}}
|
|
onDragStart={(e) => !isReadOnly && handleBlockDragStart(e, block.id)}
|
|
onDragEnd={handleBlockDragEnd}
|
|
onDragOver={(e) => {
|
|
if (!isReadOnly && draggingBlockId && draggingBlockId !== block.id) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setDragOverBlockTarget({ blockId: block.id, position: 'inside' });
|
|
}
|
|
}}
|
|
onDrop={(e) => {
|
|
if (!isReadOnly) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
handleBlockDrop(e, block.id, 'inside');
|
|
}
|
|
}}
|
|
onClick={() => handleZoomTo(block.id)}
|
|
className={`w-4 h-4 mt-1 rounded-pill flex items-center justify-center transition-colors flex-shrink-0 ${
|
|
isReadOnly
|
|
? 'cursor-pointer text-muted'
|
|
: 'hover:bg-accent/20 cursor-grab active:cursor-grabbing text-muted hover:text-accent'
|
|
} ${
|
|
isTargetThis && dragOverBlockTarget.position === 'inside'
|
|
? 'scale-125 bg-accent/30 ring-2 ring-accent'
|
|
: ''
|
|
}`}
|
|
title="Klick: Zoomen · Ziehen: Verschieben · Hierher ziehen: Als Unterpunkt einordnen"
|
|
>
|
|
<span
|
|
className={`w-1.5 h-1.5 rounded-pill transition-colors ${
|
|
isChildrenPresent
|
|
? 'bg-accent ring-2 ring-accent/30'
|
|
: 'bg-muted group-hover:bg-accent'
|
|
}`}
|
|
/>
|
|
</div>
|
|
|
|
{/* Block Content Container */}
|
|
<div className="flex-1 min-w-0 pt-0.5 relative">
|
|
{/* Live Collaborator Presence Indicator */}
|
|
{activePeers.filter((c) => c.activeBlockId === block.id).map((c) => (
|
|
<div
|
|
key={c.userId}
|
|
className="absolute -top-3.5 right-1 text-[10px] px-1.5 py-0.5 rounded font-semibold text-white shadow-xs pointer-events-none z-10 flex items-center gap-1 animate-fadeIn"
|
|
style={{ backgroundColor: c.color }}
|
|
>
|
|
<span className="w-1.5 h-1.5 rounded-full bg-white animate-ping" />
|
|
<span>{c.userName}</span>
|
|
</div>
|
|
))}
|
|
|
|
{/* TYPE: TODO */}
|
|
{block.type === 'todo' && (
|
|
<div className="space-y-1">
|
|
<div className="flex items-start gap-2 group/todo">
|
|
<input
|
|
type="checkbox"
|
|
disabled={isReadOnly}
|
|
checked={!!block.checked}
|
|
onChange={(e) => {
|
|
if (!isReadOnly && selectedDoc) {
|
|
const nextBlocks = selectedDoc.blocks.map((b) =>
|
|
b.id === block.id ? { ...b, checked: e.target.checked } : b
|
|
);
|
|
persistDocument({ ...selectedDoc, blocks: nextBlocks }, true);
|
|
onStatsChange();
|
|
}
|
|
}}
|
|
className={`w-4 h-4 mt-1 checkbox-square flex-shrink-0 ${
|
|
isReadOnly ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'
|
|
}`}
|
|
/>
|
|
{renderLiveInlineBlock(
|
|
block,
|
|
originalIndex,
|
|
block.checked ? 'line-through text-faint' : 'text-main'
|
|
)}
|
|
{!isReadOnly && (
|
|
<button
|
|
type="button"
|
|
onClick={() => insertNewBulletAfter(originalIndex, (block.level || 0) + 1, 'todo')}
|
|
className="opacity-0 group-hover/todo:opacity-100 p-0.5 text-muted hover:text-accent rounded transition-opacity cursor-pointer flex-shrink-0"
|
|
title="Unteraufgabe erstellen"
|
|
>
|
|
<Plus className="w-3.5 h-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Progress bar for child todos if any exist */}
|
|
{(() => {
|
|
const childTodos = getDirectChildTodos(selectedDoc?.blocks || [], originalIndex);
|
|
if (childTodos.length === 0) return null;
|
|
const doneChild = childTodos.filter((c) => c.checked).length;
|
|
const totalChild = childTodos.length;
|
|
const pct = Math.round((doneChild / totalChild) * 100);
|
|
return (
|
|
<div
|
|
className="space-y-0.5 ml-6 pt-0.5 select-none"
|
|
title={`${doneChild} von ${totalChild} Unteraufgaben erledigt (${pct}%)`}
|
|
>
|
|
<div className="flex items-center justify-between text-[10px] text-muted">
|
|
<span>
|
|
{doneChild}/{totalChild} erledigt
|
|
</span>
|
|
<span className={pct === 100 ? 'text-status-ok font-bold' : ''}>
|
|
{pct}%
|
|
</span>
|
|
</div>
|
|
<div className="w-full h-1 bg-border rounded-full overflow-hidden">
|
|
<div
|
|
className={`h-full transition-all duration-300 ${
|
|
pct === 100 ? 'bg-status-ok' : 'bg-accent'
|
|
}`}
|
|
style={{ width: `${pct}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
)}
|
|
|
|
{/* TYPE: H1 */}
|
|
{block.type === 'h1' && (
|
|
<textarea
|
|
ref={(el) => {
|
|
blockInputRefs.current[block.id] = el;
|
|
if (el) autoResizeTextarea(el);
|
|
}}
|
|
rows={1}
|
|
readOnly={isReadOnly}
|
|
value={block.content}
|
|
onFocus={() => handleBlockFocus(block.id)}
|
|
onBlur={handleBlockBlur}
|
|
onChange={(e) => {
|
|
autoResizeTextarea(e.target);
|
|
const pos = e.target.selectionStart;
|
|
if (typeof pos === 'number') activeCursorOffsetRef.current = pos;
|
|
handleContentChange(block, e.target.value);
|
|
}}
|
|
onSelect={(e) => {
|
|
const target = e.target as HTMLTextAreaElement;
|
|
if (typeof target.selectionStart === 'number') activeCursorOffsetRef.current = target.selectionStart;
|
|
}}
|
|
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
|
|
className={`w-full bg-transparent text-lg font-bold text-main focus:outline-none resize-none overflow-hidden break-words whitespace-pre-wrap p-0 m-0 border-0 ${
|
|
isReadOnly ? 'cursor-default' : ''
|
|
}`}
|
|
/>
|
|
)}
|
|
|
|
{/* TYPE: H2 */}
|
|
{block.type === 'h2' && (
|
|
<textarea
|
|
ref={(el) => {
|
|
blockInputRefs.current[block.id] = el;
|
|
if (el) autoResizeTextarea(el);
|
|
}}
|
|
rows={1}
|
|
readOnly={isReadOnly}
|
|
value={block.content}
|
|
onFocus={() => handleBlockFocus(block.id)}
|
|
onBlur={handleBlockBlur}
|
|
onChange={(e) => {
|
|
autoResizeTextarea(e.target);
|
|
const pos = e.target.selectionStart;
|
|
if (typeof pos === 'number') activeCursorOffsetRef.current = pos;
|
|
handleContentChange(block, e.target.value);
|
|
}}
|
|
onSelect={(e) => {
|
|
const target = e.target as HTMLTextAreaElement;
|
|
if (typeof target.selectionStart === 'number') activeCursorOffsetRef.current = target.selectionStart;
|
|
}}
|
|
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
|
|
className={`w-full bg-transparent text-base font-semibold text-accent focus:outline-none resize-none overflow-hidden break-words whitespace-pre-wrap p-0 m-0 border-0 ${
|
|
isReadOnly ? 'cursor-default' : ''
|
|
}`}
|
|
/>
|
|
)}
|
|
|
|
{/* TYPE: H3 */}
|
|
{block.type === 'h3' && (
|
|
<textarea
|
|
ref={(el) => {
|
|
blockInputRefs.current[block.id] = el;
|
|
if (el) autoResizeTextarea(el);
|
|
}}
|
|
rows={1}
|
|
readOnly={isReadOnly}
|
|
value={block.content}
|
|
onFocus={() => handleBlockFocus(block.id)}
|
|
onBlur={handleBlockBlur}
|
|
onChange={(e) => {
|
|
autoResizeTextarea(e.target);
|
|
const pos = e.target.selectionStart;
|
|
if (typeof pos === 'number') activeCursorOffsetRef.current = pos;
|
|
handleContentChange(block, e.target.value);
|
|
}}
|
|
onSelect={(e) => {
|
|
const target = e.target as HTMLTextAreaElement;
|
|
if (typeof target.selectionStart === 'number') activeCursorOffsetRef.current = target.selectionStart;
|
|
}}
|
|
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
|
|
className={`w-full bg-transparent text-sm font-semibold text-main focus:outline-none resize-none overflow-hidden break-words whitespace-pre-wrap p-0 m-0 border-0 ${
|
|
isReadOnly ? 'cursor-default' : ''
|
|
}`}
|
|
/>
|
|
)}
|
|
|
|
{/* TYPE: CALLOUT */}
|
|
{block.type === 'callout' && (
|
|
<div className="p-2 bg-canvas border border-accent/40 rounded-card flex items-start gap-2">
|
|
<Sparkles className="w-3.5 h-3.5 text-accent flex-shrink-0 mt-0.5" />
|
|
<textarea
|
|
ref={(el) => (blockInputRefs.current[block.id] = el)}
|
|
readOnly={isReadOnly}
|
|
rows={Math.max(1, (block.content || '').split('\n').length, Math.ceil((block.content || '').length / 70))}
|
|
value={block.content}
|
|
onFocus={handleBlockFocus}
|
|
onBlur={handleBlockBlur}
|
|
onChange={(e) => handleContentChange(block, e.target.value)}
|
|
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
|
|
className={`w-full bg-transparent text-xs text-main focus:outline-none resize-none ${
|
|
isReadOnly ? 'cursor-default' : ''
|
|
}`}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* TYPE: CODE */}
|
|
{block.type === 'code' && (
|
|
<CodeBlockItem
|
|
block={block}
|
|
isReadOnly={isReadOnly}
|
|
inputRef={(el) => (blockInputRefs.current[block.id] = el)}
|
|
onFocus={handleBlockFocus}
|
|
onBlur={handleBlockBlur}
|
|
onChangeContent={(content) => handleContentChange(block, content)}
|
|
onChangeLang={(lang) => {
|
|
if (!isReadOnly) {
|
|
ensureLock();
|
|
updateBlock(block.id, { lang });
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* TYPE: TABLE */}
|
|
{block.type === 'table' && (
|
|
<div className="p-2 bg-canvas border border-border rounded-card space-y-1">
|
|
<div className="flex items-center gap-1 text-[11px] text-muted font-semibold">
|
|
<TableIcon className="w-3 h-3 text-accent" /> Markdown Tabelle
|
|
</div>
|
|
<textarea
|
|
ref={(el) => (blockInputRefs.current[block.id] = el)}
|
|
readOnly={isReadOnly}
|
|
rows={Math.max(3, (block.content || '').split('\n').length)}
|
|
value={block.content}
|
|
onFocus={handleBlockFocus}
|
|
onBlur={handleBlockBlur}
|
|
onChange={(e) => handleContentChange(block, e.target.value)}
|
|
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
|
|
className={`w-full bg-transparent font-mono text-xs text-main focus:outline-none resize-none ${
|
|
isReadOnly ? 'cursor-default' : ''
|
|
}`}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* TYPE: PROGRESS */}
|
|
{block.type === 'progress' && (
|
|
<div className="p-2 bg-canvas border border-border rounded-card space-y-1.5">
|
|
<div className="flex items-center justify-between text-xs">
|
|
<span className="text-main font-medium flex items-center gap-1">
|
|
<Percent className="w-3 h-3 text-accent" /> Fortschritt
|
|
</span>
|
|
<span className="font-mono text-accent font-semibold">
|
|
{parseInt(block.content) || 0}%
|
|
</span>
|
|
</div>
|
|
<div className="w-full bg-panel h-2 overflow-hidden border border-border rounded-pill">
|
|
<div
|
|
className="bg-accent h-full transition-all rounded-pill"
|
|
style={{
|
|
width: `${Math.min(100, Math.max(0, parseInt(block.content) || 0))}%`,
|
|
}}
|
|
/>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
min="0"
|
|
max="100"
|
|
disabled={isReadOnly}
|
|
value={parseInt(block.content) || 0}
|
|
onChange={(e) => !isReadOnly && updateBlock(block.id, { content: e.target.value })}
|
|
className={`w-full accent-accent ${isReadOnly ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* TYPE: DIVIDER */}
|
|
{block.type === 'divider' && (
|
|
<div
|
|
tabIndex={isReadOnly ? undefined : 0}
|
|
onKeyDown={(e) => {
|
|
if (!isReadOnly && e.key === 'Enter') {
|
|
e.preventDefault();
|
|
insertNewBulletAfter(originalIndex, block.level || 0);
|
|
} else if (!isReadOnly && (e.key === 'Backspace' || e.key === 'Delete')) {
|
|
e.preventDefault();
|
|
deleteSingleBlock(block.id, true);
|
|
}
|
|
}}
|
|
onClick={() => {
|
|
if (!isReadOnly) {
|
|
insertNewBulletAfter(originalIndex, block.level || 0);
|
|
}
|
|
}}
|
|
className="py-2.5 my-1 group/divider relative cursor-pointer focus:outline-none"
|
|
title={isReadOnly ? undefined : "Klicken oder Enter drücken, um darunter einen neuen Aufzählungspunkt einzufügen"}
|
|
>
|
|
<div className="h-0.5 w-full bg-border group-hover/divider:bg-accent/70 group-focus/divider:bg-accent transition-colors rounded-full" />
|
|
{!isReadOnly && (
|
|
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover/divider:opacity-100 transition-opacity pointer-events-none">
|
|
<span className="bg-panel border border-border text-[10px] text-muted px-2 py-0.5 rounded-full shadow-xs flex items-center gap-1 font-medium">
|
|
<Plus className="w-3 h-3 text-accent" />
|
|
<span>Punkt einfügen</span>
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* TYPE: DEFAULT BULLET / PARAGRAPH */}
|
|
{(block.type === 'bullet' || block.type === 'paragraph') && (
|
|
<div className="space-y-0.5">
|
|
{renderLiveInlineBlock(block, originalIndex, 'text-main')}
|
|
</div>
|
|
)}
|
|
|
|
{/* FLOATING SLASH COMMAND MENU */}
|
|
{!isReadOnly && slashBlockId === block.id && (
|
|
<div className="absolute top-7 left-0 z-40 w-64 bg-panel border border-border shadow-panel rounded-card p-1 space-y-0.5">
|
|
<div className="px-2 py-1 text-[10px] text-faint font-semibold uppercase tracking-wider border-b border-border">
|
|
Block-Typ wählen:
|
|
</div>
|
|
<div ref={slashMenuRef} className="relative max-h-48 overflow-y-auto space-y-0.5">
|
|
{filteredSlashCommands.map((cmd, idx) => (
|
|
<button
|
|
key={cmd.label || idx}
|
|
type="button"
|
|
data-slash-index={idx}
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={() => applySlashCommand(block.id, cmd)}
|
|
className={`w-full text-left px-2 py-1.5 rounded-btn text-xs flex items-center justify-between transition-colors ${
|
|
slashSelectedIndex === idx
|
|
? 'bg-accent text-on-accent'
|
|
: 'text-main hover:bg-panel-elevated'
|
|
}`}
|
|
>
|
|
<span className="font-semibold">{cmd.label}</span>
|
|
<span className="text-[10px] opacity-80">{cmd.desc}</span>
|
|
</button>
|
|
))}
|
|
{filteredSlashCommands.length === 0 && (
|
|
<div className="p-2 text-center text-xs text-faint">
|
|
Kein Befehl gefunden
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Quick Delete Block on Hover */}
|
|
{!isReadOnly && (
|
|
<button
|
|
type="button"
|
|
onClick={() => handleDeleteButtonClick(block.id)}
|
|
className="opacity-0 group-hover:opacity-100 p-0.5 text-faint hover:text-status-err rounded transition-opacity cursor-pointer"
|
|
title="Bullet löschen"
|
|
>
|
|
<X className="w-3 h-3" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{/* Empty area below blocks to click and seamlessly focus or create bullet */}
|
|
{!isReadOnly && (
|
|
<div
|
|
className="flex-1 min-h-[250px] cursor-text"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleCanvasBottomClick();
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="p-8 panel-surface text-center space-y-3">
|
|
<FolderTree className="w-8 h-8 text-accent/60 mx-auto" />
|
|
<p className="text-xs text-muted">Keine Notiz geöffnet.</p>
|
|
<div className="flex flex-wrap items-center justify-center gap-2 pt-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setWorkspaceMode('editor');
|
|
setShowMobileFileDrawer(true);
|
|
}}
|
|
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 cursor-pointer"
|
|
>
|
|
<FolderTree className="w-3.5 h-3.5 text-accent" />
|
|
<span>Dateibaum öffnen</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleCreateDoc(null)}
|
|
className="btn-primary h-8 px-3 text-xs flex items-center gap-1.5 cursor-pointer"
|
|
>
|
|
<FilePlus className="w-3.5 h-3.5" />
|
|
<span>Neue Notiz anlegen</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ===================================================================== */}
|
|
{/* VIEW MODE 2: GLOBAL WORKSPACE TO-DO ROLLUP DASHBOARD */}
|
|
{/* ===================================================================== */}
|
|
{workspaceMode === 'todos' && (
|
|
<div className="p-4 panel-surface space-y-4">
|
|
{/* Header & Filter Controls */}
|
|
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border pb-3">
|
|
<div>
|
|
<h2 className="text-sm font-bold text-main flex items-center gap-2">
|
|
<CheckSquare className="w-4 h-4 text-accent" />
|
|
<span>Globale To-Dos</span>
|
|
</h2>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex border border-border rounded-btn bg-canvas overflow-hidden text-xs">
|
|
<button
|
|
type="button"
|
|
onClick={() => setTodoFilter('open')}
|
|
className={`px-3 py-1.5 transition-colors border-r border-border ${
|
|
todoFilter === 'open' ? 'bg-panel text-main font-medium' : 'text-muted hover:text-main'
|
|
}`}
|
|
>
|
|
Nur Offene ({allWorkspaceTodos.filter((t) => !t.block.checked).length})
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setTodoFilter('all')}
|
|
className={`px-3 py-1.5 transition-colors border-r border-border ${
|
|
todoFilter === 'all' ? 'bg-panel text-main font-medium' : 'text-muted hover:text-main'
|
|
}`}
|
|
>
|
|
Alle ({allWorkspaceTodos.length})
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setTodoFilter('done')}
|
|
className={`px-3 py-1.5 transition-colors ${
|
|
todoFilter === 'done' ? 'bg-panel text-main font-medium' : 'text-muted hover:text-main'
|
|
}`}
|
|
>
|
|
Erledigt ({allWorkspaceTodos.filter((t) => t.block.checked).length})
|
|
</button>
|
|
</div>
|
|
|
|
{todoFolderDrilldown && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setTodoFolderDrilldown(null)}
|
|
className="btn-action text-xs"
|
|
>
|
|
← Alle Ordner
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Drilldown Active Notification Banner */}
|
|
{todoFolderDrilldown && (
|
|
<div className="p-2.5 bg-canvas border border-accent/40 rounded-card flex items-center justify-between text-xs">
|
|
<span className="flex items-center gap-1.5 text-main">
|
|
<Folder className="w-3.5 h-3.5 text-accent" />
|
|
<span>Gefiltert auf Ordner: <strong>{todoFolderDrilldown}</strong></span>
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => setTodoFolderDrilldown(null)}
|
|
className="text-accent hover:underline"
|
|
>
|
|
Filter entfernen
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Folders & Files Hierarchy Accordion */}
|
|
{todosByFolder.size === 0 ? (
|
|
<div className="p-8 text-center text-muted text-xs space-y-1">
|
|
<Sparkles className="w-6 h-6 text-accent mx-auto mb-1" />
|
|
<p className="font-semibold text-main">Keine To-Dos in Notizen gefunden!</p>
|
|
<p className="text-faint">
|
|
Tippe in einem beliebigen Dokument <code>/todo</code> oder <code>- [ ]</code> ein, um Aufgaben anzulegen.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{Array.from(todosByFolder.entries()).map(([folderName, items]) => {
|
|
const isCollapsed = collapsedTodoFolders.has(folderName);
|
|
const openCount = items.filter((i) => !i.block.checked).length;
|
|
|
|
// Group items within this folder by document
|
|
const docsInFolder = new Map<string, typeof items>();
|
|
items.forEach((item) => {
|
|
const arr = docsInFolder.get(item.docId) || [];
|
|
arr.push(item);
|
|
docsInFolder.set(item.docId, arr);
|
|
});
|
|
|
|
return (
|
|
<div key={folderName} className="border border-border rounded-card bg-canvas overflow-hidden">
|
|
{/* Folder Header Accordion */}
|
|
<div className="p-2.5 bg-panel-elevated border-b border-border flex items-center justify-between">
|
|
<div
|
|
onClick={() => {
|
|
setCollapsedTodoFolders((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(folderName)) next.delete(folderName);
|
|
else next.add(folderName);
|
|
return next;
|
|
});
|
|
}}
|
|
className="flex items-center gap-2 cursor-pointer select-none"
|
|
>
|
|
{isCollapsed ? (
|
|
<ChevronRight className="w-4 h-4 text-muted" />
|
|
) : (
|
|
<ChevronDown className="w-4 h-4 text-accent" />
|
|
)}
|
|
<Folder className="w-4 h-4 text-accent" />
|
|
<span className="font-bold text-xs text-main">{folderName}</span>
|
|
<span className="badge-neutral text-[10px] font-mono">
|
|
{openCount} offen
|
|
</span>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => setTodoFolderDrilldown(folderName)}
|
|
className="text-[11px] text-muted hover:text-main px-2 py-0.5 rounded hover:bg-canvas/50"
|
|
title="Diesen Ordner isolieren"
|
|
>
|
|
Nur diesen Ordner
|
|
</button>
|
|
</div>
|
|
|
|
{/* Folder Documents & Tasks */}
|
|
{!isCollapsed && (
|
|
<div className="p-3 space-y-3">
|
|
{Array.from(docsInFolder.entries()).map(([docId, docItems]) => {
|
|
const docInfo = docItems[0];
|
|
return (
|
|
<div
|
|
key={docId}
|
|
className="p-2.5 bg-panel border border-border rounded-card space-y-2"
|
|
>
|
|
{/* Document header in rollup */}
|
|
<div className="flex items-center justify-between border-b border-border pb-1.5">
|
|
<button
|
|
type="button"
|
|
onClick={() => handleJumpToDoc(docId)}
|
|
className="text-xs font-semibold text-accent hover:underline flex items-center gap-1.5"
|
|
title="Zur Notiz im Editor springen"
|
|
>
|
|
<span>{docInfo.docIcon}</span>
|
|
<span>{docInfo.docTitle}</span>
|
|
<ExternalLink className="w-3 h-3 opacity-60" />
|
|
</button>
|
|
<span className="text-[10px] text-faint font-mono">
|
|
{docItems.filter((i) => !i.block.checked).length} / {docItems.length}
|
|
</span>
|
|
</div>
|
|
|
|
{/* To-Do Items inside Document */}
|
|
<div className="space-y-1.5 pl-1">
|
|
{docItems.map(({ block }) => (
|
|
<div
|
|
key={block.id}
|
|
onClick={() => handleToggleRollupTodo(docId, block.id)}
|
|
className="flex items-center gap-2 cursor-pointer group py-0.5 select-none"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={!!block.checked}
|
|
onChange={() => handleToggleRollupTodo(docId, block.id)}
|
|
className="w-4 h-4 checkbox-square cursor-pointer flex-shrink-0"
|
|
/>
|
|
<span
|
|
className={`text-xs ${
|
|
block.checked
|
|
? 'line-through text-faint'
|
|
: 'text-main group-hover:text-accent'
|
|
} transition-colors`}
|
|
>
|
|
{block.content || '(Leere Aufgabe)'}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* ===================================================================== */}
|
|
{/* VIEW MODE 3: HASHTAG / THEMEN-SEITE (LOGSEQ STYLE) */}
|
|
{/* ===================================================================== */}
|
|
{workspaceMode === 'tags' && (
|
|
<div className="space-y-4">
|
|
{/* Header Toolbar */}
|
|
<div className="p-4 panel-surface space-y-3 border border-border rounded-card">
|
|
{selectedTag && (
|
|
<div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setSelectedTag(null)}
|
|
className="inline-flex items-center gap-1 text-[11px] font-medium text-muted hover:text-accent transition-colors cursor-pointer"
|
|
title="Zurück zur Themen-Übersicht"
|
|
>
|
|
<ArrowLeft className="w-3 h-3 text-accent" />
|
|
<span>Alle Themen</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="flex items-center gap-2.5">
|
|
{selectedTag ? (
|
|
<>
|
|
<div className="w-9 h-9 rounded-lg bg-blue-500/10 border border-blue-500/30 flex items-center justify-center text-blue-500 dark:text-blue-400 font-bold flex-shrink-0">
|
|
<Hash className="w-5 h-5" />
|
|
</div>
|
|
<div>
|
|
<h2 className="text-base font-bold text-main leading-tight flex items-center gap-1.5">
|
|
<span>#{selectedTag}</span>
|
|
</h2>
|
|
<p className="text-[11px] text-muted">
|
|
{isLoadingTag
|
|
? 'Wird geladen…'
|
|
: `${tagDetails?.totalMatches || 0} ${
|
|
tagDetails?.totalMatches === 1 ? 'Abschnitt' : 'Abschnitte'
|
|
} in ${tagDetails?.documents.length || 0} ${
|
|
tagDetails?.documents.length === 1 ? 'Notiz' : 'Notizen'
|
|
}`}
|
|
</p>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="w-9 h-9 rounded-lg bg-accent-dim border border-accent-border flex items-center justify-center text-accent flex-shrink-0">
|
|
<Hash className="w-5 h-5" />
|
|
</div>
|
|
<div>
|
|
<h2 className="text-base font-bold text-main leading-tight">Themen & Hashtags</h2>
|
|
<p className="text-[11px] text-muted">
|
|
{workspaceTags.length} {workspaceTags.length === 1 ? 'Thema' : 'Themen'} im gesamten Workspace
|
|
</p>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Search Bar for tags */}
|
|
<div className="relative flex-1 min-w-[200px] max-w-xs">
|
|
<Search className="w-3.5 h-3.5 text-muted absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" />
|
|
<input
|
|
type="text"
|
|
value={tagSearchQuery}
|
|
onChange={(e) => setTagSearchQuery(e.target.value)}
|
|
placeholder="Hashtags durchsuchen…"
|
|
className="w-full h-8 pl-8 pr-7 input-field text-xs rounded-btn"
|
|
/>
|
|
{tagSearchQuery && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setTagSearchQuery('')}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted hover:text-main"
|
|
title="Suche leeren"
|
|
>
|
|
<X className="w-3.5 h-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* If NO tag is selected, show the big overview grid! */}
|
|
{!selectedTag ? (
|
|
<div>
|
|
{filteredWorkspaceTags.length === 0 ? (
|
|
<div className="p-12 text-center text-muted text-xs panel-surface space-y-2 border border-border rounded-card">
|
|
<Hash className="w-8 h-8 mx-auto text-faint" />
|
|
<p className="font-medium text-main">
|
|
{tagSearchQuery
|
|
? `Keine Hashtags gefunden für "${tagSearchQuery}"`
|
|
: 'Noch keine Themen oder Hashtags vorhanden'}
|
|
</p>
|
|
<p className="text-xs text-faint">
|
|
Füge einfach in irgendeiner Notiz ein Tag wie <code className="text-accent">#projekt</code> oder <code className="text-accent">#idee</code> ein.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
|
{filteredWorkspaceTags.map((wt) => (
|
|
<div
|
|
key={wt.tag}
|
|
onClick={() => openTagPage(wt.tag)}
|
|
className="group panel-surface p-4 rounded-card border border-border hover:border-accent hover:shadow-md cursor-pointer transition-all flex flex-col justify-between"
|
|
>
|
|
<div className="flex items-start justify-between gap-2 mb-3">
|
|
<div className="w-8 h-8 rounded-lg badge-accent flex items-center justify-center text-accent group-hover:scale-105 transition-transform">
|
|
<Hash className="w-4 h-4" />
|
|
</div>
|
|
<span className="badge-neutral text-[10px] font-mono">
|
|
{wt.count} {wt.count === 1 ? 'Abschnitt' : 'Abschnitte'}
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<h3 className="font-bold text-sm text-main group-hover:text-accent transition-colors truncate">
|
|
#{wt.tag}
|
|
</h3>
|
|
<p className="text-[11px] text-muted mt-2 flex items-center justify-between group-hover:text-main">
|
|
<span>Abschnitte ansehen</span>
|
|
<ExternalLink className="w-3 h-3 group-hover:translate-x-0.5 transition-transform text-accent" />
|
|
</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : isLoadingTag ? (
|
|
<div className="p-12 text-center text-muted text-xs panel-surface">
|
|
<Sparkles className="w-6 h-6 animate-spin mx-auto mb-2 text-accent" />
|
|
Hashtag-Verknüpfungen werden geladen…
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{/* Dedicated Logseq-Style Hashtag Markdown Note Card (#tag.md) */}
|
|
<div className="panel-surface p-4 space-y-2.5 border border-border rounded-card">
|
|
<div className="flex flex-wrap items-center justify-between gap-2 pb-2 border-b border-border">
|
|
<div className="flex items-center gap-2">
|
|
<FileText className="w-3.5 h-3.5 text-accent" />
|
|
<span className="text-xs font-bold text-main">
|
|
Eigene Notiz zu #{selectedTag}
|
|
</span>
|
|
<span className="badge-neutral text-[10px] font-mono">
|
|
#{selectedTag}.md
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[10px] text-faint font-mono">
|
|
{tagNoteSaveState === 'saving' ? 'Speichert…' : 'Gespeichert'}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={handleExportTagMarkdown}
|
|
className="btn-action h-6 px-2 text-[11px] flex items-center gap-1"
|
|
title={`#${selectedTag}.md herunterladen`}
|
|
>
|
|
<Download className="w-3 h-3" />
|
|
<span>.md</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<textarea
|
|
value={tagNoteContent}
|
|
onChange={(e) => handleTagNoteChange(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Tab') {
|
|
e.preventDefault();
|
|
const target = e.currentTarget;
|
|
const start = target.selectionStart;
|
|
const end = target.selectionEnd;
|
|
const nextVal =
|
|
tagNoteContent.slice(0, start) + ' ' + tagNoteContent.slice(end);
|
|
handleTagNoteChange(nextVal);
|
|
requestAnimationFrame(() => {
|
|
target.setSelectionRange(start + 2, start + 2);
|
|
});
|
|
}
|
|
}}
|
|
rows={Math.max(4, tagNoteContent.split('\n').length + 1)}
|
|
placeholder={`Eigene Gedanken, Zusammenfassungen oder Markdown-Notizen direkt zum Thema #${selectedTag} festhalten (unabhängig von den anderen Workspace-Notizen)…`}
|
|
className="w-full input-field rounded-card p-3 text-xs text-main leading-relaxed focus:outline-none focus:border-accent resize-y placeholder:text-faint"
|
|
/>
|
|
</div>
|
|
|
|
{/* Linked References / Verknüpfte Bullet Points from Workspace Notes */}
|
|
{!tagDetails || tagDetails.documents.length === 0 ? (
|
|
<div className="p-10 text-center text-muted text-xs panel-surface space-y-2 border border-border rounded-card">
|
|
<Hash className="w-7 h-7 mx-auto text-faint" />
|
|
<p className="font-medium text-main">Keine verknüpften Bullet Points mit #{selectedTag} in anderen Notizen</p>
|
|
<p className="text-xs text-faint">
|
|
Füge einfach in irgendeiner Notiz <code className="text-accent">#{selectedTag}</code> in einen Bullet Point ein, damit er hier automatisch verknüpft wird.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{tagDetails.documents.map((docGroup) => (
|
|
<div
|
|
key={docGroup.docId}
|
|
className="panel-surface p-4 space-y-3 border border-border rounded-card hover:border-strong transition-colors"
|
|
>
|
|
{/* Document Header */}
|
|
<div className="flex flex-wrap items-center justify-between gap-2 pb-2 border-b border-border">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<span className="text-base flex-shrink-0">{docGroup.icon || '📄'}</span>
|
|
<span className="font-semibold text-xs text-main truncate">{docGroup.title || 'Unbenannt'}</span>
|
|
{docGroup.folderPath && (
|
|
<span className="badge-neutral text-[10px] flex items-center gap-1 font-mono">
|
|
<Folder className="w-2.5 h-2.5" />
|
|
<span>{docGroup.folderPath}</span>
|
|
</span>
|
|
)}
|
|
<span className="text-[10px] text-faint">
|
|
• {docGroup.matchedBlocks.length}{' '}
|
|
{docGroup.matchedBlocks.length === 1 ? 'Treffer' : 'Treffer'}
|
|
</span>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const found = docs.find((d) => d.id === docGroup.docId);
|
|
if (found) {
|
|
setSelectedDoc(found);
|
|
setZoomedBlockId(null);
|
|
setWorkspaceMode('editor');
|
|
}
|
|
}}
|
|
className="btn-action h-7 px-2.5 text-xs font-medium text-accent hover:text-main"
|
|
>
|
|
<span>Notiz öffnen</span>
|
|
<ExternalLink className="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Matched Bullet Points and their children */}
|
|
<div className="space-y-3 pl-1">
|
|
{docGroup.matchedBlocks.map((mb, idx) => (
|
|
<div
|
|
key={mb.rootBlock.id || idx}
|
|
className="space-y-1.5 bg-canvas p-3 rounded-card border border-border"
|
|
>
|
|
{/* Root Bullet Point */}
|
|
<div className="flex items-start gap-2 group">
|
|
{mb.rootBlock.type === 'todo' ? (
|
|
<input
|
|
type="checkbox"
|
|
checked={!!mb.rootBlock.checked}
|
|
onChange={() => handleToggleRollupTodo(docGroup.docId, mb.rootBlock.id)}
|
|
className="w-4 h-4 mt-0.5 checkbox-square cursor-pointer flex-shrink-0"
|
|
/>
|
|
) : (
|
|
<div className="w-3.5 h-3.5 mt-1 rounded-full bg-accent-dim border border-accent flex items-center justify-center flex-shrink-0">
|
|
<div className="w-1.5 h-1.5 rounded-full bg-accent" />
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
onClick={() => {
|
|
const found = docs.find((d) => d.id === docGroup.docId);
|
|
if (found) {
|
|
setSelectedDoc(found);
|
|
setZoomedBlockId(mb.rootBlock.id);
|
|
setWorkspaceMode('editor');
|
|
}
|
|
}}
|
|
className={`flex-1 text-xs leading-relaxed cursor-pointer hover:text-accent transition-colors ${
|
|
mb.rootBlock.checked ? 'line-through text-faint' : 'text-main'
|
|
}`}
|
|
title="Klick: Zu diesem Bullet Point in der Notiz zoomen"
|
|
>
|
|
{renderFormattedText(mb.rootBlock.content || '(Leerer Bullet Point)')}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Children / Sub-bullets */}
|
|
{mb.children.length > 0 && (
|
|
<div className="space-y-1 mt-1 pl-4 border-l-2 border-border ml-2">
|
|
{mb.children.map((child) => {
|
|
const indentLevel = Math.max(
|
|
0,
|
|
(child.level || 0) - (mb.rootBlock.level || 0) - 1
|
|
);
|
|
return (
|
|
<div
|
|
key={child.id}
|
|
style={{ paddingLeft: `${indentLevel * 14}px` }}
|
|
className="flex items-start gap-2 py-0.5 text-xs text-muted"
|
|
>
|
|
{child.type === 'todo' ? (
|
|
<input
|
|
type="checkbox"
|
|
checked={!!child.checked}
|
|
onChange={() =>
|
|
handleToggleRollupTodo(docGroup.docId, child.id)
|
|
}
|
|
className="w-3.5 h-3.5 mt-0.5 checkbox-square cursor-pointer flex-shrink-0"
|
|
/>
|
|
) : (
|
|
<span className="w-1.5 h-1.5 mt-1.5 rounded-full bg-muted flex-shrink-0" />
|
|
)}
|
|
<div
|
|
onClick={() => {
|
|
const found = docs.find((d) => d.id === docGroup.docId);
|
|
if (found) {
|
|
setSelectedDoc(found);
|
|
setZoomedBlockId(child.id);
|
|
setWorkspaceMode('editor');
|
|
}
|
|
}}
|
|
className={`flex-1 text-xs leading-relaxed cursor-pointer hover:text-accent transition-colors ${
|
|
child.checked ? 'line-through text-faint' : 'text-muted'
|
|
}`}
|
|
title="Klick: In Notiz fokussieren"
|
|
>
|
|
{renderFormattedText(child.content || '')}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* UNIFIED DOCUMENT SHARING MODAL */}
|
|
{showShareModal && selectedDoc && (
|
|
<UnifiedShareModal
|
|
isOpen={showShareModal}
|
|
onClose={() => setShowShareModal(false)}
|
|
title={`Notiz teilen: ${selectedDoc.title || 'Unbenannte Notiz'}`}
|
|
resourceName={selectedDoc.title || 'Unbenannte Notiz'}
|
|
resourceIcon={selectedDoc.icon || '📄'}
|
|
isOwner={selectedDoc.isOwner !== false}
|
|
currentUserId={currentUser?.id}
|
|
shares={(docShares || []).map((sh) => ({
|
|
id: sh.id,
|
|
targetType: sh.targetType || (sh.teamId ? 'team' : 'user'),
|
|
targetId: sh.targetId || sh.teamId || sh.userId || '',
|
|
targetName: sh.targetName || sh.displayName || (sh.targetType === 'team' || sh.teamId ? 'Gruppe' : sh.userEmail || 'Benutzer'),
|
|
targetEmail: sh.userEmail,
|
|
permission: sh.permission === 'read' ? ('viewer' as const) : ('editor' as const),
|
|
role: sh.permission === 'read' ? 'viewer' : 'editor',
|
|
createdAt: sh.createdAt,
|
|
}))}
|
|
teams={shareTeams}
|
|
collaborators={collaborators}
|
|
allowRoleSelection={true}
|
|
onAddShare={async (targetType, targetId, permission) => {
|
|
const perm = permission === 'viewer' ? 'read' : 'write';
|
|
if (targetType === 'team') {
|
|
await api.createDocShare(selectedDoc.id, {
|
|
teamId: targetId,
|
|
permission: perm,
|
|
});
|
|
} else {
|
|
await api.createDocShare(selectedDoc.id, {
|
|
userId: targetId,
|
|
permission: perm,
|
|
});
|
|
}
|
|
const shares = await api.getDocShares(selectedDoc.id);
|
|
setDocShares(shares);
|
|
await loadDocs(selectedDoc.id);
|
|
}}
|
|
onRemoveShare={async (shareId) => {
|
|
await api.deleteDocShare(selectedDoc.id, shareId);
|
|
const shares = await api.getDocShares(selectedDoc.id);
|
|
setDocShares(shares);
|
|
await loadDocs(selectedDoc.id);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* UNIFIED FOLDER SHARING MODAL */}
|
|
{showFolderShareModal && sharingFolder && (
|
|
<UnifiedShareModal
|
|
isOpen={showFolderShareModal}
|
|
onClose={() => {
|
|
setShowFolderShareModal(false);
|
|
setSharingFolder(null);
|
|
}}
|
|
title={`Ordner teilen: ${sharingFolder.name}`}
|
|
resourceName={sharingFolder.name}
|
|
resourceIcon="📁"
|
|
isOwner={sharingFolder.isOwner}
|
|
currentUserId={currentUser?.id}
|
|
shares={(folderShares || []).map((sh) => ({
|
|
id: sh.id,
|
|
targetType: sh.targetType,
|
|
targetId: sh.targetId,
|
|
targetName: sh.targetName || (sh.targetType === 'team' ? 'Gruppe' : sh.email || 'Benutzer'),
|
|
targetEmail: sh.email,
|
|
avatarUrl: sh.avatarUrl,
|
|
permission: sh.permission === 'read' ? ('viewer' as const) : ('editor' as const),
|
|
role: sh.permission === 'read' ? 'viewer' : 'editor',
|
|
createdAt: sh.createdAt,
|
|
}))}
|
|
teams={shareTeams}
|
|
collaborators={collaborators}
|
|
allowRoleSelection={true}
|
|
onAddShare={async (targetType, targetId, permission) => {
|
|
const perm = permission === 'viewer' ? 'read' : 'write';
|
|
await api.createFolderShare(sharingFolder.id, {
|
|
targetType,
|
|
targetId,
|
|
permission: perm,
|
|
});
|
|
const updated = await api.getFolderShares(sharingFolder.id);
|
|
setFolderShares(updated);
|
|
const sfList = await api.getSharedFolders();
|
|
setSharedFolders(sfList);
|
|
await loadDocs(selectedDoc?.id);
|
|
}}
|
|
onRemoveShare={async (shareId) => {
|
|
await api.deleteFolderShare(sharingFolder.id, shareId);
|
|
const updated = await api.getFolderShares(sharingFolder.id);
|
|
setFolderShares(updated);
|
|
const sfList = await api.getSharedFolders();
|
|
setSharedFolders(sfList);
|
|
await loadDocs(selectedDoc?.id);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* LINK INSERTION MODAL (/link, /notiz, /web) */}
|
|
{linkModalBlockId && (
|
|
<div
|
|
className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs"
|
|
onClick={() => {
|
|
const blockId = linkModalBlockId;
|
|
const wasEditingLink = Boolean(editingLinkOriginalRaw);
|
|
setLinkModalBlockId(null);
|
|
setEditingLinkOriginalRaw(null);
|
|
if (blockId && !wasEditingLink) focusBlock(blockId);
|
|
}}
|
|
>
|
|
<div
|
|
className="bg-panel border border-border rounded-xl shadow-panel w-full max-w-md overflow-hidden animate-in fade-in zoom-in-95 duration-150"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Modal Header */}
|
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
|
<div className="flex items-center gap-2">
|
|
<div className="p-1.5 rounded-btn bg-accent/15 text-accent">
|
|
<ExternalLink className="w-4 h-4" />
|
|
</div>
|
|
<h3 className="text-sm font-semibold text-main">
|
|
{editingLinkOriginalRaw ? 'Link bearbeiten' : 'Link einfügen'}
|
|
</h3>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const blockId = linkModalBlockId;
|
|
const wasEditingLink = Boolean(editingLinkOriginalRaw);
|
|
setLinkModalBlockId(null);
|
|
setEditingLinkOriginalRaw(null);
|
|
if (blockId && !wasEditingLink) focusBlock(blockId);
|
|
}}
|
|
className="p-1 text-faint hover:text-main rounded-btn transition-colors cursor-pointer"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Tab Switcher: Notiz vs Webseite */}
|
|
<div className="flex border-b border-border bg-panel-elevated/40 p-1 gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => setLinkModalTab('note')}
|
|
className={`flex-1 py-1.5 px-3 rounded-btn text-xs font-medium flex items-center justify-center gap-1.5 transition-colors cursor-pointer ${
|
|
linkModalTab === 'note'
|
|
? 'bg-panel text-main shadow-xs font-semibold'
|
|
: 'text-muted hover:text-main'
|
|
}`}
|
|
>
|
|
<FileText className="w-3.5 h-3.5" />
|
|
<span>Notiz verlinken</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setLinkModalTab('web')}
|
|
className={`flex-1 py-1.5 px-3 rounded-btn text-xs font-medium flex items-center justify-center gap-1.5 transition-colors cursor-pointer ${
|
|
linkModalTab === 'web'
|
|
? 'bg-panel text-main shadow-xs font-semibold'
|
|
: 'text-muted hover:text-main'
|
|
}`}
|
|
>
|
|
<ExternalLink className="w-3.5 h-3.5" />
|
|
<span>Webseite verlinken</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Tab Content */}
|
|
<div className="p-4">
|
|
{linkModalTab === 'note' ? (
|
|
<div className="space-y-3">
|
|
<div className="relative">
|
|
<Search className="w-4 h-4 absolute left-3 top-2.5 text-muted pointer-events-none" />
|
|
<input
|
|
type="text"
|
|
autoFocus
|
|
placeholder="Notiz nach Titel oder Ordner filtern..."
|
|
value={linkSearchQuery}
|
|
onChange={(e) => setLinkSearchQuery(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Escape') {
|
|
const blockId = linkModalBlockId;
|
|
const wasEditingLink = Boolean(editingLinkOriginalRaw);
|
|
setLinkModalBlockId(null);
|
|
setEditingLinkOriginalRaw(null);
|
|
if (blockId && !wasEditingLink) focusBlock(blockId);
|
|
} else if (e.key === 'Enter') {
|
|
const filtered = docs.filter(
|
|
(d) =>
|
|
d.title.toLowerCase().includes(linkSearchQuery.toLowerCase()) ||
|
|
(d.folderPath && d.folderPath.toLowerCase().includes(linkSearchQuery.toLowerCase()))
|
|
);
|
|
if (filtered.length > 0) {
|
|
handleInsertNoteLink(filtered[0].title);
|
|
}
|
|
}
|
|
}}
|
|
className="input-field w-full pl-9 h-9 text-xs"
|
|
/>
|
|
</div>
|
|
|
|
<div className="max-h-60 overflow-y-auto space-y-1 divide-y divide-border/30">
|
|
{docs
|
|
.filter(
|
|
(d) =>
|
|
!linkSearchQuery.trim() ||
|
|
d.title.toLowerCase().includes(linkSearchQuery.toLowerCase()) ||
|
|
(d.folderPath && d.folderPath.toLowerCase().includes(linkSearchQuery.toLowerCase()))
|
|
)
|
|
.slice(0, 20)
|
|
.map((d) => (
|
|
<button
|
|
key={d.id}
|
|
type="button"
|
|
onClick={() => handleInsertNoteLink(d.title)}
|
|
className="w-full text-left px-2.5 py-2 rounded-btn text-xs hover:bg-panel-elevated flex items-center justify-between gap-2 transition-colors cursor-pointer group"
|
|
>
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<span className="text-base flex-shrink-0">{d.icon || '📄'}</span>
|
|
<div className="min-w-0">
|
|
<div className="font-medium text-main truncate group-hover:text-accent">
|
|
{d.title}
|
|
</div>
|
|
{d.folderPath && (
|
|
<div className="text-[10px] text-faint truncate">
|
|
{d.folderPath}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<span className="text-[10px] text-faint group-hover:text-accent flex-shrink-0 font-mono">
|
|
[[{d.title}]]
|
|
</span>
|
|
</button>
|
|
))}
|
|
|
|
{docs.filter(
|
|
(d) =>
|
|
!linkSearchQuery.trim() ||
|
|
d.title.toLowerCase().includes(linkSearchQuery.toLowerCase())
|
|
).length === 0 && (
|
|
<div className="py-6 text-center text-xs text-faint">
|
|
Keine passende Notiz gefunden
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
handleInsertWebLink();
|
|
}}
|
|
className="space-y-3"
|
|
>
|
|
<div className="space-y-1">
|
|
<label className="text-[11px] font-medium text-muted">Titel / Name der Verlinkung</label>
|
|
<input
|
|
type="text"
|
|
autoFocus
|
|
placeholder="z.B. Wikipedia oder Referenzseite"
|
|
value={linkWebTitle}
|
|
onChange={(e) => setLinkWebTitle(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Escape') {
|
|
const blockId = linkModalBlockId;
|
|
const wasEditingLink = Boolean(editingLinkOriginalRaw);
|
|
setLinkModalBlockId(null);
|
|
setEditingLinkOriginalRaw(null);
|
|
if (blockId && !wasEditingLink) focusBlock(blockId);
|
|
}
|
|
}}
|
|
className="input-field w-full h-9 text-xs"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<label className="text-[11px] font-medium text-muted">Web-Adresse (URL) *</label>
|
|
<input
|
|
type="text"
|
|
required
|
|
placeholder="https://example.com"
|
|
value={linkWebUrl}
|
|
onChange={(e) => setLinkWebUrl(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Escape') {
|
|
const blockId = linkModalBlockId;
|
|
const wasEditingLink = Boolean(editingLinkOriginalRaw);
|
|
setLinkModalBlockId(null);
|
|
setEditingLinkOriginalRaw(null);
|
|
if (blockId && !wasEditingLink) focusBlock(blockId);
|
|
}
|
|
}}
|
|
className="input-field w-full h-9 text-xs"
|
|
/>
|
|
</div>
|
|
|
|
<div className="pt-2 flex justify-end gap-2 border-t border-border">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const blockId = linkModalBlockId;
|
|
const wasEditingLink = Boolean(editingLinkOriginalRaw);
|
|
setLinkModalBlockId(null);
|
|
setEditingLinkOriginalRaw(null);
|
|
if (blockId && !wasEditingLink) focusBlock(blockId);
|
|
}}
|
|
className="btn-action h-8 px-3 text-xs cursor-pointer"
|
|
>
|
|
Abbrechen
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={!linkWebUrl.trim()}
|
|
className="btn-primary h-8 px-4 text-xs font-medium cursor-pointer disabled:opacity-50"
|
|
>
|
|
{editingLinkOriginalRaw ? 'Link speichern' : 'Link einfügen'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* DELETE SUBPOINTS CONFIRMATION MODAL */}
|
|
{deleteSubpointsModal && (
|
|
<div
|
|
className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs"
|
|
onClick={() => setDeleteSubpointsModal(null)}
|
|
>
|
|
<div
|
|
className="bg-panel border border-border rounded-xl shadow-panel w-full max-w-md overflow-hidden animate-in fade-in zoom-in-95 duration-150 p-5 space-y-4"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="flex items-center gap-2.5">
|
|
<div className="w-8 h-8 rounded-full bg-status-err/15 text-status-err flex items-center justify-center shrink-0">
|
|
<Trash2 className="w-4 h-4" />
|
|
</div>
|
|
<div>
|
|
<h3 className="font-bold text-sm text-main">Aufzählungspunkt löschen</h3>
|
|
<p className="text-xs text-muted mt-0.5">
|
|
Dieser Punkt enthält <strong>{deleteSubpointsModal.count} {deleteSubpointsModal.count === 1 ? 'Unterpunkt' : 'Unterpunkte'}</strong>.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setDeleteSubpointsModal(null)}
|
|
className="p-1 text-muted hover:text-main rounded-btn transition-colors cursor-pointer"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<p className="text-xs text-main">
|
|
Möchtest du alle Unterpunkte mit löschen oder nur diesen Aufzählungspunkt entfernen?
|
|
</p>
|
|
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-end gap-2 pt-2 border-t border-border">
|
|
<button
|
|
type="button"
|
|
onClick={() => setDeleteSubpointsModal(null)}
|
|
className="btn-ghost h-8 px-3 text-xs"
|
|
>
|
|
Abbrechen
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const bId = deleteSubpointsModal.blockId;
|
|
setDeleteSubpointsModal(null);
|
|
deleteSingleBlock(bId, false);
|
|
}}
|
|
className="btn-action h-8 px-3 text-xs font-medium"
|
|
>
|
|
Nur diesen Punkt löschen
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const bId = deleteSubpointsModal.blockId;
|
|
setDeleteSubpointsModal(null);
|
|
deleteBlockWithDescendants(bId);
|
|
}}
|
|
className="btn-danger h-8 px-3 text-xs font-semibold"
|
|
>
|
|
Alle Unterpunkte mit löschen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|