fix(workspace): fix outliner cursor focus, enhanced task linking, nav order and Hauptebene drop zone
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 19s

This commit is contained in:
sandrodach committed 2026-10-27 20:26:45 +01:00
1 parent d39d1d1122
commit 94acb2638e
7 files changed
+300 -182

No files matched your search

+55 -47
View File
@@ -128,6 +128,7 @@ export const App: React.FC = () => {
const [previousTab, setPreviousTab] = useState<ActiveTab>('triage');
const [selectedDocId, setSelectedDocId] = useState<string | null>(null);
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [captureFocusCount, setCaptureFocusCount] = useState(0);
const [refreshRevision, setRefreshRevision] = useState(0);
@@ -250,13 +251,6 @@ export const App: React.FC = () => {
id: 'triage-nav',
title: 'Bereiche wechseln',
items: [
{
id: 'goto-tasks',
label: 'Aufgaben & Kanban',
icon: <CheckSquare className="w-4 h-4" />,
badge: stats.tasksOpenCount,
onClick: () => setActiveTab('tasks'),
},
{
id: 'goto-workspace',
label: 'Notizen & Workspace',
@@ -264,6 +258,13 @@ export const App: React.FC = () => {
badge: stats.docsCount,
onClick: () => setActiveTab('workspace'),
},
{
id: 'goto-tasks',
label: 'Aufgaben & Kanban',
icon: <CheckSquare className="w-4 h-4" />,
badge: stats.tasksOpenCount,
onClick: () => setActiveTab('tasks'),
},
{
id: 'goto-lists',
label: 'Einkaufslisten',
@@ -298,18 +299,18 @@ export const App: React.FC = () => {
variant: 'accent',
onClick: () => setActiveTab('triage'),
},
{
id: 'goto-tasks',
label: 'Zu den Aufgaben',
icon: <CheckSquare className="w-4 h-4" />,
onClick: () => setActiveTab('tasks'),
},
{
id: 'goto-workspace',
label: 'Zum Workspace',
icon: <FileText className="w-4 h-4" />,
onClick: () => setActiveTab('workspace'),
},
{
id: 'goto-tasks',
label: 'Zu den Aufgaben',
icon: <CheckSquare className="w-4 h-4" />,
onClick: () => setActiveTab('tasks'),
},
{
id: 'goto-lists',
label: 'Zu den Listen',
@@ -556,6 +557,11 @@ export const App: React.FC = () => {
setActiveTab('workspace');
};
const handleOpenTask = useCallback((taskId: string) => {
setSelectedTaskId(taskId);
setActiveTab('tasks');
}, []);
const handleTriggerAddButton = () => {
triggerHaptic(20);
if (window.innerWidth < 640) {
@@ -874,24 +880,6 @@ export const App: React.FC = () => {
</span>
</button>
<button
onClick={() => {
triggerHaptic(10);
setActiveTab('tasks');
}}
className={`tab-btn ${activeTab === 'tasks' ? 'tab-btn-active' : ''}`}
>
<CheckSquare className="w-4 h-4" />
<span className={`relative inline-flex items-center ${stats.tasksOpenCount > 0 ? 'pr-2' : ''}`}>
<span>Aufgaben</span>
{stats.tasksOpenCount > 0 && (
<span className="absolute -top-1.5 -right-1 min-w-[15px] h-[15px] px-0.5 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-sm pointer-events-none">
{stats.tasksOpenCount}
</span>
)}
</span>
</button>
<button
onClick={() => {
triggerHaptic(10);
@@ -910,6 +898,24 @@ export const App: React.FC = () => {
</span>
</button>
<button
onClick={() => {
triggerHaptic(10);
setActiveTab('tasks');
}}
className={`tab-btn ${activeTab === 'tasks' ? 'tab-btn-active' : ''}`}
>
<CheckSquare className="w-4 h-4" />
<span className={`relative inline-flex items-center ${stats.tasksOpenCount > 0 ? 'pr-2' : ''}`}>
<span>Aufgaben</span>
{stats.tasksOpenCount > 0 && (
<span className="absolute -top-1.5 -right-1 min-w-[15px] h-[15px] px-0.5 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-sm pointer-events-none">
{stats.tasksOpenCount}
</span>
)}
</span>
</button>
<button
onClick={() => {
triggerHaptic(10);
@@ -962,6 +968,7 @@ export const App: React.FC = () => {
onRefreshStats={refreshAll}
onOpenDocument={handleOpenDocument}
refreshTrigger={refreshRevision}
initialSelectedTaskId={selectedTaskId}
/>
)}
@@ -971,6 +978,7 @@ export const App: React.FC = () => {
initialDocId={selectedDocId}
initialBlockId={selectedBlockId}
onStatsChange={refreshAll}
onOpenTask={handleOpenTask}
/>
)}
@@ -1037,27 +1045,27 @@ export const App: React.FC = () => {
<span className="text-[10px] leading-tight">Triage</span>
</button>
{/* Col 2: Aufgaben */}
{/* Col 2: Workspace */}
<button
type="button"
onClick={() => {
triggerHaptic(10);
setActiveTab('tasks');
setActiveTab('workspace');
}}
className={`min-h-[48px] rounded-xl flex flex-col items-center justify-center gap-0.5 transition-colors relative cursor-pointer active:scale-95 ${
activeTab === 'tasks' ? 'text-accent font-semibold' : 'text-muted hover:text-main'
activeTab === 'workspace' ? 'text-accent font-semibold' : 'text-muted hover:text-main'
}`}
title="Aufgaben"
title="Workspace"
>
<div className="relative">
<CheckSquare className="w-5 h-5" />
{stats.tasksOpenCount > 0 && (
<FileText className="w-5 h-5" />
{(stats.docsTodosOpenCount ?? 0) > 0 && (
<span className="absolute -top-1.5 -right-2.5 min-w-[16px] h-[16px] px-1 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-xs pointer-events-none">
{stats.tasksOpenCount}
{stats.docsTodosOpenCount}
</span>
)}
</div>
<span className="text-[10px] leading-tight">Aufgaben</span>
<span className="text-[10px] leading-tight">Workspace</span>
</button>
{/* Col 3 (EXACT CENTER): + Quick-Add FAB */}
@@ -1079,27 +1087,27 @@ export const App: React.FC = () => {
</button>
</div>
{/* Col 4: Workspace */}
{/* Col 4: Aufgaben */}
<button
type="button"
onClick={() => {
triggerHaptic(10);
setActiveTab('workspace');
setActiveTab('tasks');
}}
className={`min-h-[48px] rounded-xl flex flex-col items-center justify-center gap-0.5 transition-colors relative cursor-pointer active:scale-95 ${
activeTab === 'workspace' ? 'text-accent font-semibold' : 'text-muted hover:text-main'
activeTab === 'tasks' ? 'text-accent font-semibold' : 'text-muted hover:text-main'
}`}
title="Workspace"
title="Aufgaben"
>
<div className="relative">
<FileText className="w-5 h-5" />
{(stats.docsTodosOpenCount ?? 0) > 0 && (
<CheckSquare className="w-5 h-5" />
{stats.tasksOpenCount > 0 && (
<span className="absolute -top-1.5 -right-2.5 min-w-[16px] h-[16px] px-1 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-xs pointer-events-none">
{stats.docsTodosOpenCount}
{stats.tasksOpenCount}
</span>
)}
</div>
<span className="text-[10px] leading-tight">Workspace</span>
<span className="text-[10px] leading-tight">Aufgaben</span>
</button>
{/* Col 5: Listen */}
+16 -2
View File
@@ -48,6 +48,7 @@ interface TasksViewProps {
onRefreshStats: () => void;
onOpenDocument: (docId: string, blockId?: string) => void;
refreshTrigger?: number;
initialSelectedTaskId?: string | null;
}
export const TasksView: React.FC<TasksViewProps> = ({
@@ -55,6 +56,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
onRefreshStats,
onOpenDocument,
refreshTrigger = 0,
initialSelectedTaskId,
}) => {
const [viewMode, setViewMode] = useState<'list' | 'kanban'>('list');
const [filter, setFilter] = useState<'open' | 'done' | 'discarded'>('open');
@@ -101,6 +103,18 @@ export const TasksView: React.FC<TasksViewProps> = ({
// Detail modal state
const [detailTaskId, setDetailTaskId] = useState<string | null>(null);
const [showDetailModal, setShowDetailModal] = useState(false);
const [extraTask, setExtraTask] = useState<Thought | null>(null);
useEffect(() => {
if (initialSelectedTaskId) {
setDetailTaskId(initialSelectedTaskId);
setShowDetailModal(true);
setFilter('open');
api.getThought(initialSelectedTaskId).then((th) => {
if (th) setExtraTask(th);
}).catch(() => {});
}
}, [initialSelectedTaskId]);
// Manage columns modal state
const [showManageColumnsModal, setShowManageColumnsModal] = useState(false);
@@ -243,8 +257,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
const detailTask = useMemo(() => {
if (!detailTaskId) return null;
return tasks.find((t) => t.id === detailTaskId) || null;
}, [detailTaskId, tasks]);
return tasks.find((t) => t.id === detailTaskId) || (extraTask?.id === detailTaskId ? extraTask : null);
}, [detailTaskId, tasks, extraTask]);
const safeTeams = Array.isArray(teams) ? teams : [];
const safeCollaborators = Array.isArray(collaborators) ? collaborators : [];
@@ -17,7 +17,7 @@ import {
} from 'lucide-react';
import { Document, DocumentBlock, User } from '../../lib/types';
import { api, getToken } from '../../lib/api';
import { OutlinerTree } from './OutlinerTree';
import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree';
export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error';
@@ -34,6 +34,7 @@ export interface DocumentEditorProps {
onSelectTag: (tag: string) => void;
onWikiLinkClick?: (pageTitle: string) => void;
onBackMobile?: () => void;
onOpenTask?: (taskId: string) => void;
onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void;
}
@@ -209,6 +210,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
onSelectTag,
onWikiLinkClick,
onBackMobile,
onOpenTask,
onSaveStatusChange,
}) => {
const [doc, setDoc] = useState<Document | null>(initialDoc || null);
@@ -239,6 +241,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
const isInitialLoadRef = useRef<boolean>(true);
const titleInputRef = useRef<HTMLTextAreaElement | HTMLInputElement | null>(null);
const outlinerTreeRef = useRef<OutlinerTreeHandle | null>(null);
const docRef = useRef<Document | null>(doc);
docRef.current = doc;
@@ -492,13 +495,20 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
};
const handleUpdateBlock = useCallback(
(updatedBlock: DocumentBlock) => {
(updatedBlock: DocumentBlock, immediate: boolean = false) => {
if (!canEdit) return;
setBlocks((prev) =>
prev.map((b) => (b.id === updatedBlock.id ? updatedBlock : b))
);
setBlocks((prev) => {
const next = prev.map((b) => (b.id === updatedBlock.id ? updatedBlock : b));
currentBlocksRef.current = next;
return next;
});
if (immediate) {
window.setTimeout(() => {
executeAutoSave(true);
}, 10);
}
},
[canEdit]
[canEdit, executeAutoSave]
);
const handleUpdateBlocks = useCallback(
@@ -939,6 +949,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
onCreateBlock={handleCreateBlock}
onDeleteBlock={handleDeleteBlock}
onMoveBlock={handleMoveBlock}
onOpenTask={onOpenTask}
onTagClick={onSelectTag}
onWikiLinkClick={onWikiLinkClick}
initialFocusedBlockId={initialFocusedBlockId}
@@ -977,6 +988,20 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
className="input-field w-full pl-7 pr-2 py-1 text-xs"
/>
</div>
{doc?.icon && (
<button
type="button"
onClick={() => {
handleSelectIcon('');
setShowEmojiPicker(false);
setEmojiSearch('');
}}
className="w-full text-left px-2 py-1 text-status-warn/90 hover:bg-status-warn/10 rounded text-xs font-medium flex items-center gap-1.5 cursor-pointer"
>
<Trash2 className="w-3 h-3" />
<span>Icon entfernen</span>
</button>
)}
{emojiSearch.trim() && (
<button
type="button"
@@ -1034,6 +1059,12 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
e.target.style.height = 'auto';
e.target.style.height = `${e.target.scrollHeight}px`;
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
outlinerTreeRef.current?.focusFirstBlock();
}
}}
onBlur={handleTitleBlur}
placeholder="Titel der Notiz…"
className="flex-1 min-w-0 text-xl sm:text-2xl font-bold text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight resize-none leading-snug whitespace-pre-wrap break-words [overflow-wrap:anywhere]"
@@ -1042,6 +1073,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
{/* Logseq Outliner Tree */}
<OutlinerTree
ref={outlinerTreeRef}
docId={docId}
blocks={blocks}
canEdit={canEdit}
@@ -1052,6 +1084,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
onCreateBlock={handleCreateBlock}
onDeleteBlock={handleDeleteBlock}
onMoveBlock={handleMoveBlock}
onOpenTask={onOpenTask}
onTagClick={onSelectTag}
onWikiLinkClick={onWikiLinkClick}
initialFocusedBlockId={initialFocusedBlockId}
+111 -75
View File
@@ -44,6 +44,7 @@ const SLASH_COMMANDS: SlashCommand[] = [
];
export interface OutlinerBlockProps {
docId: string;
block: DocumentBlock;
index: number;
isLast: boolean;
@@ -58,6 +59,8 @@ export interface OutlinerBlockProps {
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;
@@ -69,6 +72,7 @@ export interface OutlinerBlockProps {
}
export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
docId,
block,
index,
isLast,
@@ -83,6 +87,8 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
onDeleteBlock,
onIndentBlock,
onNavigateFocus,
onFocusBlock,
onOpenTask,
onTagClick,
onWikiLinkClick,
onDragStartBlock,
@@ -93,6 +99,8 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
registerTextareaRef,
}) => {
const [localContent, setLocalContent] = useState<string>(block.content || '');
const [isLocallyFocused, setIsLocallyFocused] = useState<boolean>(false);
const activeFocus = isFocused || isLocallyFocused;
const [slashSearch, setSlashSearch] = useState<string | null>(null);
const [slashIndex, setSlashIndex] = useState(0);
const [showLinkModal, setShowLinkModal] = useState<boolean>(false);
@@ -199,7 +207,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
useLayoutEffect(() => {
adjustHeight();
}, [localContent, block.type, block.level, adjustHeight]);
}, [localContent, block.type, block.level, activeFocus, adjustHeight]);
useEffect(() => {
window.addEventListener('resize', adjustHeight);
@@ -299,10 +307,14 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
const handleFocus = () => {
isFocusedRef.current = true;
setIsLocallyFocused(true);
onFocusBlock?.(block.id);
setTimeout(adjustHeight, 0);
};
const handleBlur = () => {
isFocusedRef.current = false;
setIsLocallyFocused(false);
if (debounceTimerRef.current !== null) {
window.clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
@@ -440,7 +452,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
? 'border-t-2 border-accent bg-accent/10'
: isTargeted
? 'bg-accent/15 ring-2 ring-accent'
: isFocused
: activeFocus
? 'bg-panel-elevated'
: 'hover:bg-panel'
}`}
@@ -500,7 +512,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
className={`w-1.5 h-1.5 rounded-full transition-all ${
block.collapsed && hasChildren
? 'bg-accent ring-2 ring-accent/30'
: isFocused
: activeFocus
? 'bg-accent scale-125'
: 'bg-faint group-hover/bullet:bg-accent group-hover/bullet:scale-125'
}`}
@@ -525,16 +537,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
<Check className="w-3 h-3 stroke-[3]" />
</button>
)}
{block.taskId && (
<span
className="badge-accent text-[9px] inline-flex items-center gap-0.5 px-1 py-0.2 rounded font-medium shrink-0"
title="Mit erweiterter Aufgabe synchronisiert"
>
<CheckSquare className="w-2.5 h-2.5" />
<span>Erweitert</span>
</span>
)}
</div>
{/* Main Block Content */}
@@ -589,23 +591,101 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
</div>
) : (
<div className="relative w-full">
{!isFocused && localContent && (
<div
className={`absolute inset-0 cursor-text select-text pointer-events-none w-full leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] ${
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'
}`}
>
{renderInlineMarkdown(localContent)}
</div>
)}
{/* View Layer with Inline Markdown & Actions (3-dots or Erweitert Badge) directly behind text */}
<div
onClick={() => {
if (!activeFocus && canEdit) {
textareaRef.current?.focus();
}
}}
className={`w-full 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 */}
<span className={activeFocus ? 'invisible' : ''}>
{localContent ? renderInlineMarkdown(localContent) : ''}
</span>
{/* Inline Badge or 3-Dots directly behind the text */}
{block.type === 'todo' && (
<span className="inline-flex items-center align-middle ml-1.5 gap-1 shrink-0 pointer-events-auto z-10 relative select-none">
{block.taskId ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onOpenTask?.(block.taskId!);
}}
className="badge-accent text-[9px] inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded font-medium cursor-pointer hover:opacity-80 transition-opacity"
title="Zu dieser erweiterten Aufgabe wechseln"
>
<CheckSquare className="w-2.5 h-2.5" />
<span>Erweitert</span>
</button>
) : canEdit ? (
<span className="relative inline-flex items-center">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setShowTodoMenu((prev) => !prev);
}}
className="p-0.5 text-faint hover:text-accent rounded-btn transition-colors cursor-pointer"
title="Aufgaben-Optionen"
>
<MoreHorizontal className="w-3.5 h-3.5" />
</button>
{showTodoMenu && (
<div
className="absolute left-0 top-full mt-1 bg-panel border border-border shadow-lg rounded-card p-1 z-30 w-52 text-xs"
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
onClick={async () => {
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);
onOpenTask?.(thought.id);
} 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"
>
<CheckSquare className="w-3.5 h-3.5 text-accent" />
<span>In erweiterte Aufgabe umwandeln</span>
</button>
</div>
)}
</span>
) : null}
</span>
)}
</div>
<textarea
ref={(el) => {
textareaRef.current = el;
@@ -624,7 +704,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
: ''
}
className={`w-full resize-none overflow-hidden bg-transparent border-0 focus:outline-none focus:ring-0 leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] ${
!isFocused && localContent ? 'text-transparent selection:text-transparent' : ''
activeFocus ? 'relative z-0 text-main' : 'absolute inset-0 text-transparent selection:text-transparent pointer-events-none'
} ${
block.type === 'h1'
? 'text-lg font-bold text-main tracking-tight'
@@ -749,50 +829,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
)}
</div>
{/* Enhanced Task Option (3-dots) for To-Do Blocks */}
{canEdit && block.type === 'todo' && !block.taskId && (
<div className="relative shrink-0">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setShowTodoMenu((prev) => !prev);
}}
className="opacity-0 group-hover:opacity-100 p-0.5 text-faint hover:text-accent rounded-btn transition-opacity cursor-pointer"
title="Aufgaben-Optionen"
>
<MoreHorizontal className="w-3.5 h-3.5" />
</button>
{showTodoMenu && (
<div
className="absolute right-0 top-full mt-1 bg-panel border border-border shadow-lg rounded-card p-1 z-30 w-52 text-xs"
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
onClick={async () => {
setShowTodoMenu(false);
try {
const docId = block.documentId || block.docId || '';
const thought = await api.createEnhancedTask(docId, block.id, {
content: localContent || 'Aufgabe',
checked: Boolean(block.checked),
});
flushDebounce(localContent, { taskId: thought.id });
} 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"
>
<CheckSquare className="w-3.5 h-3.5 text-accent" />
<span>In erweiterte Aufgabe umwandeln</span>
</button>
</div>
)}
</div>
)}
{/* Zoom / Focus button on hover */}
<button
type="button"
@@ -1,8 +1,12 @@
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { Plus } from 'lucide-react';
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[];
@@ -14,12 +18,13 @@ export interface OutlinerTreeProps {
onCreateBlock: (newBlock: DocumentBlock, afterBlockId?: string) => 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<OutlinerTreeProps> = ({
export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
docId,
blocks,
canEdit,
@@ -30,10 +35,11 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
onCreateBlock,
onDeleteBlock,
onMoveBlock,
onOpenTask,
onTagClick,
onWikiLinkClick,
initialFocusedBlockId,
}) => {
}, ref) => {
const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null);
const [highlightedBlockId, setHighlightedBlockId] = useState<string | null>(
initialFocusedBlockId || null
@@ -70,6 +76,32 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
[]
);
useImperativeHandle(
ref,
() => ({
focusFirstBlock: () => {
if (blocks.length > 0) {
focusBlockInput(blocks[0].id, 'start');
} 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);
}
},
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);
@@ -380,6 +412,7 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
return (
<OutlinerBlock
key={block.id}
docId={docId}
block={block}
index={index}
isLast={isLast}
@@ -394,6 +427,8 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
onDeleteBlock={handleDeleteBlock}
onIndentBlock={handleIndentBlock}
onNavigateFocus={handleNavigateFocus}
onFocusBlock={setFocusedBlockId}
onOpenTask={onOpenTask}
onTagClick={onTagClick}
onWikiLinkClick={onWikiLinkClick}
onDragStartBlock={handleDragStartBlock}
@@ -408,9 +443,33 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
{canEdit && (
<div
className={`pt-2 pl-2 transition-colors ${
className={`min-h-[14rem] pt-2 pl-2 transition-colors cursor-text ${
dragOverIndex === blocks.length ? 'border-t-2 border-accent bg-accent/10 rounded' : ''
}`}
onClick={(e) => {
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.content && lastBlock.content.trim() !== '') {
handleCreateBlockBelow(lastBlock.id, lastBlock.content);
} else {
focusBlockInput(lastBlock.id, 'end');
}
}
}}
onDragOver={(e) => {
e.preventDefault();
if (draggedIndex !== null) {
@@ -418,33 +477,8 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
}
}}
onDrop={() => handleDropBlock(blocks.length)}
>
<button
type="button"
onClick={() => {
const lastBlock = blocks[blocks.length - 1];
if (lastBlock) {
handleCreateBlockBelow(lastBlock.id, lastBlock.content);
} 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);
}
}}
className="btn-action inline-flex items-center gap-1.5 px-2.5 py-1 text-xs text-muted hover:text-main"
>
<Plus className="w-3.5 h-3.5" />
<span>Neuer Punkt</span>
</button>
</div>
/>
)}
</div>
);
};
});
@@ -57,6 +57,7 @@ interface WorkspaceViewProps {
initialDocId?: string | null;
initialBlockId?: string | null;
onStatsChange: () => void;
onOpenTask?: (taskId: string) => void;
}
type WorkspaceSubView = 'editor' | 'tag_detail' | 'todos' | 'tags';
@@ -274,6 +275,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
initialDocId,
initialBlockId,
onStatsChange,
onOpenTask,
}) => {
const [docs, setDocs] = useState<Document[]>([]);
const [folders, setFolders] = useState<string[]>([]);
@@ -1315,21 +1317,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
{/* Scrollable Navigation Tree */}
<div className="flex-1 overflow-y-auto p-2 space-y-3">
{/* Top drop target for moving to root */}
{(draggingDocId || draggingFolderPath) && (
<div
onDragOver={(e) => {
e.preventDefault();
setDragOverFolder('__root_top__');
}}
onDragLeave={() => setDragOverFolder(null)}
onDrop={(e) => handleDropOnFolder('', e)}
className={`h-2.5 -mx-1 my-0.5 rounded transition-all ${
dragOverFolder === '__root_top__' ? 'bg-accent/40 ring-1 ring-accent h-4' : 'opacity-0'
}`}
/>
)}
{/* Pinned Notes */}
{pinnedDocs.length > 0 && !searchQuery && (
<div>
@@ -1447,10 +1434,14 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}}
onDragLeave={() => setDragOverFolder(null)}
onDrop={(e) => handleDropOnFolder('', e)}
className={`min-h-[2.5rem] flex-1 rounded transition-all ${
dragOverFolder === '__root_bottom__' ? 'bg-accent/20 ring-1 ring-accent' : ''
className={`p-3 rounded-card border-2 border-dashed transition-all flex items-center justify-center text-xs font-medium text-center ${
dragOverFolder === '__root_bottom__'
? 'border-accent bg-accent/20 text-accent ring-2 ring-accent/30'
: 'border-border/60 bg-panel-elevated/40 text-muted hover:border-accent/40'
}`}
/>
>
<span>Auf Hauptebene verschieben</span>
</div>
)}
{/* Tags Explorer */}
@@ -1502,6 +1493,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
onSelectTag={handleSelectTag}
onWikiLinkClick={handleWikiLinkClick}
onBackMobile={() => setMobileShowDetail(false)}
onOpenTask={onOpenTask}
onSaveStatusChange={(status, err) => {
setDocSaveStatus(status);
setDocSaveError(err || null);
+3 -2
View File
@@ -2364,7 +2364,7 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st
if title != nil {
doc.Title = strings.TrimSpace(*title)
}
if icon != nil && *icon != "" {
if icon != nil {
doc.Icon = *icon
}
if tags != nil {
@@ -4185,12 +4185,13 @@ func (s *Store) CreateTaskFromDocBlock(userID, docID, blockID, content string, c
t.DocID = docID
t.BlockID = blockID
t.IsCompleted = checked
t.Status = domain.StatusKept
doneInt := 0
if checked {
doneInt = 1
t.KanbanStatus = "done"
}
_, _ = s.db.Exec(`UPDATE thoughts SET converted_type = 'document_block', converted_ref_id = ?, is_completed = ?, kanban_status = ? WHERE id = ?`,
_, _ = s.db.Exec(`UPDATE thoughts SET converted_type = 'document_block', converted_ref_id = ?, is_completed = ?, kanban_status = ?, status = 'kept' WHERE id = ?`,
t.ConvertedRefID, doneInt, t.KanbanStatus, t.ID)
var updatedBlocks []domain.DocumentBlock