= ({
)}
- {/* Badges: Priority, Status, Tags */}
+ {/* Badges: In Notiz, Priority, Status, Tags */}
e.stopPropagation()}>
+ {(task.convertedType === 'document_block' || task.docId) && (
+
+ )}
{task.priority === 'urgent' && (
- {/* Right Status & Action Buttons */}
+ {/* Right Action Buttons */}
- {/* Active Presence Avatars (Immer rund: rounded-full mit Überlappung) */}
- {activeUsers.length > 0 && (
-
- {activeUsers.map((u) => {
- const initials = (u.userName || 'A')
- .split(' ')
- .map((n) => n[0])
- .slice(0, 2)
- .join('')
- .toUpperCase();
- return (
-
-
- {initials}
-
- {/* Tooltip */}
-
- {u.userName}
-
-
- );
- })}
-
- )}
-
- {/* Sharing Section (1:1 styling from SharedListsView) */}
+ {/* Sharing Section */}
{doc && (() => {
const isOwner = doc.isOwner !== false && (doc.userId === currentUser?.id || !doc.userId || currentUser?.role === 'admin');
const sharesCount = doc.sharedWith?.length || 0;
@@ -790,14 +873,21 @@ export const DocumentEditor: React.FC
= ({
+ {/* Read-only notification banner */}
+ {!canEdit && (
+
+
+ Dieses Dokument wurde schreibgeschützt mit dir geteilt (Nur Lesen).
+
+ )}
+
{/* Main Content Area */}
-
+
{zoomedBlock ? (
/* =========================================================================
FOKUS-MODUS & ZOOM-IN (HOISTED SUBTREE VIEW)
========================================================================= */
- {/* Prominent Zoomed Ast-Heading */}
@@ -846,7 +936,6 @@ export const DocumentEditor: React.FC = ({
- {/* Subtree Children or Empty State */}
{childBlocks.length === 0 ? (
Dieser Ast hat noch keine Unterpunkte.
@@ -878,12 +967,13 @@ export const DocumentEditor: React.FC
= ({
canEdit={canEdit}
levelOffset={levelOffset}
onZoomBlock={setZoomedBlockId}
- remoteCursors={remoteCursors}
- sendCursorMoved={sendCursorMoved}
onUpdateBlock={handleUpdateBlock}
+ onUpdateBlocks={handleUpdateBlocks}
onCreateBlock={handleCreateBlock}
+ onInsertBlocks={handleInsertBlocks}
onDeleteBlock={handleDeleteBlock}
onMoveBlock={handleMoveBlock}
+ onOpenTask={onOpenTask}
onTagClick={onSelectTag}
onWikiLinkClick={onWikiLinkClick}
initialFocusedBlockId={initialFocusedBlockId}
@@ -907,47 +997,119 @@ export const DocumentEditor: React.FC = ({
{doc?.icon || '📝'}
{showEmojiPicker && canEdit && (
-
- {EMOJI_PALETTE.map((emoji) => (
+
e.stopPropagation()}
+ >
+
+
+ setEmojiSearch(e.target.value)}
+ placeholder="Icon suchen oder Emoji eingeben…"
+ className="input-field w-full pl-7 pr-2 py-1 text-xs"
+ />
+
+ {doc?.icon && (
- ))}
+ )}
+ {emojiSearch.trim() && (
+
+ )}
+
+ {filteredEmojis.map((item) => (
+
+ ))}
+ {filteredEmojis.length === 0 && (
+
+ Kein passendes Standard-Icon
+
+ )}
+
)}
- {
+ titleInputRef.current = el;
+ if (el) {
+ el.style.height = 'auto';
+ el.style.height = `${el.scrollHeight}px`;
+ }
+ }}
+ rows={1}
value={title}
readOnly={!canEdit}
autoFocus={autoFocusTitle}
- onChange={handleTitleChange}
+ onChange={(e) => {
+ handleTitleChange(e);
+ e.target.style.height = 'auto';
+ e.target.style.height = `${e.target.scrollHeight}px`;
+ }}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ outlinerTreeRef.current?.focusFirstBlock();
+ }
+ }}
onBlur={handleTitleBlur}
placeholder="Titel der Notiz…"
- className="flex-1 min-w-0 text-xl sm:text-2xl font-bold text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight"
+ className="flex-1 min-w-0 text-xl sm:text-2xl font-bold text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight resize-none leading-snug whitespace-pre-wrap break-words [overflow-wrap:anywhere]"
/>
{/* Logseq Outliner Tree */}
void;
levelOffset?: number;
onZoomBlock?: (blockId: string) => void;
- onFocusBlock: (blockId: string) => void;
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
- onCreateBlockBelow: (afterBlockId: string, currentContent: string) => void;
- onDeleteBlock: (blockId: string) => void;
+ onCreateBlockBelow: (afterBlockId: string, newContent?: string) => void;
+ onDeleteBlock: (blockId: string, deleteSubtree?: boolean) => void;
onIndentBlock: (blockId: string, delta: 1 | -1) => void;
onNavigateFocus: (blockId: string, direction: 'up' | 'down') => void;
+ onFocusBlock?: (blockId: string) => void;
+ onOpenTask?: (taskId: string) => void;
onTagClick?: (tag: string) => void;
onWikiLinkClick?: (pageTitle: string) => void;
onDragStartBlock?: (index: number) => void;
@@ -65,24 +69,27 @@ export interface OutlinerBlockProps {
isDragTarget?: boolean;
isTargeted?: boolean;
registerTextareaRef: (blockId: string, el: HTMLTextAreaElement | null) => void;
+ onPasteMultiLines?: (blockId: string, textBefore: string, textAfter: string, rawLines: string[]) => void;
}
export const OutlinerBlock: React.FC = ({
+ docId,
block,
index,
+ isLast,
+ maxAllowedLevel,
hasChildren,
isFocused,
canEdit,
- activeRemoteCursors = [],
- sendCursorMoved,
levelOffset = 0,
onZoomBlock,
- onFocusBlock,
onUpdateBlock,
onCreateBlockBelow,
onDeleteBlock,
onIndentBlock,
onNavigateFocus,
+ onFocusBlock,
+ onOpenTask,
onTagClick,
onWikiLinkClick,
onDragStartBlock,
@@ -91,11 +98,15 @@ export const OutlinerBlock: React.FC = ({
isDragTarget,
isTargeted,
registerTextareaRef,
+ onPasteMultiLines,
}) => {
const [localContent, setLocalContent] = useState(block.content || '');
+ const [isLocallyFocused, setIsLocallyFocused] = useState(false);
+ const activeFocus = isFocused || isLocallyFocused;
const [slashSearch, setSlashSearch] = useState(null);
const [slashIndex, setSlashIndex] = useState(0);
const [showLinkModal, setShowLinkModal] = useState(false);
+ const [showTodoMenu, setShowTodoMenu] = useState(false);
const textareaRef = useRef(null);
const debounceTimerRef = useRef(null);
@@ -107,18 +118,17 @@ export const OutlinerBlock: React.FC = ({
// Callback refs to avoid stale closures in setTimeout, debounce flush, and event listeners
const onUpdateBlockRef = useRef(onUpdateBlock);
onUpdateBlockRef.current = onUpdateBlock;
- const onFocusBlockRef = useRef(onFocusBlock);
- onFocusBlockRef.current = onFocusBlock;
const onCreateBlockBelowRef = useRef(onCreateBlockBelow);
onCreateBlockBelowRef.current = onCreateBlockBelow;
+ const onPasteMultiLinesRef = useRef(onPasteMultiLines);
+ onPasteMultiLinesRef.current = onPasteMultiLines;
const onDeleteBlockRef = useRef(onDeleteBlock);
onDeleteBlockRef.current = onDeleteBlock;
const onIndentBlockRef = useRef(onIndentBlock);
onIndentBlockRef.current = onIndentBlock;
const onNavigateFocusRef = useRef(onNavigateFocus);
onNavigateFocusRef.current = onNavigateFocus;
- const sendCursorMovedRef = useRef(sendCursorMoved);
- sendCursorMovedRef.current = sendCursorMoved;
+ const isPastingRef = useRef(false);
// Track if this input currently has actual focus in an active browser window
const isFocusedRef = useRef(false);
@@ -166,7 +176,6 @@ export const OutlinerBlock: React.FC = ({
flushDebounce(localContentRef.current);
}
isFocusedRef.current = false;
- sendCursorMovedRef.current?.(null);
};
const handleWindowFocus = () => {
@@ -203,7 +212,7 @@ export const OutlinerBlock: React.FC = ({
useLayoutEffect(() => {
adjustHeight();
- }, [localContent, block.type, block.level, adjustHeight]);
+ }, [localContent, block.type, block.level, activeFocus, adjustHeight]);
useEffect(() => {
window.addEventListener('resize', adjustHeight);
@@ -279,7 +288,6 @@ export const OutlinerBlock: React.FC = ({
}
setLocalContent(val);
- sendCursorMoved?.(block.id, e.target.selectionStart, e.target.selectionEnd);
if (debounceTimerRef.current !== null) {
window.clearTimeout(debounceTimerRef.current);
@@ -302,14 +310,20 @@ export const OutlinerBlock: React.FC = ({
}, 150);
};
- const handleFocus = (e: React.FocusEvent) => {
+ const handleFocus = () => {
isFocusedRef.current = true;
- onFocusBlockRef.current(block.id);
- sendCursorMovedRef.current?.(block.id, e.target.selectionStart, e.target.selectionEnd);
+ setIsLocallyFocused(true);
+ onFocusBlock?.(block.id);
+ setTimeout(adjustHeight, 0);
};
const handleBlur = () => {
isFocusedRef.current = false;
+ setIsLocallyFocused(false);
+ if (isPastingRef.current) {
+ isPastingRef.current = false;
+ return;
+ }
if (debounceTimerRef.current !== null) {
window.clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
@@ -317,23 +331,30 @@ export const OutlinerBlock: React.FC = ({
} else if (localContentRef.current !== latestBlockRef.current.content) {
flushDebounce(localContentRef.current);
}
- sendCursorMovedRef.current?.(null);
};
- const handleClick = (e: React.MouseEvent) => {
- sendCursorMovedRef.current?.(block.id, e.currentTarget.selectionStart, e.currentTarget.selectionEnd);
- };
+ const handlePaste = (e: React.ClipboardEvent) => {
+ if (!canEdit || block.type === 'code') return;
+ const pastedText = e.clipboardData.getData('text');
+ if (!pastedText || !pastedText.includes('\n')) return;
- const handleKeyUp = (e: React.KeyboardEvent) => {
- sendCursorMovedRef.current?.(block.id, e.currentTarget.selectionStart, e.currentTarget.selectionEnd);
- };
+ e.preventDefault();
+ const rawLines = pastedText.split(/\r?\n/);
+ if (rawLines.length <= 1) return;
- const handleSelect = (e: React.SyntheticEvent) => {
- const target = e.currentTarget;
- sendCursorMovedRef.current?.(block.id, target.selectionStart, target.selectionEnd);
- };
+ const start = e.currentTarget.selectionStart ?? localContent.length;
+ const end = e.currentTarget.selectionEnd ?? start;
+ const textBefore = localContent.slice(0, start);
+ const textAfter = localContent.slice(end);
- const primaryRemoteCursor = activeRemoteCursors.length > 0 ? activeRemoteCursors[0] : null;
+ if (debounceTimerRef.current !== null) {
+ window.clearTimeout(debounceTimerRef.current);
+ debounceTimerRef.current = null;
+ }
+
+ isPastingRef.current = true;
+ onPasteMultiLinesRef.current?.(block.id, textBefore, textAfter, rawLines);
+ };
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!canEdit) return;
@@ -374,13 +395,49 @@ export const OutlinerBlock: React.FC = ({
return;
}
- // 2. Enter (without Shift) -> Create new block on same level
+ // 2. Enter (without Shift) -> If block is completely empty:
+ // - If level > minLevel, outdent (Shift+Tab behaviour)
+ // - If already at minLevel and is the last block, do NOT create another empty block below
+ // - Otherwise split paragraph at cursor and create new block below
if (e.key === 'Enter' && !e.shiftKey && block.type !== 'code') {
e.preventDefault();
e.stopPropagation();
setSlashSearch(null);
- flushDebounce(localContent);
- onCreateBlockBelowRef.current(block.id, localContent);
+
+ const minLevel = levelOffset || 0;
+ if (localContent === '') {
+ if (Math.max(0, block.level || 0) > minLevel) {
+ flushDebounce(localContent);
+ onIndentBlockRef.current(block.id, -1);
+ return;
+ }
+ if (isLast) {
+ // Bereits auf oberster Ebene und der letzte Block: kein weiterer leerer Block
+ return;
+ }
+ }
+
+ const start = e.currentTarget.selectionStart ?? localContent.length;
+ const end = e.currentTarget.selectionEnd ?? start;
+ let beforeText = localContent.slice(0, start);
+ let afterText = localContent.slice(end);
+
+ // Clean linebreaks at the split boundary if present
+ if (beforeText.endsWith('\r\n')) {
+ beforeText = beforeText.slice(0, -2);
+ } else if (beforeText.endsWith('\n')) {
+ beforeText = beforeText.slice(0, -1);
+ }
+ if (afterText.startsWith('\r\n')) {
+ afterText = afterText.slice(2);
+ } else if (afterText.startsWith('\n')) {
+ afterText = afterText.slice(1);
+ }
+
+ setLocalContent(beforeText);
+ localContentRef.current = beforeText;
+ flushDebounce(beforeText);
+ onCreateBlockBelowRef.current(block.id, afterText);
return;
}
@@ -422,6 +479,7 @@ export const OutlinerBlock: React.FC = ({
const level = Math.max(0, block.level || 0);
const visualLevel = Math.max(0, level - (levelOffset || 0));
+ const isEmpty = (!localContent || localContent.trim() === '') && !hasChildren;
// Parse rich tokens (WikiLinks [[Page]], Markdown Links [Title](url), raw URLs, and #Tags)
const tokens = useMemo(() => {
@@ -441,39 +499,29 @@ export const OutlinerBlock: React.FC = ({
return (
onDragOverBlock?.(e, index)}
onDrop={() => onDropBlock?.(index)}
+ onClick={(e) => {
+ if ((e.target as HTMLElement).closest('button, a, input, select')) return;
+ if (!activeFocus && canEdit && textareaRef.current) {
+ textareaRef.current.focus();
+ const len = textareaRef.current.value.length;
+ textareaRef.current.setSelectionRange(len, len);
+ }
+ }}
>
- {/* Remote Cursors Floating Name Badges on Block */}
- {activeRemoteCursors.length > 0 && (
-
- {activeRemoteCursors.map((c) => (
-
- {c.userName}
-
- ))}
-
- )}
-
{/* Indentation Guide Line */}
{visualLevel > 0 &&
Array.from({ length: visualLevel }).map((_, lvlIdx) => (
@@ -484,20 +532,8 @@ export const OutlinerBlock: React.FC
= ({
/>
))}
- {/* Bullet Controls: Drag Handle, Collapse Toggle, Bullet / Checkbox */}
+ {/* Bullet Controls: Collapse Toggle & Bullet (Drag-Handle & Zoom) / Checkbox */}
- {canEdit && (
-
- )}
-
{hasChildren ? (
{/* Main Block Content */}
-
+
{block.type === 'divider' ? (
@@ -581,9 +631,6 @@ export const OutlinerBlock: React.FC = ({
readOnly={!canEdit}
onFocus={handleFocus}
onBlur={handleBlur}
- onClick={handleClick}
- onKeyUp={handleKeyUp}
- onSelect={handleSelect}
onChange={handleContentChange}
onKeyDown={handleKeyDown}
className="w-full resize-none font-mono text-xs bg-transparent text-status-ok border-0 focus:outline-none focus:ring-0 leading-relaxed"
@@ -601,16 +648,133 @@ export const OutlinerBlock: React.FC = ({
readOnly={!canEdit}
onFocus={handleFocus}
onBlur={handleBlur}
- onClick={handleClick}
- onKeyUp={handleKeyUp}
- onSelect={handleSelect}
onChange={handleContentChange}
onKeyDown={handleKeyDown}
className="w-full resize-none bg-transparent text-sm text-main border-0 focus:outline-none focus:ring-0 leading-relaxed italic"
/>
) : (
-
+
+ {/* View Layer with Inline Markdown & Actions (3-dots or Erweitert Badge) directly behind text */}
+
{
+ if (!activeFocus && canEdit && textareaRef.current) {
+ textareaRef.current.focus();
+ const len = textareaRef.current.value.length;
+ textareaRef.current.setSelectionRange(len, len);
+ }
+ }}
+ className={`w-full min-h-[1.5rem] leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] ${
+ activeFocus ? 'absolute inset-0 pointer-events-none' : 'relative cursor-text'
+ } ${
+ block.type === 'h1'
+ ? 'text-lg font-bold text-main tracking-tight'
+ : block.type === 'h2'
+ ? 'text-base font-semibold text-main'
+ : block.type === 'h3'
+ ? 'text-sm font-semibold text-main'
+ : block.type === 'todo' && block.checked
+ ? 'text-sm text-faint line-through'
+ : 'text-sm text-main'
+ }`}
+ >
+ {/* When actively focused, ghost text is invisible so textarea renders text/caret directly */}
+
+ {localContent ? renderInlineMarkdown(localContent) : '\u200b'}
+
+
+ {/* Inline Badge and 3-Dots directly behind the text */}
+ {block.type === 'todo' && (
+
+ {block.taskId && (
+
+
+ Erweitert
+
+ )}
+
+ {canEdit && (
+
+ {
+ e.stopPropagation();
+ setShowTodoMenu((prev) => !prev);
+ }}
+ className="p-0.5 text-faint hover:text-accent rounded-btn transition-colors cursor-pointer"
+ title="Aufgaben-Optionen"
+ >
+
+
+ {showTodoMenu && (
+ e.stopPropagation()}
+ >
+ {!block.taskId ? (
+ {
+ setShowTodoMenu(false);
+ try {
+ const targetDocId = docId || block.documentId || block.docId || '';
+ const thought = await api.createEnhancedTask(targetDocId, block.id, {
+ content: localContent || 'Aufgabe',
+ checked: Boolean(block.checked),
+ });
+ const updatedBlock: DocumentBlock = {
+ ...latestBlockRef.current,
+ content: localContent,
+ taskId: thought.id,
+ type: 'todo',
+ };
+ onUpdateBlockRef.current(updatedBlock, true);
+ } catch (err: any) {
+ alert(err.message || 'Fehler beim Erstellen der erweiterten Aufgabe');
+ }
+ }}
+ className="w-full text-left px-2 py-1.5 rounded-btn hover:bg-panel-elevated hover:text-accent flex items-center gap-1.5 text-main font-medium cursor-pointer"
+ >
+
+ In erweiterte Aufgabe umwandeln
+
+ ) : (
+ {
+ setShowTodoMenu(false);
+ try {
+ if (block.taskId) {
+ await api.unlinkEnhancedTask(block.taskId).catch(() => {});
+ }
+ const updatedBlock: DocumentBlock = {
+ ...latestBlockRef.current,
+ content: localContent,
+ taskId: '',
+ type: 'todo',
+ };
+ onUpdateBlockRef.current(updatedBlock, true);
+ } catch (err: any) {
+ alert(err.message || 'Fehler beim Aufheben der Verknüpfung');
+ }
+ }}
+ className="w-full text-left px-2 py-1.5 rounded-btn hover:bg-panel-elevated hover:text-status-warn flex items-center gap-1.5 text-main font-medium cursor-pointer"
+ >
+
+ Verknüpfung aufheben
+
+ )}
+
+ )}
+
+ )}
+
+ )}
+
+
)}
@@ -804,15 +924,23 @@ export const OutlinerBlock: React.FC
= ({
- {/* Delete button on hover */}
+ {/* Delete button on hover (Trash2 icon) */}
{canEdit && (
onDeleteBlockRef.current(block.id)}
- className="opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-status-err rounded-btn transition-opacity shrink-0"
+ onClick={() => {
+ if (hasChildren) {
+ if (window.confirm('Bist du sicher? Alle Unterpunkte dieses Asts werden ebenfalls gelöscht.')) {
+ onDeleteBlockRef.current(block.id, true);
+ }
+ } else {
+ onDeleteBlockRef.current(block.id, false);
+ }
+ }}
+ className="opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-status-err rounded-btn transition-opacity shrink-0 cursor-pointer"
title="Block löschen"
>
-
+
)}
diff --git a/frontend/src/features/workspace/OutlinerTree.tsx b/frontend/src/features/workspace/OutlinerTree.tsx
index 41b1762..6c6aca5 100644
--- a/frontend/src/features/workspace/OutlinerTree.tsx
+++ b/frontend/src/features/workspace/OutlinerTree.tsx
@@ -1,42 +1,47 @@
-import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
-import { Plus } from 'lucide-react';
-import { DocumentBlock } from '../../lib/types';
-import { RemoteCursor } from '../../lib/useDocumentSync';
+import React, { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
+import { DocumentBlock, BlockType } from '../../lib/types';
import { OutlinerBlock } from './OutlinerBlock';
+export interface OutlinerTreeHandle {
+ focusFirstBlock: () => void;
+ focusBlock: (blockId: string, cursor?: 'start' | 'end') => void;
+}
+
export interface OutlinerTreeProps {
docId: string;
blocks: DocumentBlock[];
canEdit: boolean;
levelOffset?: number;
onZoomBlock?: (blockId: string) => void;
- remoteCursors?: Record;
- sendCursorMoved?: (blockId: string | null, selectionStart?: number, selectionEnd?: number) => void;
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
+ onUpdateBlocks?: (updated: DocumentBlock[]) => void;
onCreateBlock: (newBlock: DocumentBlock, afterBlockId?: string) => void;
+ onInsertBlocks?: (afterBlockId: string, updatedCurrent: DocumentBlock, newBlocks: DocumentBlock[]) => void;
onDeleteBlock: (blockId: string) => void;
onMoveBlock: (blockId: string, newIndex: number, level?: number) => void;
+ onOpenTask?: (taskId: string) => void;
onTagClick?: (tag: string) => void;
onWikiLinkClick?: (pageTitle: string) => void;
initialFocusedBlockId?: string | null;
}
-export const OutlinerTree: React.FC = ({
+export const OutlinerTree = forwardRef(({
docId,
blocks,
canEdit,
levelOffset = 0,
onZoomBlock,
- remoteCursors,
- sendCursorMoved,
onUpdateBlock,
+ onUpdateBlocks,
onCreateBlock,
+ onInsertBlocks,
onDeleteBlock,
onMoveBlock,
+ onOpenTask,
onTagClick,
onWikiLinkClick,
initialFocusedBlockId,
-}) => {
+}, ref) => {
const [focusedBlockId, setFocusedBlockId] = useState(null);
const [highlightedBlockId, setHighlightedBlockId] = useState(
initialFocusedBlockId || null
@@ -73,6 +78,32 @@ export const OutlinerTree: React.FC = ({
[]
);
+ useImperativeHandle(
+ ref,
+ () => ({
+ focusFirstBlock: () => {
+ if (blocks.length > 0) {
+ focusBlockInput(blocks[0].id, 'end');
+ } else {
+ const firstBlock: DocumentBlock = {
+ id: `blk_${Date.now().toString(36)}_0`,
+ documentId: docId,
+ type: 'text',
+ content: '',
+ level: levelOffset || 0,
+ position: 1000,
+ };
+ pendingFocusRef.current = { blockId: firstBlock.id, cursor: 'end' };
+ onCreateBlock(firstBlock);
+ }
+ },
+ focusBlock: (blockId: string, cursor: 'start' | 'end' = 'end') => {
+ focusBlockInput(blockId, cursor);
+ },
+ }),
+ [blocks, docId, levelOffset, onCreateBlock, focusBlockInput]
+ );
+
const registerTextareaRef = useCallback((blockId: string, el: HTMLTextAreaElement | null) => {
if (el) {
textareaMapRef.current.set(blockId, el);
@@ -164,22 +195,21 @@ export const OutlinerTree: React.FC = ({
}
return result;
- }, [blocks]);
+ }, [blocks, levelOffset]);
const handleCreateBlockBelow = useCallback(
- (afterBlockId: string, _currentContent: string) => {
+ (afterBlockId: string, newContent: string = '') => {
if (!canEdit) return;
const idx = blocks.findIndex((b) => b.id === afterBlockId);
const currentBlock = idx >= 0 ? blocks[idx] : null;
const level = currentBlock ? Math.max(0, currentBlock.level || 0) : 0;
- const inheritedType =
- currentBlock && currentBlock.type === 'todo' ? 'todo' : 'text';
+ const inheritedType: BlockType = 'text';
const newBlock: DocumentBlock = {
id: `blk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
documentId: docId,
type: inheritedType,
- content: '',
+ content: newContent,
level,
checked: false,
collapsed: false,
@@ -193,8 +223,252 @@ export const OutlinerTree: React.FC = ({
[blocks, canEdit, docId, onCreateBlock]
);
+ const handlePasteMultiLines = useCallback(
+ (blockId: string, textBefore: string, textAfter: string, rawLines: string[]) => {
+ if (!canEdit || rawLines.length === 0) return;
+ const idx = blocks.findIndex((b) => b.id === blockId);
+ if (idx === -1) return;
+
+ const currentBlock = blocks[idx];
+ const baseLevel = Math.max(0, currentBlock.level || 0);
+
+ // Detect indentation style (2-space vs 4-space tabs)
+ let detectedSpaceStep = 2;
+ const leadingSpacesList: number[] = [];
+ for (const line of rawLines) {
+ if (!line.trim()) continue;
+ let sp = 0;
+ while (sp < line.length && line[sp] === ' ') sp++;
+ if (sp > 0) leadingSpacesList.push(sp);
+ }
+ if (leadingSpacesList.length > 0 && leadingSpacesList.every((s) => s % 4 === 0)) {
+ detectedSpaceStep = 4;
+ }
+
+ // Parse lines into structured blocks
+ interface ParsedPasteItem {
+ type: BlockType;
+ checked?: boolean;
+ content: string;
+ indent: number;
+ }
+
+ const parsed: ParsedPasteItem[] = [];
+ let currentItem: ParsedPasteItem | null = null;
+ let inCodeBlock = false;
+ let afterHeading = false;
+
+ const pushCurrentItem = () => {
+ if (currentItem) {
+ currentItem.content = currentItem.content.trimEnd();
+ parsed.push(currentItem);
+ currentItem = null;
+ }
+ };
+
+ for (let i = 0; i < rawLines.length; i++) {
+ const rawLine = rawLines[i];
+
+ // 1. Inside a fenced code block
+ if (inCodeBlock) {
+ if (currentItem) {
+ currentItem.content += '\n' + rawLine;
+ }
+ if (rawLine.trim().startsWith('```')) {
+ inCodeBlock = false;
+ }
+ continue;
+ }
+
+ // 2. Starts a code fence
+ if (rawLine.trim().startsWith('```')) {
+ inCodeBlock = true;
+ if (afterHeading) {
+ afterHeading = false;
+ pushCurrentItem();
+ currentItem = {
+ type: 'text',
+ content: rawLine,
+ indent: 0,
+ };
+ } else if (currentItem) {
+ currentItem.content += (currentItem.content.endsWith('\n') ? '' : '\n') + rawLine;
+ } else {
+ currentItem = {
+ type: 'text',
+ content: rawLine,
+ indent: 0,
+ };
+ }
+ continue;
+ }
+
+ // Measure leading indent
+ let tabs = 0;
+ let spaces = 0;
+ let charIdx = 0;
+ while (charIdx < rawLine.length) {
+ if (rawLine[charIdx] === '\t') {
+ tabs++;
+ charIdx++;
+ } else if (rawLine[charIdx] === ' ') {
+ spaces++;
+ charIdx++;
+ } else {
+ break;
+ }
+ }
+ const strippedLine = rawLine.slice(charIdx);
+ const lineIndent = tabs + Math.floor(spaces / detectedSpaceStep);
+
+ // Blank lines
+ if (strippedLine.trim() === '') {
+ if (currentItem) {
+ currentItem.content += '\n';
+ }
+ continue;
+ }
+
+ // Headings: #, ##, ###
+ const h3Match = strippedLine.match(/^###\s+(.*)$/);
+ const h2Match = strippedLine.match(/^##\s+(.*)$/);
+ const h1Match = strippedLine.match(/^#\s+(.*)$/);
+ if (h1Match || h2Match || h3Match) {
+ pushCurrentItem();
+ afterHeading = true;
+ const type: BlockType = h1Match ? 'h1' : h2Match ? 'h2' : 'h3';
+ const content = (h1Match || h2Match || h3Match)![1];
+ parsed.push({ type, content, indent: lineIndent });
+ continue;
+ }
+
+ // Dividers: ---, ***, ___
+ if (/^(?:---|---|\*\*\*|___)\s*$/.test(strippedLine.trim())) {
+ pushCurrentItem();
+ afterHeading = false;
+ parsed.push({ type: 'divider', content: '---', indent: lineIndent });
+ continue;
+ }
+
+ // Todo items: - [ ], - [x], * [ ], * [x]
+ const todoMatch = strippedLine.match(/^[-*]\s*\[([ xX])\]\s*(.*)$/);
+ if (todoMatch) {
+ pushCurrentItem();
+ afterHeading = false;
+ parsed.push({
+ type: 'todo',
+ checked: todoMatch[1].toLowerCase() === 'x',
+ content: todoMatch[2],
+ indent: lineIndent,
+ });
+ continue;
+ }
+
+ // Explicit bullet or numbered list: - item, * item, • item, 1. item, 1) item
+ const bulletMatch = strippedLine.match(/^[-*•]\s+(.*)$/);
+ const numMatch = strippedLine.match(/^\d+[\.\)]\s+(.*)$/);
+ if (bulletMatch || numMatch) {
+ pushCurrentItem();
+ afterHeading = false;
+ parsed.push({
+ type: 'text',
+ content: (bulletMatch ? bulletMatch[1] : numMatch![1]),
+ indent: lineIndent,
+ });
+ continue;
+ }
+
+ // Regular text line
+ if (afterHeading) {
+ // This starts the single bullet point after the heading
+ afterHeading = false;
+ pushCurrentItem();
+ currentItem = {
+ type: 'text',
+ content: strippedLine,
+ indent: lineIndent,
+ };
+ } else if (currentItem) {
+ if (currentItem.content.endsWith('\n')) {
+ currentItem.content += strippedLine;
+ } else {
+ currentItem.content += '\n' + strippedLine;
+ }
+ } else {
+ currentItem = {
+ type: 'text',
+ content: strippedLine,
+ indent: lineIndent,
+ };
+ }
+ }
+
+ pushCurrentItem();
+
+ if (parsed.length === 0) return;
+
+ const line0 = parsed[0];
+ const baseIndent = line0.indent;
+ const isSingle = parsed.length === 1;
+ const line0Content = textBefore + line0.content + (isSingle ? textAfter : '');
+
+ const isCurrentEmpty = (currentBlock.content || '').trim() === '';
+ const adoptedType = line0.type !== 'text' ? line0.type : (isCurrentEmpty ? 'text' : currentBlock.type);
+ const adoptedChecked = line0.type === 'todo' ? (line0.checked ?? false) : currentBlock.checked;
+
+ const updatedCurrentBlock: DocumentBlock = {
+ ...currentBlock,
+ content: line0Content,
+ type: adoptedType,
+ checked: adoptedChecked,
+ };
+
+ const newBlocks: DocumentBlock[] = [];
+ const nowBase = Date.now().toString(36);
+
+ for (let j = 1; j < parsed.length; j++) {
+ const p = parsed[j];
+ const isLastLine = j === parsed.length - 1;
+ const lineContent = isLastLine ? (p.content + textAfter) : p.content;
+ const lineLevel = Math.max(0, baseLevel + (p.indent - baseIndent));
+
+ newBlocks.push({
+ id: `blk_${nowBase}_${j}_${Math.random().toString(36).slice(2, 6)}`,
+ documentId: docId,
+ type: p.type,
+ checked: p.checked || false,
+ content: lineContent,
+ level: lineLevel,
+ collapsed: false,
+ position: (currentBlock.position || 1000) + j * 10,
+ });
+ }
+
+ const lastTargetId = newBlocks.length > 0 ? newBlocks[newBlocks.length - 1].id : currentBlock.id;
+ const existingEl = textareaMapRef.current.get(lastTargetId);
+ if (existingEl) {
+ pendingFocusRef.current = null;
+ focusBlockInput(lastTargetId, 'end');
+ } else {
+ pendingFocusRef.current = { blockId: lastTargetId, cursor: 'end' };
+ }
+ setFocusedBlockId(lastTargetId);
+
+ if (onInsertBlocks) {
+ onInsertBlocks(currentBlock.id, updatedCurrentBlock, newBlocks);
+ } else {
+ onUpdateBlock(updatedCurrentBlock, true);
+ for (let k = 0; k < newBlocks.length; k++) {
+ const afterId = k === 0 ? currentBlock.id : newBlocks[k - 1].id;
+ onCreateBlock(newBlocks[k], afterId);
+ }
+ }
+ },
+ [blocks, canEdit, docId, focusBlockInput, onCreateBlock, onInsertBlocks, onUpdateBlock]
+ );
+
const handleDeleteBlock = useCallback(
- (blockId: string) => {
+ (blockId: string, deleteSubtree = false) => {
if (!canEdit) return;
const idx = blocks.findIndex((b) => b.id === blockId);
if (idx === -1) return;
@@ -219,9 +493,25 @@ export const OutlinerTree: React.FC = ({
if (targetFocusBlock) {
focusBlockInput(targetFocusBlock.id, 'end');
}
- onDeleteBlock(blockId);
+
+ if (deleteSubtree) {
+ const rootLevel = blocks[idx].level || 0;
+ const idsToDelete = [blockId];
+ for (let j = idx + 1; j < blocks.length; j++) {
+ if ((blocks[j].level || 0) > rootLevel) {
+ idsToDelete.push(blocks[j].id);
+ } else {
+ break;
+ }
+ }
+ for (const id of idsToDelete) {
+ onDeleteBlock(id);
+ }
+ } else {
+ onDeleteBlock(blockId);
+ }
},
- [blocks, canEdit, focusBlockInput, onDeleteBlock, onUpdateBlock]
+ [blocks, canEdit, focusBlockInput, levelOffset, onDeleteBlock, onUpdateBlock]
);
const handleIndentBlock = useCallback(
@@ -233,9 +523,9 @@ export const OutlinerTree: React.FC = ({
const current = blocks[idx];
const currentLevel = Math.max(0, current.level || 0);
const prevLevel = idx > 0 ? Math.max(0, blocks[idx - 1].level || 0) : 0;
- const maxLevel = idx === 0 ? 0 : prevLevel + 1;
-
const minLevel = levelOffset || 0;
+ const maxLevel = idx === 0 ? minLevel : prevLevel + 1;
+
const nextLevel =
delta === 1
? Math.min(maxLevel, currentLevel + 1)
@@ -243,15 +533,38 @@ export const OutlinerTree: React.FC = ({
if (nextLevel === currentLevel) return;
- onUpdateBlock(
- {
- ...current,
- level: nextLevel,
- },
- true
- );
+ const actualDelta = nextLevel - currentLevel;
+
+ // Find all contiguous children / descendants:
+ // contiguous blocks with level > currentLevel
+ let count = 1;
+ for (let i = idx + 1; i < blocks.length; i++) {
+ if (Math.max(0, blocks[i].level || 0) > currentLevel) {
+ count++;
+ } else {
+ break;
+ }
+ }
+
+ const updatedBlocks: DocumentBlock[] = [];
+ for (let i = idx; i < idx + count; i++) {
+ const b = blocks[i];
+ const newLvl = Math.max(minLevel, (b.level || 0) + actualDelta);
+ updatedBlocks.push({
+ ...b,
+ level: newLvl,
+ });
+ }
+
+ if (onUpdateBlocks) {
+ onUpdateBlocks(updatedBlocks);
+ } else {
+ for (const b of updatedBlocks) {
+ onUpdateBlock(b, true);
+ }
+ }
},
- [blocks, canEdit, onUpdateBlock]
+ [blocks, canEdit, levelOffset, onUpdateBlock, onUpdateBlocks]
);
const handleNavigateFocus = useCallback(
@@ -274,10 +587,29 @@ export const OutlinerTree: React.FC = ({
setDraggedIndex(index);
}, []);
- const handleDragOverBlock = useCallback((e: React.DragEvent, index: number) => {
- e.preventDefault();
- setDragOverIndex(index);
- }, []);
+ const handleDragOverBlock = useCallback(
+ (e: React.DragEvent, index: number) => {
+ e.preventDefault();
+ if (draggedIndex !== null) {
+ const parentLevel = Math.max(0, blocks[draggedIndex]?.level || 0);
+ let count = 1;
+ for (let i = draggedIndex + 1; i < blocks.length; i++) {
+ if (Math.max(0, blocks[i].level || 0) > parentLevel) {
+ count++;
+ } else {
+ break;
+ }
+ }
+ // Do not allow dropping inside its own subtree (self or descendants)
+ if (index >= draggedIndex && index < draggedIndex + count) {
+ setDragOverIndex(null);
+ return;
+ }
+ }
+ setDragOverIndex(index);
+ },
+ [blocks, draggedIndex]
+ );
const handleDropBlock = useCallback(
(targetIndex: number) => {
@@ -286,6 +618,20 @@ export const OutlinerTree: React.FC = ({
setDragOverIndex(null);
return;
}
+ const parentLevel = Math.max(0, blocks[draggedIndex]?.level || 0);
+ let count = 1;
+ for (let i = draggedIndex + 1; i < blocks.length; i++) {
+ if (Math.max(0, blocks[i].level || 0) > parentLevel) {
+ count++;
+ } else {
+ break;
+ }
+ }
+ if (targetIndex >= draggedIndex && targetIndex < draggedIndex + count) {
+ setDraggedIndex(null);
+ setDragOverIndex(null);
+ return;
+ }
const movingBlock = blocks[draggedIndex];
if (movingBlock) {
onMoveBlock(movingBlock.id, targetIndex, movingBlock.level);
@@ -305,78 +651,81 @@ export const OutlinerTree: React.FC = ({
return () => window.removeEventListener('dragend', clearDrag);
}, []);
- const cursorsByBlock = useMemo(() => {
- const map: Record = {};
- if (!remoteCursors) return map;
- for (const c of Object.values(remoteCursors)) {
- if (c.blockId) {
- if (!map[c.blockId]) map[c.blockId] = [];
- map[c.blockId].push(c);
- }
- }
- return map;
- }, [remoteCursors]);
-
return (
- {visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }) => (
-
- ))}
+ {visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }, entryIdx) => {
+ const isLast = entryIdx === visibleEntries.length - 1;
+ return (
+
+ );
+ })}
{canEdit && (
-
-
{
+ {
+ if (e.target !== e.currentTarget && (e.target as HTMLElement).closest('button, a, input, textarea, select')) {
+ return;
+ }
+ if (blocks.length === 0) {
+ const firstBlock: DocumentBlock = {
+ id: `blk_${Date.now().toString(36)}_0`,
+ documentId: docId,
+ type: 'text',
+ content: '',
+ level: levelOffset || 0,
+ position: 1000,
+ };
+ pendingFocusRef.current = { blockId: firstBlock.id, cursor: 'start' };
+ onCreateBlock(firstBlock);
+ } else {
const lastBlock = blocks[blocks.length - 1];
- if (lastBlock) {
- handleCreateBlockBelow(lastBlock.id, lastBlock.content);
+ if (lastBlock.content && lastBlock.content.trim() !== '') {
+ handleCreateBlockBelow(lastBlock.id, '');
} else {
- const firstBlock: DocumentBlock = {
- id: `blk_${Date.now().toString(36)}_0`,
- documentId: docId,
- type: 'text',
- content: '',
- level: levelOffset || 0,
- position: 1000,
- };
- pendingFocusRef.current = { blockId: firstBlock.id, cursor: 'start' };
- onCreateBlock(firstBlock);
+ focusBlockInput(lastBlock.id, 'end');
}
- }}
- className="btn-action inline-flex items-center gap-1.5 px-2.5 py-1 text-xs text-muted hover:text-main"
- >
-
-
Neuer Punkt
-
-
+ }
+ }}
+ onDragOver={(e) => {
+ e.preventDefault();
+ if (draggedIndex !== null) {
+ setDragOverIndex(blocks.length);
+ }
+ }}
+ onDrop={() => handleDropBlock(blocks.length)}
+ />
)}
);
-};
+});
diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx
index 0aa8cb7..60b8650 100644
--- a/frontend/src/features/workspace/WorkspaceView.tsx
+++ b/frontend/src/features/workspace/WorkspaceView.tsx
@@ -23,6 +23,10 @@ import {
PanelLeftOpen,
ChevronLeft,
Radio,
+ Clock,
+ AlertTriangle,
+ Loader2,
+ Lock,
} from 'lucide-react';
import {
Document,
@@ -44,15 +48,16 @@ import {
getRealtimeTransport,
RealtimeTransport,
} from '../../lib/api';
-import { DocSyncStatus } from '../../lib/useDocumentSync';
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { useMobileActions } from '../../context/MobileActionContext';
-import { DocumentEditor } from './DocumentEditor';
+import { DocumentEditor, DocSaveStatus } from './DocumentEditor';
interface WorkspaceViewProps {
currentUser?: User | null;
initialDocId?: string | null;
+ initialBlockId?: string | null;
onStatsChange: () => void;
+ onOpenTask?: (taskId: string) => void;
}
type WorkspaceSubView = 'editor' | 'tag_detail' | 'todos' | 'tags';
@@ -78,6 +83,7 @@ interface FolderTreeItemProps {
handleFolderDragStart: (e: React.DragEvent, path: string) => void;
handleDocDragStart: (e: React.DragEvent, id: string) => void;
handleCreateDocument: (folderPath?: string) => void;
+ handleOpenNewFolder: (parentPath?: string, e?: React.MouseEvent) => void;
handleShareFolderClick: (folderPath: string, existingSf?: SharedFolder) => void;
handleDeleteFolder: (folderPath: string) => void;
onSelectDoc: (id: string) => void;
@@ -96,13 +102,14 @@ const FolderTreeItem: React.FC
= ({
handleFolderDragStart,
handleDocDragStart,
handleCreateDocument,
+ handleOpenNewFolder,
handleShareFolderClick,
handleDeleteFolder,
onSelectDoc,
}) => {
- const isExpanded = Boolean(expandedFolders[node.fullPath]);
- const isDragOver = dragOverFolder === node.fullPath;
const hasContent = node.subfolders.length > 0 || node.docs.length > 0;
+ const isExpanded = Boolean(hasContent && expandedFolders[node.fullPath]);
+ const isDragOver = dragOverFolder === node.fullPath;
return (
@@ -129,6 +136,7 @@ const FolderTreeItem: React.FC
= ({
+ hasContent &&
setExpandedFolders((prev) => ({
...prev,
[node.fullPath]: !prev[node.fullPath],
@@ -142,9 +150,7 @@ const FolderTreeItem: React.FC = ({
) : (
)
- ) : (
-
- )}
+ ) : null}
{isExpanded ? (
) : (
@@ -154,9 +160,6 @@ const FolderTreeItem: React.FC = ({
{node.sharedFolderObj && (
)}
-
- {node.docs.length}
-
@@ -166,18 +169,29 @@ const FolderTreeItem: React.FC
= ({
e.stopPropagation();
handleCreateDocument(node.fullPath);
}}
- className="p-1 h-6 w-6 flex items-center justify-center rounded-btn hover:bg-canvas text-muted hover:text-main cursor-pointer"
+ className="p-1 h-6 w-6 flex items-center justify-center text-muted hover:text-main transition-colors cursor-pointer"
title="Notiz in diesem Ordner erstellen"
>
+ {
+ e.stopPropagation();
+ handleOpenNewFolder(node.fullPath, e);
+ }}
+ className="p-1 h-6 w-6 flex items-center justify-center text-muted hover:text-main transition-colors cursor-pointer"
+ title="Neuen Unterordner erstellen"
+ >
+
+
{
e.stopPropagation();
handleShareFolderClick(node.fullPath, node.sharedFolderObj);
}}
- className={`p-1 h-6 w-6 flex items-center justify-center rounded-btn hover:bg-canvas cursor-pointer ${
+ className={`p-1 h-6 w-6 flex items-center justify-center transition-colors cursor-pointer ${
node.sharedFolderObj ? 'text-accent' : 'text-faint hover:text-accent'
}`}
title="Ordner teilen und Berechtigungen"
@@ -190,7 +204,7 @@ const FolderTreeItem: React.FC = ({
e.stopPropagation();
handleDeleteFolder(node.fullPath);
}}
- className="p-1 h-6 w-6 flex items-center justify-center rounded-btn hover:bg-status-err/20 text-faint hover:text-status-err cursor-pointer"
+ className="p-1 h-6 w-6 flex items-center justify-center text-faint hover:text-status-err transition-colors cursor-pointer"
title="Ordner löschen"
>
@@ -216,6 +230,7 @@ const FolderTreeItem: React.FC = ({
handleFolderDragStart={handleFolderDragStart}
handleDocDragStart={handleDocDragStart}
handleCreateDocument={handleCreateDocument}
+ handleOpenNewFolder={handleOpenNewFolder}
handleShareFolderClick={handleShareFolderClick}
handleDeleteFolder={handleDeleteFolder}
onSelectDoc={onSelectDoc}
@@ -232,7 +247,7 @@ const FolderTreeItem: React.FC = ({
style={{ paddingLeft: `${(depth + 1) * 12 + 16}px` }}
className={`w-full flex items-center gap-2 pr-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${
selectedDocId === d.id && subView === 'editor'
- ? 'bg-panel border-accent text-main font-semibold shadow-xs'
+ ? 'bg-panel-elevated text-main font-semibold border-border/80'
: 'border-transparent text-main hover:bg-panel-elevated'
}`}
>
@@ -258,15 +273,39 @@ const FolderTreeItem: React.FC = ({
export const WorkspaceView: React.FC = ({
currentUser,
initialDocId,
+ initialBlockId,
onStatsChange,
+ onOpenTask,
}) => {
const [docs, setDocs] = useState([]);
const [folders, setFolders] = useState([]);
const [sharedFolders, setSharedFolders] = useState([]);
- const [tagsSummary, setTagsSummary] = useState([]);
+ const [serverTagsSummary, setServerTagsSummary] = useState([]);
+
+ // Dynamically compute hashtag counts directly from in-memory documents
+ const tagsSummary = useMemo(() => {
+ if (docs.length === 0) return serverTagsSummary;
+ const tagMap = new Map();
+ for (const d of docs) {
+ for (const b of d.blocks || []) {
+ const matches = b.content?.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g);
+ if (matches) {
+ for (const m of matches) {
+ const clean = m.slice(1).toLowerCase();
+ tagMap.set(clean, (tagMap.get(clean) || 0) + 1);
+ }
+ }
+ }
+ }
+ const list: TagSummary[] = [];
+ tagMap.forEach((count, tag) => {
+ list.push({ tag, count });
+ });
+ return list.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
+ }, [docs, serverTagsSummary]);
const [selectedDocId, setSelectedDocId] = useState(initialDocId || null);
- const [targetBlockId, setTargetBlockId] = useState(null);
+ const [targetBlockId, setTargetBlockId] = useState(initialBlockId || null);
const [newlyCreatedDocId, setNewlyCreatedDocId] = useState(null);
const [subView, setSubView] = useState('editor');
const [selectedTag, setSelectedTag] = useState(null);
@@ -276,10 +315,40 @@ export const WorkspaceView: React.FC = ({
const [tagSortBy, setTagSortBy] = useState<'count' | 'name'>('count');
const [searchQuery, setSearchQuery] = useState('');
- const [expandedFolders, setExpandedFolders] = useState>({});
+ const [expandedFolders, setExpandedFolders] = useState>(() => {
+ try {
+ const saved = localStorage.getItem('mindsift:workspace_expanded_folders');
+ return saved ? JSON.parse(saved) : {};
+ } catch {
+ return {};
+ }
+ });
+
+ useEffect(() => {
+ try {
+ localStorage.setItem('mindsift:workspace_expanded_folders', JSON.stringify(expandedFolders));
+ } catch {}
+ }, [expandedFolders]);
+
const [showNewFolderModal, setShowNewFolderModal] = useState(false);
+ const [newFolderCoords, setNewFolderCoords] = useState<{ x: number; y: number } | null>(null);
+ const [newFolderParentPath, setNewFolderParentPath] = useState('');
const [newFolderName, setNewFolderName] = useState('');
const [newFolderIsShared, setNewFolderIsShared] = useState(false);
+
+ const handleOpenNewFolder = (parentPath = '', e?: React.MouseEvent) => {
+ setNewFolderParentPath(parentPath);
+ setNewFolderName('');
+ if (e) {
+ setNewFolderCoords({
+ x: Math.min(e.clientX, window.innerWidth - 270),
+ y: Math.min(e.clientY, window.innerHeight - 130),
+ });
+ } else {
+ setNewFolderCoords({ x: 100, y: 150 });
+ }
+ setShowNewFolderModal(true);
+ };
const [mobileShowDetail, setMobileShowDetail] = useState(Boolean(initialDocId));
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => {
try {
@@ -302,7 +371,8 @@ export const WorkspaceView: React.FC = ({
// Realtime connection status for top header indicator (identical to SharedListsView)
const [realtimeConnected, setRealtimeConnected] = useState(() => isRealtimeConnected());
const [realtimeTransport, setRealtimeTransport] = useState(() => getRealtimeTransport());
- const [activeDocSyncStatus, setActiveDocSyncStatus] = useState('connected');
+ const [docSaveStatus, setDocSaveStatus] = useState('saved');
+ const [docSaveError, setDocSaveError] = useState(null);
useEffect(() => {
const unsub = onRealtimeStatus((connected, transport) => {
@@ -319,8 +389,9 @@ export const WorkspaceView: React.FC = ({
// Rollup drilldown & View Mode
const [todoFolderDrilldown, setTodoFolderDrilldown] = useState(null);
- const [todoGrouping, setTodoGrouping] = useState<'folder_note' | 'note' | 'folder'>(() => {
- return (localStorage.getItem('mindsift_todo_grouping') as any) || 'folder_note';
+ const [todoGrouping, setTodoGrouping] = useState<'grouped' | 'list'>(() => {
+ const saved = localStorage.getItem('mindsift_todo_grouping');
+ return saved === 'list' ? 'list' : 'grouped';
});
const [collapsedTodoFolders, setCollapsedTodoFolders] = useState>(new Set());
const [collapsedTodoNotes, setCollapsedTodoNotes] = useState>(new Set());
@@ -346,7 +417,7 @@ export const WorkspaceView: React.FC = ({
setDocs(docsRes);
setFolders(foldersRes);
setSharedFolders(sharedFoldersRes);
- setTagsSummary(tagsRes);
+ setServerTagsSummary(tagsRes);
setSelectedDocId((prevId) => {
if (prevId && docsRes.some((d) => d.id === prevId)) {
@@ -510,14 +581,11 @@ export const WorkspaceView: React.FC = ({
const handleCreateFolderSubmit = async (e: React.FormEvent) => {
e.preventDefault();
- const clean = newFolderName.trim().replace(/^\/+|\/+$/g, '');
- if (!clean) return;
+ const cleanName = newFolderName.trim().replace(/^\/+|\/+$/g, '');
+ if (!cleanName) return;
+ const cleanPath = newFolderParentPath ? `${newFolderParentPath}/${cleanName}` : cleanName;
try {
- if (newFolderIsShared) {
- const sf = await api.createSharedFolder({ name: clean, mountPath: clean });
- setSharedFolders((prev) => [...prev, sf]);
- }
- const res = await api.createFolder(clean);
+ const res = await api.createFolder(cleanPath);
setFolders((prev) => (prev.includes(res.path) ? prev : [...prev, res.path].sort()));
const parts = res.path.split('/');
let acc = '';
@@ -528,10 +596,10 @@ export const WorkspaceView: React.FC = ({
}
setExpandedFolders((prev) => ({ ...prev, ...expansions }));
setNewFolderName('');
- setNewFolderIsShared(false);
+ setNewFolderParentPath('');
setShowNewFolderModal(false);
- } catch {
- // ignore
+ } catch (err: any) {
+ alert(err.message || 'Fehler beim Erstellen des Ordners');
}
};
@@ -1133,50 +1201,61 @@ export const WorkspaceView: React.FC = ({
- {/* Right Header Section: Live Sync Status (identical to SharedListsView) */}
+ {/* Right Header Section: Save Status & Live Collaboration Button */}
- {(() => {
- const isDocActive = subView === 'editor' && Boolean(selectedDocId);
- const isConnected = isDocActive
- ? activeDocSyncStatus === 'connected'
- : realtimeConnected;
- const isConnecting = isDocActive && (activeDocSyncStatus === 'connecting' || activeDocSyncStatus === 'reconnecting');
- const isSSE = !isDocActive && realtimeTransport === 'sse';
-
- return (
-
-
-
- );
- })()}
+ {subView === 'editor' && selectedDocId ? (
+ /* Save Status Pill */
+
+ {docSaveStatus === 'saved' && (
+ <>
+
+
Gespeichert
+ >
+ )}
+ {docSaveStatus === 'saving' && (
+ <>
+
+
Speichert…
+ >
+ )}
+ {docSaveStatus === 'unsaved' && (
+ <>
+
+
Ungespeichert
+ >
+ )}
+ {docSaveStatus === 'error' && (
+ <>
+
+
Fehler
+ >
+ )}
+
+ ) : null}
{/* Main Workspace Split Pane */}
-
+
{/* LEFT MASTER SIDEBAR */}