Dev #3

Merged
sandrodach merged 30 commits from dev into main 2026-10-25 18:00:10 +01:00
Showing only changes of commit 646ed6d960 - Show all commits
+250 -78
View File
@@ -1069,6 +1069,84 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
// 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<WorkspaceViewProps> = ({
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<WorkspaceViewProps> = ({
}
};
// 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<WorkspaceViewProps> = ({
}
// 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<WorkspaceViewProps> = ({
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<WorkspaceViewProps> = ({
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<WorkspaceViewProps> = ({
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<WorkspaceViewProps> = ({
<div className="flex flex-wrap items-center gap-1.5 min-w-0">
<button
type="button"
onClick={() => setZoomedBlockId(null)}
onClick={() => handleZoomTo(null)}
className="text-muted hover:text-main flex items-center gap-1 font-medium transition-colors"
>
<span>{selectedDoc.icon || '📄'}</span>
@@ -3124,7 +3259,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
<ChevronRight className="w-3 h-3 text-faint flex-shrink-0" />
<button
type="button"
onClick={() => setZoomedBlockId(b.id)}
onClick={() => handleZoomTo(b.id)}
className="text-muted hover:text-main truncate max-w-[140px] font-medium transition-colors"
>
{b.content || 'Bullet'}
@@ -3210,18 +3345,36 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
<span className="w-2.5 h-2.5 rounded-pill bg-accent flex-shrink-0" />
<input
type="text"
readOnly={isReadOnly}
value={zoomedBlock.content}
onChange={(e) => updateBlock(zoomedBlock.id, { content: e.target.value })}
onFocus={handleBlockFocus}
onBlur={handleBlockBlur}
onChange={(e) => {
if (!isReadOnly) {
ensureLock();
updateBlock(zoomedBlock.id, { content: e.target.value });
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
if (e.key === 'Enter' && !isReadOnly) {
e.preventDefault();
if (
visibleBlocks.length > 0 &&
(!visibleBlocks[0].block.content || visibleBlocks[0].block.content.trim() === '')
) {
focusBlock(visibleBlocks[0].block.id);
return;
}
const zoomIdx = selectedDoc.blocks.findIndex((b) => b.id === zoomedBlock.id);
if (zoomIdx !== -1) {
insertNewBulletAfter(zoomIdx, (zoomedBlock.level || 0) + 1);
}
}
}}
className="w-full bg-transparent text-xl font-bold text-main focus:outline-none placeholder:text-faint"
placeholder="Aufzählungspunkt…"
className={`w-full bg-transparent text-xl font-bold text-main focus:outline-none placeholder:text-faint ${
isReadOnly ? 'cursor-default' : ''
}`}
/>
</div>
)}
@@ -3237,6 +3390,25 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}
}}
>
{zoomedBlock && visibleBlocks.length === 0 && !isReadOnly && (
<div
onClick={(e) => {
e.stopPropagation();
handleCanvasBottomClick();
}}
className="group flex items-center gap-1 py-1 cursor-text text-faint hover:text-muted transition-colors"
>
<div className="w-3 flex-shrink-0" />
<div className="w-4 flex-shrink-0" />
<div className="w-4 h-4 rounded-pill flex items-center justify-center flex-shrink-0">
<span className="w-1.5 h-1.5 rounded-pill bg-muted group-hover:bg-accent transition-colors" />
</div>
<span className="text-sm pl-0.5 select-none">
Klicken, um einen Unterpunkt zu erstellen…
</span>
</div>
)}
{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<WorkspaceViewProps> = ({
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'