fix(workspace): support creating sub-bullets and clicking empty canvas in zoomed bullet views
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m3s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m3s
This commit is contained in:
1 parent
518a0903dd
commit
646ed6d960
1 file changed
+250
-78
@@ -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'
|
||||
|
||||
Reference in new issue
Block a user