feat(workspace): tabula-rasa outliner architecture, SQLite blocks/block_tags schema & echo-protected live room sync
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m12s

This commit is contained in:
sandrodach committed 2026-10-26 21:14:06 +01:00
1 parent f3b3f07e04
commit 86867b0d6f
17 files changed
+3409 -7178

No files matched your search

@@ -0,0 +1,312 @@
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { Plus } from 'lucide-react';
import { DocumentBlock } from '../../lib/types';
import { OutlinerBlock } from './OutlinerBlock';
export interface OutlinerTreeProps {
docId: string;
blocks: DocumentBlock[];
canEdit: boolean;
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
onCreateBlock: (newBlock: DocumentBlock, afterBlockId?: string) => void;
onDeleteBlock: (blockId: string) => void;
onMoveBlock: (blockId: string, newIndex: number, level?: number) => void;
onTagClick?: (tag: string) => void;
}
export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
docId,
blocks,
canEdit,
onUpdateBlock,
onCreateBlock,
onDeleteBlock,
onMoveBlock,
onTagClick,
}) => {
const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const textareaMapRef = useRef<Map<string, HTMLTextAreaElement>>(new Map());
const pendingFocusRef = useRef<{ blockId: string; cursor: 'start' | 'end' } | null>(null);
const registerTextareaRef = useCallback((blockId: string, el: HTMLTextAreaElement | null) => {
if (el) {
textareaMapRef.current.set(blockId, el);
if (pendingFocusRef.current && pendingFocusRef.current.blockId === blockId) {
const target = pendingFocusRef.current;
pendingFocusRef.current = null;
requestAnimationFrame(() => {
el.focus();
const pos = target.cursor === 'end' ? el.value.length : 0;
try {
el.setSelectionRange(pos, pos);
} catch {
// ignore
}
});
}
} else {
textareaMapRef.current.delete(blockId);
}
}, []);
const focusBlockInput = useCallback((blockId: string, cursor: 'start' | 'end' = 'end') => {
setFocusedBlockId(blockId);
const el = textareaMapRef.current.get(blockId);
if (el) {
requestAnimationFrame(() => {
el.focus();
const pos = cursor === 'end' ? el.value.length : 0;
try {
el.setSelectionRange(pos, pos);
} catch {
// ignore
}
});
} else {
pendingFocusRef.current = { blockId, cursor };
}
}, []);
// Compute visibility based on collapsed ancestor blocks
const visibleEntries = useMemo(() => {
const result: {
block: DocumentBlock;
index: number;
hasChildren: boolean;
maxAllowedLevel: number;
}[] = [];
let collapsedAboveLevel: number | null = null;
for (let i = 0; i < blocks.length; i++) {
const b = blocks[i];
const lvl = Math.max(0, b.level || 0);
if (collapsedAboveLevel !== null) {
if (lvl > collapsedAboveLevel) {
continue;
} else {
collapsedAboveLevel = null;
}
}
const nextBlock = blocks[i + 1];
const nextLvl = nextBlock ? Math.max(0, nextBlock.level || 0) : 0;
const hasChildren = Boolean(nextBlock && nextLvl > lvl);
const prevBlock = i > 0 ? blocks[i - 1] : null;
const maxAllowedLevel = prevBlock ? Math.max(0, prevBlock.level || 0) + 1 : 0;
result.push({
block: b,
index: i,
hasChildren,
maxAllowedLevel,
});
if (b.collapsed && hasChildren) {
collapsedAboveLevel = lvl;
}
}
return result;
}, [blocks]);
const handleCreateBlockBelow = useCallback(
(afterBlockId: string, _currentContent: string) => {
if (!canEdit) return;
const idx = blocks.findIndex((b) => b.id === afterBlockId);
const currentBlock = idx >= 0 ? blocks[idx] : null;
const level = currentBlock ? Math.max(0, currentBlock.level || 0) : 0;
const inheritedType =
currentBlock && currentBlock.type === 'todo' ? 'todo' : 'text';
const newBlock: DocumentBlock = {
id: `blk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
documentId: docId,
type: inheritedType,
content: '',
level,
checked: false,
collapsed: false,
position: ((idx >= 0 ? idx + 2 : blocks.length + 1) * 1000),
};
pendingFocusRef.current = { blockId: newBlock.id, cursor: 'start' };
setFocusedBlockId(newBlock.id);
onCreateBlock(newBlock, afterBlockId);
},
[blocks, canEdit, docId, onCreateBlock]
);
const handleDeleteBlock = useCallback(
(blockId: string) => {
if (!canEdit) return;
const idx = blocks.findIndex((b) => b.id === blockId);
if (idx === -1) return;
// If this is the only block in the document, clear its content/type instead of leaving 0 blocks
if (blocks.length <= 1) {
const only = blocks[0];
onUpdateBlock(
{
...only,
content: '',
type: 'text',
level: 0,
checked: false,
},
true
);
return;
}
const targetFocusBlock = idx > 0 ? blocks[idx - 1] : blocks[idx + 1];
if (targetFocusBlock) {
focusBlockInput(targetFocusBlock.id, 'end');
}
onDeleteBlock(blockId);
},
[blocks, canEdit, focusBlockInput, onDeleteBlock, onUpdateBlock]
);
const handleIndentBlock = useCallback(
(blockId: string, delta: 1 | -1) => {
if (!canEdit) return;
const idx = blocks.findIndex((b) => b.id === blockId);
if (idx === -1) return;
const current = blocks[idx];
const currentLevel = Math.max(0, current.level || 0);
const prevLevel = idx > 0 ? Math.max(0, blocks[idx - 1].level || 0) : 0;
const maxLevel = idx === 0 ? 0 : prevLevel + 1;
const nextLevel =
delta === 1
? Math.min(maxLevel, currentLevel + 1)
: Math.max(0, currentLevel - 1);
if (nextLevel === currentLevel) return;
onUpdateBlock(
{
...current,
level: nextLevel,
},
true
);
},
[blocks, canEdit, onUpdateBlock]
);
const handleNavigateFocus = useCallback(
(blockId: string, direction: 'up' | 'down') => {
const visIdx = visibleEntries.findIndex((e) => e.block.id === blockId);
if (visIdx === -1) return;
const targetEntry =
direction === 'up' ? visibleEntries[visIdx - 1] : visibleEntries[visIdx + 1];
if (targetEntry) {
focusBlockInput(
targetEntry.block.id,
direction === 'up' ? 'end' : 'start'
);
}
},
[focusBlockInput, visibleEntries]
);
const handleDragStartBlock = useCallback((index: number) => {
setDraggedIndex(index);
}, []);
const handleDragOverBlock = useCallback((e: React.DragEvent, index: number) => {
e.preventDefault();
setDragOverIndex(index);
}, []);
const handleDropBlock = useCallback(
(targetIndex: number) => {
if (draggedIndex === null || draggedIndex === targetIndex) {
setDraggedIndex(null);
setDragOverIndex(null);
return;
}
const movingBlock = blocks[draggedIndex];
if (movingBlock) {
onMoveBlock(movingBlock.id, targetIndex, movingBlock.level);
}
setDraggedIndex(null);
setDragOverIndex(null);
},
[blocks, draggedIndex, onMoveBlock]
);
useEffect(() => {
const clearDrag = () => {
setDraggedIndex(null);
setDragOverIndex(null);
};
window.addEventListener('dragend', clearDrag);
return () => window.removeEventListener('dragend', clearDrag);
}, []);
return (
<div className="space-y-0.5">
{visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }) => (
<OutlinerBlock
key={block.id}
block={block}
index={index}
maxAllowedLevel={maxAllowedLevel}
hasChildren={hasChildren}
isFocused={focusedBlockId === block.id}
canEdit={canEdit}
onFocusBlock={setFocusedBlockId}
onUpdateBlock={onUpdateBlock}
onCreateBlockBelow={handleCreateBlockBelow}
onDeleteBlock={handleDeleteBlock}
onIndentBlock={handleIndentBlock}
onNavigateFocus={handleNavigateFocus}
onTagClick={onTagClick}
onDragStartBlock={handleDragStartBlock}
onDragOverBlock={handleDragOverBlock}
onDropBlock={handleDropBlock}
isDragTarget={dragOverIndex === index && draggedIndex !== index}
registerTextareaRef={registerTextareaRef}
/>
))}
{canEdit && (
<div className="pt-2 pl-2">
<button
type="button"
onClick={() => {
const lastBlock = blocks[blocks.length - 1];
if (lastBlock) {
handleCreateBlockBelow(lastBlock.id, lastBlock.content);
} else {
const firstBlock: DocumentBlock = {
id: `blk_${Date.now().toString(36)}_0`,
documentId: docId,
type: 'text',
content: '',
level: 0,
position: 1000,
};
pendingFocusRef.current = { blockId: firstBlock.id, cursor: 'start' };
onCreateBlock(firstBlock);
}
}}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium text-slate-400 hover:text-indigo-600 dark:hover:text-indigo-400 hover:bg-slate-100/80 dark:hover:bg-slate-800/50 transition-colors"
>
<Plus className="w-3.5 h-3.5" />
<span>Neuer Punkt</span>
</button>
</div>
)}
</div>
);
};