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
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 19s
This commit is contained in:
1 parent
d39d1d1122
commit
94acb2638e
7 files changed
+300
-182
No files matched your search
+55
-47
@@ -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 */}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user