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
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:
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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user