diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index 7e0ed8c..6b3592b 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -1069,6 +1069,84 @@ export const WorkspaceView: React.FC = ({ // LOGSEQ OUTLINER CORE LOGIC // ============================================================================ + // Zoom-in / Hoisting Calculation + const { visibleBlocks, breadcrumbBlocks, zoomedBlock } = useMemo(() => { + if (!selectedDoc) return { visibleBlocks: [], breadcrumbBlocks: [], zoomedBlock: null }; + const all = selectedDoc.blocks; + + if (!zoomedBlockId) { + // Normal view: filter out children of collapsed blocks + const list: { block: DocumentBlock; originalIndex: number }[] = []; + let hideUntilLevel = -1; + + for (let i = 0; i < all.length; i++) { + const b = all[i]; + const lvl = b.level || 0; + + if (hideUntilLevel !== -1) { + if (lvl > hideUntilLevel) { + continue; // Skip collapsed child + } else { + hideUntilLevel = -1; // End of collapsed subtree + } + } + + list.push({ block: b, originalIndex: i }); + + if (b.collapsed) { + hideUntilLevel = lvl; + } + } + + return { visibleBlocks: list, breadcrumbBlocks: [], zoomedBlock: null }; + } + + // Zoomed view: find zoomed block and its descendants + const zoomIdx = all.findIndex((b) => b.id === zoomedBlockId); + if (zoomIdx === -1) { + return { visibleBlocks: [], breadcrumbBlocks: [], zoomedBlock: null }; + } + + const target = all[zoomIdx]; + const targetLevel = target.level || 0; + + // Build breadcrumbs: find parents with strictly decreasing levels + const crumbs: DocumentBlock[] = []; + let curLevel = targetLevel; + for (let i = zoomIdx - 1; i >= 0; i--) { + const b = all[i]; + const lvl = b.level || 0; + if (lvl < curLevel) { + crumbs.unshift(b); + curLevel = lvl; + } + } + + // Descendants of zoomed block (respecting collapsed sub-branches inside the zoomed view) + const descendants: { block: DocumentBlock; originalIndex: number }[] = []; + let hideUntilLevel = -1; + for (let i = zoomIdx + 1; i < all.length; i++) { + const b = all[i]; + const lvl = b.level || 0; + if (lvl <= targetLevel) { + break; + } + if (hideUntilLevel !== -1) { + if (lvl > hideUntilLevel) { + continue; + } else { + hideUntilLevel = -1; + } + } + descendants.push({ block: b, originalIndex: i }); + if (b.collapsed) { + hideUntilLevel = lvl; + } + } + + return { visibleBlocks: descendants, breadcrumbBlocks: crumbs, zoomedBlock: target }; + }, [selectedDoc, zoomedBlockId]); + // Helper: check if a block has children (i.e. next block has greater level) const hasChildren = (blocks: DocumentBlock[], index: number): boolean => { if (index + 1 >= blocks.length) return false; @@ -1076,6 +1154,78 @@ export const WorkspaceView: React.FC = ({ return (blocks[index + 1].level || 0) > currentLevel; }; + // Zoom into a bullet (or back out to parent/root) and ensure childless bullets get an editable sub-bullet + const handleZoomTo = (targetBlockId: string | null) => { + if (!selectedDoc) { + setZoomedBlockId(targetBlockId); + return; + } + + let workingBlocks = [...selectedDoc.blocks]; + let blocksChanged = false; + + // If zooming out/away from a zoomed block whose only descendant is a completely empty bullet, clean it up + if (!isReadOnly && zoomedBlock && targetBlockId !== zoomedBlock.id) { + const curZoomIdx = workingBlocks.findIndex((b) => b.id === zoomedBlock.id); + if (curZoomIdx !== -1) { + const curZoomLevel = workingBlocks[curZoomIdx].level || 0; + const descIndices: number[] = []; + for (let i = curZoomIdx + 1; i < workingBlocks.length; i++) { + if ((workingBlocks[i].level || 0) > curZoomLevel) { + descIndices.push(i); + } else { + break; + } + } + if (descIndices.length === 1) { + const onlyChild = workingBlocks[descIndices[0]]; + if ( + onlyChild.id !== targetBlockId && + onlyChild.type === 'bullet' && + (!onlyChild.content || onlyChild.content.trim() === '') + ) { + workingBlocks.splice(descIndices[0], 1); + blocksChanged = true; + } + } + } + } + + if (targetBlockId && !isReadOnly) { + const targetIdx = workingBlocks.findIndex((b) => b.id === targetBlockId); + if (targetIdx !== -1 && !hasChildren(workingBlocks, targetIdx)) { + const childLevel = (workingBlocks[targetIdx].level || 0) + 1; + const newChild: DocumentBlock = { + id: `blk_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, + type: 'bullet', + content: '', + level: childLevel, + checked: false, + }; + workingBlocks.splice(targetIdx + 1, 0, newChild); + persistDocument({ ...selectedDoc, blocks: workingBlocks }); + setZoomedBlockId(targetBlockId); + setLastFocusedBlockId(newChild.id); + setBlockCursor(newChild.id, 0); + setTimeout(() => { + const el = blockInputRefs.current[newChild.id]; + if (el) { + el.focus(); + if ('setSelectionRange' in el && typeof (el as any).value === 'string') { + (el as any).setSelectionRange(0, 0); + } + } + }, 30); + return; + } + } + + if (blocksChanged) { + persistDocument({ ...selectedDoc, blocks: workingBlocks }); + } + setZoomedBlockId(targetBlockId); + }; + // Insert a new bullet immediately after index, inheriting level const insertNewBulletAfter = ( afterIndex: number, @@ -1127,13 +1277,14 @@ export const WorkspaceView: React.FC = ({ } }; - // Outdent block (Shift+Tab): decrease level by 1 + // Outdent block (Shift+Tab): decrease level by 1 (without leaving active zoomed subtree) const outdentBlock = (blockId: string) => { if (isReadOnly || !selectedDoc) return; const block = selectedDoc.blocks.find((b) => b.id === blockId); if (!block) return; const currentLevel = block.level || 0; - if (currentLevel > 0) { + const minLevel = zoomedBlock ? (zoomedBlock.level || 0) + 1 : 0; + if (currentLevel > minLevel) { updateBlock(blockId, { level: currentLevel - 1 }); } }; @@ -1185,7 +1336,20 @@ export const WorkspaceView: React.FC = ({ } // Determine fallback block to focus - const prevBlock = index > 0 ? blocks[index - 1] : nextBlocks[0]; + let prevBlock: DocumentBlock | undefined; + if (zoomedBlock) { + const zoomIdx = blocks.findIndex((b) => b.id === zoomedBlock.id); + if (index > zoomIdx + 1) { + prevBlock = blocks[index - 1]; + } else { + const nextFirstChild = nextBlocks[zoomIdx + 1]; + if (nextFirstChild && (nextFirstChild.level || 0) > (zoomedBlock.level || 0)) { + prevBlock = nextFirstChild; + } + } + } else { + prevBlock = index > 0 ? blocks[index - 1] : nextBlocks[0]; + } persistDocument({ ...selectedDoc, blocks: nextBlocks }); @@ -1358,10 +1522,15 @@ export const WorkspaceView: React.FC = ({ const target = e.target as HTMLTextAreaElement | HTMLInputElement; const fullContent = block.content || ''; if (!fullContent || fullContent === '') { - if ((block.level || 0) > 0) { + const minLevel = zoomedBlock ? (zoomedBlock.level || 0) + 1 : 0; + if ((block.level || 0) > minLevel) { e.preventDefault(); outdentBlock(block.id); - } else if (selectedDoc?.blocks && selectedDoc.blocks.length > 1) { + } else if ( + zoomedBlock + ? visibleBlocks.length > 1 + : selectedDoc?.blocks && selectedDoc.blocks.length > 1 + ) { e.preventDefault(); setSlashBlockId(null); deleteBlockAndFocusPrev(index); @@ -1564,6 +1733,39 @@ export const WorkspaceView: React.FC = ({ const handleCanvasBottomClick = () => { if (isReadOnly || !selectedDoc) return; + + if (zoomedBlock) { + const zoomIdx = selectedDoc.blocks.findIndex((b) => b.id === zoomedBlock.id); + if (zoomIdx === -1) return; + const minChildLevel = (zoomedBlock.level || 0) + 1; + + let lastDescendantIndex = zoomIdx; + for (let i = zoomIdx + 1; i < selectedDoc.blocks.length; i++) { + if ((selectedDoc.blocks[i].level || 0) >= minChildLevel) { + lastDescendantIndex = i; + } else { + break; + } + } + + if (lastDescendantIndex === zoomIdx) { + insertNewBulletAfter(zoomIdx, minChildLevel); + return; + } + + const lastVisible = visibleBlocks[visibleBlocks.length - 1]; + if ( + lastVisible && + lastVisible.block.type !== 'divider' && + (!lastVisible.block.content || lastVisible.block.content.trim() === '') + ) { + focusBlock(lastVisible.block.id); + } else { + insertNewBulletAfter(lastDescendantIndex, minChildLevel); + } + return; + } + if (selectedDoc.blocks.length === 0) { insertNewBulletAfter(-1, 0); return; @@ -1739,73 +1941,6 @@ export const WorkspaceView: React.FC = ({ setDraggingBlockId(null); }; - // Zoom-in / Hoisting Calculation - const { visibleBlocks, breadcrumbBlocks, zoomedBlock } = useMemo(() => { - if (!selectedDoc) return { visibleBlocks: [], breadcrumbBlocks: [], zoomedBlock: null }; - const all = selectedDoc.blocks; - - if (!zoomedBlockId) { - // Normal view: filter out children of collapsed blocks - const list: { block: DocumentBlock; originalIndex: number }[] = []; - let hideUntilLevel = -1; - - for (let i = 0; i < all.length; i++) { - const b = all[i]; - const lvl = b.level || 0; - - if (hideUntilLevel !== -1) { - if (lvl > hideUntilLevel) { - continue; // Skip collapsed child - } else { - hideUntilLevel = -1; // End of collapsed subtree - } - } - - list.push({ block: b, originalIndex: i }); - - if (b.collapsed) { - hideUntilLevel = lvl; - } - } - - return { visibleBlocks: list, breadcrumbBlocks: [], zoomedBlock: null }; - } - - // Zoomed view: find zoomed block and its descendants - const zoomIdx = all.findIndex((b) => b.id === zoomedBlockId); - if (zoomIdx === -1) { - return { visibleBlocks: [], breadcrumbBlocks: [], zoomedBlock: null }; - } - - const target = all[zoomIdx]; - const targetLevel = target.level || 0; - - // Build breadcrumbs: find parents with strictly decreasing levels - const crumbs: DocumentBlock[] = []; - let curLevel = targetLevel; - for (let i = zoomIdx - 1; i >= 0; i--) { - const b = all[i]; - const lvl = b.level || 0; - if (lvl < curLevel) { - crumbs.unshift(b); - curLevel = lvl; - } - } - - // Descendants of zoomed block - const descendants: { block: DocumentBlock; originalIndex: number }[] = []; - for (let i = zoomIdx + 1; i < all.length; i++) { - const b = all[i]; - if ((b.level || 0) > targetLevel) { - descendants.push({ block: b, originalIndex: i }); - } else { - break; - } - } - - return { visibleBlocks: descendants, breadcrumbBlocks: crumbs, zoomedBlock: target }; - }, [selectedDoc, zoomedBlockId]); - // Export full document as portable Logseq-indented Markdown const handleExportMarkdown = () => { if (!selectedDoc) return; @@ -3112,7 +3247,7 @@ export const WorkspaceView: React.FC = ({
)} @@ -3237,6 +3390,25 @@ export const WorkspaceView: React.FC = ({ } }} > + {zoomedBlock && visibleBlocks.length === 0 && !isReadOnly && ( +
{ + e.stopPropagation(); + handleCanvasBottomClick(); + }} + className="group flex items-center gap-1 py-1 cursor-text text-faint hover:text-muted transition-colors" + > +
+
+
+ +
+ + Klicken, um einen Unterpunkt zu erstellen… + +
+ )} + {visibleBlocks.map(({ block, originalIndex }) => { const blockLevel = Math.max(0, (block.level || 0) - (zoomedBlock ? (zoomedBlock.level || 0) + 1 : 0)); const isChildrenPresent = hasChildren(selectedDoc.blocks, originalIndex); @@ -3279,7 +3451,7 @@ export const WorkspaceView: React.FC = ({ onDragStart={(e) => !isReadOnly && handleDragStart(e, block.id)} onDragOver={(e) => e.preventDefault()} onDrop={(e) => !isReadOnly && handleDropOnBlock(e, block.id)} - onClick={() => setZoomedBlockId(block.id)} + onClick={() => handleZoomTo(block.id)} className={`w-4 h-4 mt-1 rounded-pill flex items-center justify-center transition-colors flex-shrink-0 ${ isReadOnly ? 'cursor-pointer text-muted'