feat(workspace): add focus mode and hoisting zoom-in for outliner blocks
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s

- Click bullet point (•) or hover zoom icon to zoom into any block branch
- Render hoisted block as prominent heading in focus mode with direct editing
- Display clickable breadcrumb path leading back to ancestors or root document
- Render isolated subtree with visual level offset and bound indentation
This commit is contained in:
sandrodach committed 2026-10-26 22:04:53 +01:00
1 parent 95977ba15a
commit 177e24fdc8
3 files changed
+346 -78

No files matched your search

@@ -11,6 +11,11 @@ import {
ArrowLeft,
Hash,
Lock,
Home,
ChevronRight,
Minimize2,
Sparkles,
Plus,
} from 'lucide-react';
import { Document, DocumentBlock, User } from '../../lib/types';
import { api } from '../../lib/api';
@@ -55,6 +60,85 @@ function ensureNonEmptyBlocks(docId: string, blocks?: DocumentBlock[]): Document
}));
}
export interface BreadcrumbNode {
id: string | null;
title: string;
}
export function computeBreadcrumbs(
docTitle: string,
blocks: DocumentBlock[],
zoomedBlockId: string | null
): BreadcrumbNode[] {
if (!zoomedBlockId) return [];
const zoomedIdx = blocks.findIndex((b) => b.id === zoomedBlockId);
if (zoomedIdx === -1) return [];
const trail: BreadcrumbNode[] = [];
const zoomedBlock = blocks[zoomedIdx];
let currentLevel = zoomedBlock.level;
for (let i = zoomedIdx - 1; i >= 0; i--) {
if (blocks[i].level < currentLevel) {
trail.unshift({
id: blocks[i].id,
title: blocks[i].content?.trim() || 'Punkt',
});
currentLevel = blocks[i].level;
if (currentLevel === 0) break;
}
}
trail.unshift({
id: null,
title: docTitle || 'Dokument',
});
trail.push({
id: zoomedBlock.id,
title: zoomedBlock.content?.trim() || 'Punkt',
});
return trail;
}
export function getZoomedSubtree(
blocks: DocumentBlock[],
zoomedBlockId: string | null
): {
zoomedBlock: DocumentBlock | null;
childBlocks: DocumentBlock[];
levelOffset: number;
} {
if (!zoomedBlockId) {
return { zoomedBlock: null, childBlocks: blocks, levelOffset: 0 };
}
const idx = blocks.findIndex((b) => b.id === zoomedBlockId);
if (idx === -1) {
return { zoomedBlock: null, childBlocks: blocks, levelOffset: 0 };
}
const zoomedBlock = blocks[idx];
const zLevel = zoomedBlock.level;
const childBlocks: DocumentBlock[] = [];
for (let i = idx + 1; i < blocks.length; i++) {
if (blocks[i].level > zLevel) {
childBlocks.push(blocks[i]);
} else {
break;
}
}
return {
zoomedBlock,
childBlocks,
levelOffset: zLevel + 1,
};
}
export const DocumentEditor: React.FC<DocumentEditorProps> = ({
docId,
currentUser,
@@ -76,6 +160,21 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
const [error, setError] = useState<string | null>(null);
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
const [showFolderMenu, setShowFolderMenu] = useState<boolean>(false);
const [zoomedBlockId, setZoomedBlockId] = useState<string | null>(null);
useEffect(() => {
if (zoomedBlockId && !blocks.some((b) => b.id === zoomedBlockId)) {
setZoomedBlockId(null);
}
}, [blocks, zoomedBlockId]);
const { zoomedBlock, childBlocks, levelOffset } = useMemo(() => {
return getZoomedSubtree(blocks, zoomedBlockId);
}, [blocks, zoomedBlockId]);
const breadcrumbs = useMemo(() => {
return computeBreadcrumbs(title || doc?.title || 'Dokument', blocks, zoomedBlockId);
}, [title, doc?.title, blocks, zoomedBlockId]);
const titleInputRef = useRef<HTMLInputElement | null>(null);
const titleDebounceRef = useRef<number | null>(null);
@@ -640,75 +739,227 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
{/* Main Content Area */}
<div className="max-w-4xl w-full mx-auto px-4 sm:px-8 py-5 flex-1">
{/* Document Icon + Title Header */}
<div className="flex items-center gap-3 mb-3 relative">
<div className="relative">
<button
type="button"
onClick={() => canEdit && setShowEmojiPicker((prev) => !prev)}
className="w-10 h-10 rounded-btn bg-panel-elevated border border-border hover:border-strong flex items-center justify-center text-xl transition-colors"
title="Icon ändern"
>
{doc?.icon || '📝'}
</button>
{showEmojiPicker && canEdit && (
<div className="absolute left-0 mt-1 p-2 panel-surface shadow-panel border border-border rounded-card grid grid-cols-6 gap-1 z-30 w-52">
{EMOJI_PALETTE.map((emoji) => (
{zoomedBlock ? (
/* =========================================================================
FOKUS-MODUS & ZOOM-IN (HOISTED SUBTREE VIEW)
========================================================================= */
<div className="space-y-3">
{/* Clickable Breadcrumb Bar */}
{breadcrumbs.length > 0 && (
<nav
aria-label="Breadcrumb Navigation"
className="flex items-center flex-wrap gap-1.5 py-1.5 px-3 bg-panel-elevated/70 border border-border rounded-card text-xs text-muted"
>
<button
type="button"
onClick={() => setZoomedBlockId(null)}
className="hover:text-accent inline-flex items-center gap-1 font-medium transition-colors"
title="Zurück zum Hauptdokument"
>
<Home className="w-3.5 h-3.5 text-accent" />
<span className="truncate max-w-[120px]">
{title || doc?.title || 'Dokument'}
</span>
</button>
{breadcrumbs.slice(1).map((crumb, idx) => {
const isCurrent = idx === breadcrumbs.length - 2;
return (
<React.Fragment key={crumb.id || idx}>
<ChevronRight className="w-3 h-3 text-faint shrink-0" />
{isCurrent ? (
<span
className="font-bold text-accent truncate max-w-[200px]"
title={crumb.title}
>
{crumb.title || 'Punkt'}
</span>
) : (
<button
type="button"
onClick={() => setZoomedBlockId(crumb.id)}
className="hover:text-main truncate max-w-[140px] transition-colors"
title={`Zoom zu "${crumb.title}"`}
>
{crumb.title || 'Punkt'}
</button>
)}
</React.Fragment>
);
})}
</nav>
)}
{/* Prominent Zoomed Ast-Heading */}
<div className="flex items-start justify-between gap-3 pb-4 mb-2 border-b border-border">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1.5">
<span className="inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-accent bg-accent-dim px-2 py-0.5 rounded-btn border border-accent/30">
<Sparkles className="w-2.5 h-2.5 text-accent animate-pulse" />
Fokus-Modus
</span>
<span className="text-[11px] text-faint hidden sm:inline">
Isolierter Ast • Zoom aktiv
</span>
</div>
<input
type="text"
value={zoomedBlock.content}
onChange={(e) => {
handleUpdateBlock({ ...zoomedBlock, content: e.target.value });
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const firstChild: DocumentBlock = {
id: `blk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
documentId: docId,
type: 'text',
content: '',
level: levelOffset,
position: (zoomedBlock.position || 1000) + 10,
};
handleCreateBlock(firstChild, zoomedBlock.id);
}
}}
placeholder="Ast-Titel…"
readOnly={!canEdit}
className="w-full text-2xl sm:text-3xl font-extrabold text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight leading-tight"
/>
</div>
<button
type="button"
onClick={() => setZoomedBlockId(null)}
className="btn-action inline-flex items-center gap-1.5 px-2.5 py-1 text-xs text-muted hover:text-main shrink-0 mt-1"
title="Fokus-Modus beenden"
>
<Minimize2 className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Fokus beenden</span>
</button>
</div>
{/* Subtree Children or Empty State */}
{childBlocks.length === 0 ? (
<div className="py-8 text-center text-xs text-faint border border-dashed border-border rounded-card my-2">
<p className="mb-2">Dieser Ast hat noch keine Unterpunkte.</p>
{canEdit && (
<button
key={emoji}
type="button"
onClick={() => handleSelectIcon(emoji)}
className="p-1.5 rounded-btn hover:bg-panel-elevated text-base flex items-center justify-center"
onClick={() => {
const firstChild: DocumentBlock = {
id: `blk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
documentId: docId,
type: 'text',
content: '',
level: levelOffset,
position: (zoomedBlock.position || 1000) + 10,
};
handleCreateBlock(firstChild, zoomedBlock.id);
}}
className="btn-action inline-flex items-center gap-1.5 px-3 py-1.5 text-xs text-accent font-medium hover:bg-accent/10"
>
{emoji}
<Plus className="w-3.5 h-3.5" />
<span>Ersten Unterpunkt anlegen (oder Enter drücken)</span>
</button>
)}
</div>
) : (
<OutlinerTree
docId={docId}
blocks={childBlocks}
canEdit={canEdit}
levelOffset={levelOffset}
onZoomBlock={setZoomedBlockId}
remoteCursors={remoteCursors}
sendCursorMoved={sendCursorMoved}
onUpdateBlock={handleUpdateBlock}
onCreateBlock={handleCreateBlock}
onDeleteBlock={handleDeleteBlock}
onMoveBlock={handleMoveBlock}
onTagClick={onSelectTag}
onWikiLinkClick={onWikiLinkClick}
/>
)}
</div>
) : (
/* =========================================================================
STANDARD GESAMTDOKUMENT-ANSICHT
========================================================================= */
<>
{/* Document Icon + Title Header */}
<div className="flex items-center gap-3 mb-3 relative">
<div className="relative">
<button
type="button"
onClick={() => canEdit && setShowEmojiPicker((prev) => !prev)}
className="w-10 h-10 rounded-btn bg-panel-elevated border border-border hover:border-strong flex items-center justify-center text-xl transition-colors"
title="Icon ändern"
>
{doc?.icon || '📝'}
</button>
{showEmojiPicker && canEdit && (
<div className="absolute left-0 mt-1 p-2 panel-surface shadow-panel border border-border rounded-card grid grid-cols-6 gap-1 z-30 w-52">
{EMOJI_PALETTE.map((emoji) => (
<button
key={emoji}
type="button"
onClick={() => handleSelectIcon(emoji)}
className="p-1.5 rounded-btn hover:bg-panel-elevated text-base flex items-center justify-center"
>
{emoji}
</button>
))}
</div>
)}
</div>
<input
ref={titleInputRef}
type="text"
value={title}
readOnly={!canEdit}
onChange={handleTitleChange}
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"
/>
</div>
{/* Extracted #Tags Summary Bar */}
{extractedTags.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5 mb-4 pb-2 border-b border-border">
<Hash className="w-3.5 h-3.5 text-faint mr-0.5" />
{extractedTags.map((tag) => (
<button
key={tag}
type="button"
onClick={() => onSelectTag(tag)}
className="badge-accent text-[11px] font-medium px-2 py-0.5 rounded-btn hover:opacity-85 transition-opacity"
>
#{tag}
</button>
))}
</div>
)}
</div>
<input
ref={titleInputRef}
type="text"
value={title}
readOnly={!canEdit}
onChange={handleTitleChange}
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"
/>
</div>
{/* Extracted #Tags Summary Bar */}
{extractedTags.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5 mb-4 pb-2 border-b border-border">
<Hash className="w-3.5 h-3.5 text-faint mr-0.5" />
{extractedTags.map((tag) => (
<button
key={tag}
type="button"
onClick={() => onSelectTag(tag)}
className="badge-accent text-[11px] font-medium px-2 py-0.5 rounded-btn hover:opacity-85 transition-opacity"
>
#{tag}
</button>
))}
</div>
{/* Logseq Outliner Tree */}
<OutlinerTree
docId={docId}
blocks={blocks}
canEdit={canEdit}
levelOffset={0}
onZoomBlock={setZoomedBlockId}
remoteCursors={remoteCursors}
sendCursorMoved={sendCursorMoved}
onUpdateBlock={handleUpdateBlock}
onCreateBlock={handleCreateBlock}
onDeleteBlock={handleDeleteBlock}
onMoveBlock={handleMoveBlock}
onTagClick={onSelectTag}
onWikiLinkClick={onWikiLinkClick}
/>
</>
)}
{/* Logseq Outliner Tree */}
<OutlinerTree
docId={docId}
blocks={blocks}
canEdit={canEdit}
remoteCursors={remoteCursors}
sendCursorMoved={sendCursorMoved}
onUpdateBlock={handleUpdateBlock}
onCreateBlock={handleCreateBlock}
onDeleteBlock={handleDeleteBlock}
onMoveBlock={handleMoveBlock}
onTagClick={onSelectTag}
onWikiLinkClick={onWikiLinkClick}
/>
</div>
</div>
);