diff --git a/frontend/src/features/workspace/DocumentEditor.tsx b/frontend/src/features/workspace/DocumentEditor.tsx index cf67ee3..66829de 100644 --- a/frontend/src/features/workspace/DocumentEditor.tsx +++ b/frontend/src/features/workspace/DocumentEditor.tsx @@ -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 = ({ docId, currentUser, @@ -76,6 +160,21 @@ export const DocumentEditor: React.FC = ({ const [error, setError] = useState(null); const [showEmojiPicker, setShowEmojiPicker] = useState(false); const [showFolderMenu, setShowFolderMenu] = useState(false); + const [zoomedBlockId, setZoomedBlockId] = useState(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(null); const titleDebounceRef = useRef(null); @@ -640,75 +739,227 @@ export const DocumentEditor: React.FC = ({ {/* Main Content Area */}
- {/* Document Icon + Title Header */} -
-
- - {showEmojiPicker && canEdit && ( -
- {EMOJI_PALETTE.map((emoji) => ( + {zoomedBlock ? ( + /* ========================================================================= + FOKUS-MODUS & ZOOM-IN (HOISTED SUBTREE VIEW) + ========================================================================= */ +
+ {/* Clickable Breadcrumb Bar */} + {breadcrumbs.length > 0 && ( + + )} + + {/* Prominent Zoomed Ast-Heading */} +
+
+
+ + + Fokus-Modus + + + Isolierter Ast • Zoom aktiv + +
+ { + 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" + /> +
+ + +
+ + {/* Subtree Children or Empty State */} + {childBlocks.length === 0 ? ( +
+

Dieser Ast hat noch keine Unterpunkte.

+ {canEdit && ( + )} +
+ ) : ( + + )} +
+ ) : ( + /* ========================================================================= + STANDARD GESAMTDOKUMENT-ANSICHT + ========================================================================= */ + <> + {/* Document Icon + Title Header */} +
+
+ + {showEmojiPicker && canEdit && ( +
+ {EMOJI_PALETTE.map((emoji) => ( + + ))} +
+ )} +
+ + +
+ + {/* Extracted #Tags Summary Bar */} + {extractedTags.length > 0 && ( +
+ + {extractedTags.map((tag) => ( + ))}
)} -
- -
- - {/* Extracted #Tags Summary Bar */} - {extractedTags.length > 0 && ( -
- - {extractedTags.map((tag) => ( - - ))} -
+ {/* Logseq Outliner Tree */} + + )} - - {/* Logseq Outliner Tree */} -
); diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx index 2a696fa..08584f4 100644 --- a/frontend/src/features/workspace/OutlinerBlock.tsx +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -15,6 +15,7 @@ import { MessageSquare, Hash, ExternalLink, + Maximize2, } from 'lucide-react'; import { DocumentBlock, BlockType } from '../../lib/types'; import { RemoteCursor } from '../../lib/useDocumentSync'; @@ -46,6 +47,8 @@ export interface OutlinerBlockProps { canEdit: boolean; activeRemoteCursors?: RemoteCursor[]; sendCursorMoved?: (blockId: string | null, selectionStart?: number, selectionEnd?: number) => void; + levelOffset?: number; + onZoomBlock?: (blockId: string) => void; onFocusBlock: (blockId: string) => void; onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void; onCreateBlockBelow: (afterBlockId: string, currentContent: string) => void; @@ -69,6 +72,8 @@ export const OutlinerBlock: React.FC = ({ canEdit, activeRemoteCursors = [], sendCursorMoved, + levelOffset = 0, + onZoomBlock, onFocusBlock, onUpdateBlock, onCreateBlockBelow, @@ -334,6 +339,7 @@ export const OutlinerBlock: React.FC = ({ }; const level = Math.max(0, block.level || 0); + const visualLevel = Math.max(0, level - (levelOffset || 0)); // Parse rich tokens (WikiLinks [[Page]] and #Tags) const tokens = useMemo(() => { @@ -355,7 +361,7 @@ export const OutlinerBlock: React.FC = ({ : 'hover:bg-panel' }`} style={{ - paddingLeft: `${level * 22 + 4}px`, + paddingLeft: `${visualLevel * 22 + 4}px`, ...(primaryRemoteCursor ? { borderLeft: `2px solid ${primaryRemoteCursor.userColor}` } : {}), @@ -379,8 +385,8 @@ export const OutlinerBlock: React.FC = ({ )} {/* Indentation Guide Line */} - {level > 0 && - Array.from({ length: level }).map((_, lvlIdx) => ( + {visualLevel > 0 && + Array.from({ length: visualLevel }).map((_, lvlIdx) => (
= ({ ) : ( @@ -670,6 +670,16 @@ export const OutlinerBlock: React.FC = ({ )}
+ {/* Zoom / Focus button on hover */} + + {/* Delete button on hover */} {canEdit && (