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,
|
ArrowLeft,
|
||||||
Hash,
|
Hash,
|
||||||
Lock,
|
Lock,
|
||||||
|
Home,
|
||||||
|
ChevronRight,
|
||||||
|
Minimize2,
|
||||||
|
Sparkles,
|
||||||
|
Plus,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Document, DocumentBlock, User } from '../../lib/types';
|
import { Document, DocumentBlock, User } from '../../lib/types';
|
||||||
import { api } from '../../lib/api';
|
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> = ({
|
export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||||
docId,
|
docId,
|
||||||
currentUser,
|
currentUser,
|
||||||
@@ -76,6 +160,21 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
|
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
|
||||||
const [showFolderMenu, setShowFolderMenu] = 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 titleInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const titleDebounceRef = useRef<number | null>(null);
|
const titleDebounceRef = useRef<number | null>(null);
|
||||||
@@ -640,75 +739,227 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<div className="max-w-4xl w-full mx-auto px-4 sm:px-8 py-5 flex-1">
|
<div className="max-w-4xl w-full mx-auto px-4 sm:px-8 py-5 flex-1">
|
||||||
{/* Document Icon + Title Header */}
|
{zoomedBlock ? (
|
||||||
<div className="flex items-center gap-3 mb-3 relative">
|
/* =========================================================================
|
||||||
<div className="relative">
|
FOKUS-MODUS & ZOOM-IN (HOISTED SUBTREE VIEW)
|
||||||
<button
|
========================================================================= */
|
||||||
type="button"
|
<div className="space-y-3">
|
||||||
onClick={() => canEdit && setShowEmojiPicker((prev) => !prev)}
|
{/* Clickable Breadcrumb Bar */}
|
||||||
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"
|
{breadcrumbs.length > 0 && (
|
||||||
title="Icon ändern"
|
<nav
|
||||||
>
|
aria-label="Breadcrumb Navigation"
|
||||||
{doc?.icon || '📝'}
|
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>
|
>
|
||||||
{showEmojiPicker && canEdit && (
|
<button
|
||||||
<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">
|
type="button"
|
||||||
{EMOJI_PALETTE.map((emoji) => (
|
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
|
<button
|
||||||
key={emoji}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSelectIcon(emoji)}
|
onClick={() => {
|
||||||
className="p-1.5 rounded-btn hover:bg-panel-elevated text-base flex items-center justify-center"
|
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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
<input
|
{/* Logseq Outliner Tree */}
|
||||||
ref={titleInputRef}
|
<OutlinerTree
|
||||||
type="text"
|
docId={docId}
|
||||||
value={title}
|
blocks={blocks}
|
||||||
readOnly={!canEdit}
|
canEdit={canEdit}
|
||||||
onChange={handleTitleChange}
|
levelOffset={0}
|
||||||
placeholder="Titel der Notiz…"
|
onZoomBlock={setZoomedBlockId}
|
||||||
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"
|
remoteCursors={remoteCursors}
|
||||||
/>
|
sendCursorMoved={sendCursorMoved}
|
||||||
</div>
|
onUpdateBlock={handleUpdateBlock}
|
||||||
|
onCreateBlock={handleCreateBlock}
|
||||||
{/* Extracted #Tags Summary Bar */}
|
onDeleteBlock={handleDeleteBlock}
|
||||||
{extractedTags.length > 0 && (
|
onMoveBlock={handleMoveBlock}
|
||||||
<div className="flex flex-wrap items-center gap-1.5 mb-4 pb-2 border-b border-border">
|
onTagClick={onSelectTag}
|
||||||
<Hash className="w-3.5 h-3.5 text-faint mr-0.5" />
|
onWikiLinkClick={onWikiLinkClick}
|
||||||
{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}
|
|
||||||
remoteCursors={remoteCursors}
|
|
||||||
sendCursorMoved={sendCursorMoved}
|
|
||||||
onUpdateBlock={handleUpdateBlock}
|
|
||||||
onCreateBlock={handleCreateBlock}
|
|
||||||
onDeleteBlock={handleDeleteBlock}
|
|
||||||
onMoveBlock={handleMoveBlock}
|
|
||||||
onTagClick={onSelectTag}
|
|
||||||
onWikiLinkClick={onWikiLinkClick}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
Hash,
|
Hash,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
Maximize2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { DocumentBlock, BlockType } from '../../lib/types';
|
import { DocumentBlock, BlockType } from '../../lib/types';
|
||||||
import { RemoteCursor } from '../../lib/useDocumentSync';
|
import { RemoteCursor } from '../../lib/useDocumentSync';
|
||||||
@@ -46,6 +47,8 @@ export interface OutlinerBlockProps {
|
|||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
activeRemoteCursors?: RemoteCursor[];
|
activeRemoteCursors?: RemoteCursor[];
|
||||||
sendCursorMoved?: (blockId: string | null, selectionStart?: number, selectionEnd?: number) => void;
|
sendCursorMoved?: (blockId: string | null, selectionStart?: number, selectionEnd?: number) => void;
|
||||||
|
levelOffset?: number;
|
||||||
|
onZoomBlock?: (blockId: string) => void;
|
||||||
onFocusBlock: (blockId: string) => void;
|
onFocusBlock: (blockId: string) => void;
|
||||||
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
|
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
|
||||||
onCreateBlockBelow: (afterBlockId: string, currentContent: string) => void;
|
onCreateBlockBelow: (afterBlockId: string, currentContent: string) => void;
|
||||||
@@ -69,6 +72,8 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
canEdit,
|
canEdit,
|
||||||
activeRemoteCursors = [],
|
activeRemoteCursors = [],
|
||||||
sendCursorMoved,
|
sendCursorMoved,
|
||||||
|
levelOffset = 0,
|
||||||
|
onZoomBlock,
|
||||||
onFocusBlock,
|
onFocusBlock,
|
||||||
onUpdateBlock,
|
onUpdateBlock,
|
||||||
onCreateBlockBelow,
|
onCreateBlockBelow,
|
||||||
@@ -334,6 +339,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const level = Math.max(0, block.level || 0);
|
const level = Math.max(0, block.level || 0);
|
||||||
|
const visualLevel = Math.max(0, level - (levelOffset || 0));
|
||||||
|
|
||||||
// Parse rich tokens (WikiLinks [[Page]] and #Tags)
|
// Parse rich tokens (WikiLinks [[Page]] and #Tags)
|
||||||
const tokens = useMemo(() => {
|
const tokens = useMemo(() => {
|
||||||
@@ -355,7 +361,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
: 'hover:bg-panel'
|
: 'hover:bg-panel'
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
paddingLeft: `${level * 22 + 4}px`,
|
paddingLeft: `${visualLevel * 22 + 4}px`,
|
||||||
...(primaryRemoteCursor
|
...(primaryRemoteCursor
|
||||||
? { borderLeft: `2px solid ${primaryRemoteCursor.userColor}` }
|
? { borderLeft: `2px solid ${primaryRemoteCursor.userColor}` }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -379,8 +385,8 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Indentation Guide Line */}
|
{/* Indentation Guide Line */}
|
||||||
{level > 0 &&
|
{visualLevel > 0 &&
|
||||||
Array.from({ length: level }).map((_, lvlIdx) => (
|
Array.from({ length: visualLevel }).map((_, lvlIdx) => (
|
||||||
<div
|
<div
|
||||||
key={lvlIdx}
|
key={lvlIdx}
|
||||||
className="pointer-events-none absolute top-0 bottom-0 w-px border-l border-border"
|
className="pointer-events-none absolute top-0 bottom-0 w-px border-l border-border"
|
||||||
@@ -440,15 +446,9 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => onZoomBlock?.(block.id)}
|
||||||
if (canEdit) {
|
className="w-4 h-4 flex items-center justify-center rounded-btn hover:bg-panel-hover group/bullet transition-transform"
|
||||||
flushDebounce(localContent, {
|
title="Klick: In diesen Ast hineinzoomen (Fokus-Modus)"
|
||||||
type: block.type === 'todo' ? 'bullet' : 'todo',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-4 h-4 flex items-center justify-center rounded-btn hover:bg-panel-hover"
|
|
||||||
title="Klick: Zu To-Do / Bullet umschalten"
|
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`w-1.5 h-1.5 rounded-full transition-all ${
|
className={`w-1.5 h-1.5 rounded-full transition-all ${
|
||||||
@@ -456,7 +456,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
? 'bg-accent ring-2 ring-accent/30'
|
? 'bg-accent ring-2 ring-accent/30'
|
||||||
: isFocused
|
: isFocused
|
||||||
? 'bg-accent scale-125'
|
? 'bg-accent scale-125'
|
||||||
: 'bg-faint group-hover:bg-accent'
|
: 'bg-faint group-hover/bullet:bg-accent group-hover/bullet:scale-125'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
@@ -670,6 +670,16 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Zoom / Focus button on hover */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onZoomBlock?.(block.id)}
|
||||||
|
className="opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-accent rounded-btn transition-opacity shrink-0"
|
||||||
|
title="In diesen Ast hineinzoomen (Fokus-Modus)"
|
||||||
|
>
|
||||||
|
<Maximize2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Delete button on hover */}
|
{/* Delete button on hover */}
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export interface OutlinerTreeProps {
|
|||||||
docId: string;
|
docId: string;
|
||||||
blocks: DocumentBlock[];
|
blocks: DocumentBlock[];
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
|
levelOffset?: number;
|
||||||
|
onZoomBlock?: (blockId: string) => void;
|
||||||
remoteCursors?: Record<string, RemoteCursor>;
|
remoteCursors?: Record<string, RemoteCursor>;
|
||||||
sendCursorMoved?: (blockId: string | null, selectionStart?: number, selectionEnd?: number) => void;
|
sendCursorMoved?: (blockId: string | null, selectionStart?: number, selectionEnd?: number) => void;
|
||||||
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
|
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
|
||||||
@@ -22,6 +24,8 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
|||||||
docId,
|
docId,
|
||||||
blocks,
|
blocks,
|
||||||
canEdit,
|
canEdit,
|
||||||
|
levelOffset = 0,
|
||||||
|
onZoomBlock,
|
||||||
remoteCursors,
|
remoteCursors,
|
||||||
sendCursorMoved,
|
sendCursorMoved,
|
||||||
onUpdateBlock,
|
onUpdateBlock,
|
||||||
@@ -105,7 +109,7 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
|||||||
const hasChildren = Boolean(nextBlock && nextLvl > lvl);
|
const hasChildren = Boolean(nextBlock && nextLvl > lvl);
|
||||||
|
|
||||||
const prevBlock = i > 0 ? blocks[i - 1] : null;
|
const prevBlock = i > 0 ? blocks[i - 1] : null;
|
||||||
const maxAllowedLevel = prevBlock ? Math.max(0, prevBlock.level || 0) + 1 : 0;
|
const maxAllowedLevel = prevBlock ? Math.max(0, prevBlock.level || 0) + 1 : (levelOffset || 0);
|
||||||
|
|
||||||
result.push({
|
result.push({
|
||||||
block: b,
|
block: b,
|
||||||
@@ -155,8 +159,8 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
|||||||
const idx = blocks.findIndex((b) => b.id === blockId);
|
const idx = blocks.findIndex((b) => b.id === blockId);
|
||||||
if (idx === -1) return;
|
if (idx === -1) return;
|
||||||
|
|
||||||
// If this is the only block in the document, clear its content/type instead of leaving 0 blocks
|
// If this is the only block in the document (and not in an isolated zoom subtree), clear instead of deleting
|
||||||
if (blocks.length <= 1) {
|
if (blocks.length <= 1 && !levelOffset) {
|
||||||
const only = blocks[0];
|
const only = blocks[0];
|
||||||
onUpdateBlock(
|
onUpdateBlock(
|
||||||
{
|
{
|
||||||
@@ -191,10 +195,11 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
|||||||
const prevLevel = idx > 0 ? Math.max(0, blocks[idx - 1].level || 0) : 0;
|
const prevLevel = idx > 0 ? Math.max(0, blocks[idx - 1].level || 0) : 0;
|
||||||
const maxLevel = idx === 0 ? 0 : prevLevel + 1;
|
const maxLevel = idx === 0 ? 0 : prevLevel + 1;
|
||||||
|
|
||||||
|
const minLevel = levelOffset || 0;
|
||||||
const nextLevel =
|
const nextLevel =
|
||||||
delta === 1
|
delta === 1
|
||||||
? Math.min(maxLevel, currentLevel + 1)
|
? Math.min(maxLevel, currentLevel + 1)
|
||||||
: Math.max(0, currentLevel - 1);
|
: Math.max(minLevel, currentLevel - 1);
|
||||||
|
|
||||||
if (nextLevel === currentLevel) return;
|
if (nextLevel === currentLevel) return;
|
||||||
|
|
||||||
@@ -283,6 +288,8 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
|||||||
hasChildren={hasChildren}
|
hasChildren={hasChildren}
|
||||||
isFocused={focusedBlockId === block.id}
|
isFocused={focusedBlockId === block.id}
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
|
levelOffset={levelOffset}
|
||||||
|
onZoomBlock={onZoomBlock}
|
||||||
activeRemoteCursors={cursorsByBlock[block.id] || []}
|
activeRemoteCursors={cursorsByBlock[block.id] || []}
|
||||||
sendCursorMoved={sendCursorMoved}
|
sendCursorMoved={sendCursorMoved}
|
||||||
onFocusBlock={setFocusedBlockId}
|
onFocusBlock={setFocusedBlockId}
|
||||||
@@ -315,7 +322,7 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
|||||||
documentId: docId,
|
documentId: docId,
|
||||||
type: 'text',
|
type: 'text',
|
||||||
content: '',
|
content: '',
|
||||||
level: 0,
|
level: levelOffset || 0,
|
||||||
position: 1000,
|
position: 1000,
|
||||||
};
|
};
|
||||||
pendingFocusRef.current = { blockId: firstBlock.id, cursor: 'start' };
|
pendingFocusRef.current = { blockId: firstBlock.id, cursor: 'start' };
|
||||||
|
|||||||
Reference in new issue
Block a user