Dev #7

Merged
sandrodach merged 33 commits from dev into main 2026-10-27 11:42:12 +01:00
3 changed files with 346 additions and 78 deletions
Showing only changes of commit 177e24fdc8 - Show all commits

No files matched your search

@@ -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<DocumentEditorProps> = ({
docId,
currentUser,
@@ -76,6 +160,21 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
const [error, setError] = useState<string | null>(null);
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
const [showFolderMenu, setShowFolderMenu] = useState<boolean>(false);
const [zoomedBlockId, setZoomedBlockId] = useState<string | null>(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<HTMLInputElement | null>(null);
const titleDebounceRef = useRef<number | null>(null);
@@ -640,75 +739,227 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
{/* Main Content Area */}
<div className="max-w-4xl w-full mx-auto px-4 sm:px-8 py-5 flex-1">
{/* Document Icon + Title Header */}
<div className="flex items-center gap-3 mb-3 relative">
<div className="relative">
<button
type="button"
onClick={() => canEdit && setShowEmojiPicker((prev) => !prev)}
className="w-10 h-10 rounded-btn bg-panel-elevated border border-border hover:border-strong flex items-center justify-center text-xl transition-colors"
title="Icon ändern"
>
{doc?.icon || '📝'}
</button>
{showEmojiPicker && canEdit && (
<div className="absolute left-0 mt-1 p-2 panel-surface shadow-panel border border-border rounded-card grid grid-cols-6 gap-1 z-30 w-52">
{EMOJI_PALETTE.map((emoji) => (
{zoomedBlock ? (
/* =========================================================================
FOKUS-MODUS & ZOOM-IN (HOISTED SUBTREE VIEW)
========================================================================= */
<div className="space-y-3">
{/* Clickable Breadcrumb Bar */}
{breadcrumbs.length > 0 && (
<nav
aria-label="Breadcrumb Navigation"
className="flex items-center flex-wrap gap-1.5 py-1.5 px-3 bg-panel-elevated/70 border border-border rounded-card text-xs text-muted"
>
<button
type="button"
onClick={() => setZoomedBlockId(null)}
className="hover:text-accent inline-flex items-center gap-1 font-medium transition-colors"
title="Zurück zum Hauptdokument"
>
<Home className="w-3.5 h-3.5 text-accent" />
<span className="truncate max-w-[120px]">
{title || doc?.title || 'Dokument'}
</span>
</button>
{breadcrumbs.slice(1).map((crumb, idx) => {
const isCurrent = idx === breadcrumbs.length - 2;
return (
<React.Fragment key={crumb.id || idx}>
<ChevronRight className="w-3 h-3 text-faint shrink-0" />
{isCurrent ? (
<span
className="font-bold text-accent truncate max-w-[200px]"
title={crumb.title}
>
{crumb.title || 'Punkt'}
</span>
) : (
<button
type="button"
onClick={() => setZoomedBlockId(crumb.id)}
className="hover:text-main truncate max-w-[140px] transition-colors"
title={`Zoom zu "${crumb.title}"`}
>
{crumb.title || 'Punkt'}
</button>
)}
</React.Fragment>
);
})}
</nav>
)}
{/* Prominent Zoomed Ast-Heading */}
<div className="flex items-start justify-between gap-3 pb-4 mb-2 border-b border-border">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1.5">
<span className="inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-accent bg-accent-dim px-2 py-0.5 rounded-btn border border-accent/30">
<Sparkles className="w-2.5 h-2.5 text-accent animate-pulse" />
Fokus-Modus
</span>
<span className="text-[11px] text-faint hidden sm:inline">
Isolierter Ast • Zoom aktiv
</span>
</div>
<input
type="text"
value={zoomedBlock.content}
onChange={(e) => {
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"
/>
</div>
<button
type="button"
onClick={() => setZoomedBlockId(null)}
className="btn-action inline-flex items-center gap-1.5 px-2.5 py-1 text-xs text-muted hover:text-main shrink-0 mt-1"
title="Fokus-Modus beenden"
>
<Minimize2 className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Fokus beenden</span>
</button>
</div>
{/* Subtree Children or Empty State */}
{childBlocks.length === 0 ? (
<div className="py-8 text-center text-xs text-faint border border-dashed border-border rounded-card my-2">
<p className="mb-2">Dieser Ast hat noch keine Unterpunkte.</p>
{canEdit && (
<button
key={emoji}
type="button"
onClick={() => handleSelectIcon(emoji)}
className="p-1.5 rounded-btn hover:bg-panel-elevated text-base flex items-center justify-center"
onClick={() => {
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);
}}
className="btn-action inline-flex items-center gap-1.5 px-3 py-1.5 text-xs text-accent font-medium hover:bg-accent/10"
>
{emoji}
<Plus className="w-3.5 h-3.5" />
<span>Ersten Unterpunkt anlegen (oder Enter drücken)</span>
</button>
)}
</div>
) : (
<OutlinerTree
docId={docId}
blocks={childBlocks}
canEdit={canEdit}
levelOffset={levelOffset}
onZoomBlock={setZoomedBlockId}
remoteCursors={remoteCursors}
sendCursorMoved={sendCursorMoved}
onUpdateBlock={handleUpdateBlock}
onCreateBlock={handleCreateBlock}
onDeleteBlock={handleDeleteBlock}
onMoveBlock={handleMoveBlock}
onTagClick={onSelectTag}
onWikiLinkClick={onWikiLinkClick}
/>
)}
</div>
) : (
/* =========================================================================
STANDARD GESAMTDOKUMENT-ANSICHT
========================================================================= */
<>
{/* Document Icon + Title Header */}
<div className="flex items-center gap-3 mb-3 relative">
<div className="relative">
<button
type="button"
onClick={() => canEdit && setShowEmojiPicker((prev) => !prev)}
className="w-10 h-10 rounded-btn bg-panel-elevated border border-border hover:border-strong flex items-center justify-center text-xl transition-colors"
title="Icon ändern"
>
{doc?.icon || '📝'}
</button>
{showEmojiPicker && canEdit && (
<div className="absolute left-0 mt-1 p-2 panel-surface shadow-panel border border-border rounded-card grid grid-cols-6 gap-1 z-30 w-52">
{EMOJI_PALETTE.map((emoji) => (
<button
key={emoji}
type="button"
onClick={() => handleSelectIcon(emoji)}
className="p-1.5 rounded-btn hover:bg-panel-elevated text-base flex items-center justify-center"
>
{emoji}
</button>
))}
</div>
)}
</div>
<input
ref={titleInputRef}
type="text"
value={title}
readOnly={!canEdit}
onChange={handleTitleChange}
placeholder="Titel der Notiz…"
className="flex-1 min-w-0 text-xl sm:text-2xl font-bold text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight"
/>
</div>
{/* Extracted #Tags Summary Bar */}
{extractedTags.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5 mb-4 pb-2 border-b border-border">
<Hash className="w-3.5 h-3.5 text-faint mr-0.5" />
{extractedTags.map((tag) => (
<button
key={tag}
type="button"
onClick={() => onSelectTag(tag)}
className="badge-accent text-[11px] font-medium px-2 py-0.5 rounded-btn hover:opacity-85 transition-opacity"
>
#{tag}
</button>
))}
</div>
)}
</div>
<input
ref={titleInputRef}
type="text"
value={title}
readOnly={!canEdit}
onChange={handleTitleChange}
placeholder="Titel der Notiz…"
className="flex-1 min-w-0 text-xl sm:text-2xl font-bold text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight"
/>
</div>
{/* Extracted #Tags Summary Bar */}
{extractedTags.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5 mb-4 pb-2 border-b border-border">
<Hash className="w-3.5 h-3.5 text-faint mr-0.5" />
{extractedTags.map((tag) => (
<button
key={tag}
type="button"
onClick={() => onSelectTag(tag)}
className="badge-accent text-[11px] font-medium px-2 py-0.5 rounded-btn hover:opacity-85 transition-opacity"
>
#{tag}
</button>
))}
</div>
{/* Logseq Outliner Tree */}
<OutlinerTree
docId={docId}
blocks={blocks}
canEdit={canEdit}
levelOffset={0}
onZoomBlock={setZoomedBlockId}
remoteCursors={remoteCursors}
sendCursorMoved={sendCursorMoved}
onUpdateBlock={handleUpdateBlock}
onCreateBlock={handleCreateBlock}
onDeleteBlock={handleDeleteBlock}
onMoveBlock={handleMoveBlock}
onTagClick={onSelectTag}
onWikiLinkClick={onWikiLinkClick}
/>
</>
)}
{/* Logseq Outliner Tree */}
<OutlinerTree
docId={docId}
blocks={blocks}
canEdit={canEdit}
remoteCursors={remoteCursors}
sendCursorMoved={sendCursorMoved}
onUpdateBlock={handleUpdateBlock}
onCreateBlock={handleCreateBlock}
onDeleteBlock={handleDeleteBlock}
onMoveBlock={handleMoveBlock}
onTagClick={onSelectTag}
onWikiLinkClick={onWikiLinkClick}
/>
</div>
</div>
);
@@ -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<OutlinerBlockProps> = ({
canEdit,
activeRemoteCursors = [],
sendCursorMoved,
levelOffset = 0,
onZoomBlock,
onFocusBlock,
onUpdateBlock,
onCreateBlockBelow,
@@ -334,6 +339,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
};
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<OutlinerBlockProps> = ({
: '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<OutlinerBlockProps> = ({
)}
{/* Indentation Guide Line */}
{level > 0 &&
Array.from({ length: level }).map((_, lvlIdx) => (
{visualLevel > 0 &&
Array.from({ length: visualLevel }).map((_, lvlIdx) => (
<div
key={lvlIdx}
className="pointer-events-none absolute top-0 bottom-0 w-px border-l border-border"
@@ -440,15 +446,9 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
) : (
<button
type="button"
onClick={() => {
if (canEdit) {
flushDebounce(localContent, {
type: block.type === 'todo' ? 'bullet' : 'todo',
});
}
}}
className="w-4 h-4 flex items-center justify-center rounded-btn hover:bg-panel-hover"
title="Klick: Zu To-Do / Bullet umschalten"
onClick={() => onZoomBlock?.(block.id)}
className="w-4 h-4 flex items-center justify-center rounded-btn hover:bg-panel-hover group/bullet transition-transform"
title="Klick: In diesen Ast hineinzoomen (Fokus-Modus)"
>
<span
className={`w-1.5 h-1.5 rounded-full transition-all ${
@@ -456,7 +456,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
? 'bg-accent ring-2 ring-accent/30'
: isFocused
? 'bg-accent scale-125'
: 'bg-faint group-hover:bg-accent'
: 'bg-faint group-hover/bullet:bg-accent group-hover/bullet:scale-125'
}`}
/>
</button>
@@ -670,6 +670,16 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
)}
</div>
{/* Zoom / Focus button on hover */}
<button
type="button"
onClick={() => onZoomBlock?.(block.id)}
className="opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-accent rounded-btn transition-opacity shrink-0"
title="In diesen Ast hineinzoomen (Fokus-Modus)"
>
<Maximize2 className="w-3.5 h-3.5" />
</button>
{/* Delete button on hover */}
{canEdit && (
<button
@@ -8,6 +8,8 @@ export interface OutlinerTreeProps {
docId: string;
blocks: DocumentBlock[];
canEdit: boolean;
levelOffset?: number;
onZoomBlock?: (blockId: string) => void;
remoteCursors?: Record<string, RemoteCursor>;
sendCursorMoved?: (blockId: string | null, selectionStart?: number, selectionEnd?: number) => void;
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
@@ -22,6 +24,8 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
docId,
blocks,
canEdit,
levelOffset = 0,
onZoomBlock,
remoteCursors,
sendCursorMoved,
onUpdateBlock,
@@ -105,7 +109,7 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
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;
const maxAllowedLevel = prevBlock ? Math.max(0, prevBlock.level || 0) + 1 : (levelOffset || 0);
result.push({
block: b,
@@ -155,8 +159,8 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
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) {
// If this is the only block in the document (and not in an isolated zoom subtree), clear instead of deleting
if (blocks.length <= 1 && !levelOffset) {
const only = blocks[0];
onUpdateBlock(
{
@@ -191,10 +195,11 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
const prevLevel = idx > 0 ? Math.max(0, blocks[idx - 1].level || 0) : 0;
const maxLevel = idx === 0 ? 0 : prevLevel + 1;
const minLevel = levelOffset || 0;
const nextLevel =
delta === 1
? Math.min(maxLevel, currentLevel + 1)
: Math.max(0, currentLevel - 1);
: Math.max(minLevel, currentLevel - 1);
if (nextLevel === currentLevel) return;
@@ -283,6 +288,8 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
hasChildren={hasChildren}
isFocused={focusedBlockId === block.id}
canEdit={canEdit}
levelOffset={levelOffset}
onZoomBlock={onZoomBlock}
activeRemoteCursors={cursorsByBlock[block.id] || []}
sendCursorMoved={sendCursorMoved}
onFocusBlock={setFocusedBlockId}
@@ -315,7 +322,7 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
documentId: docId,
type: 'text',
content: '',
level: 0,
level: levelOffset || 0,
position: 1000,
};
pendingFocusRef.current = { blockId: firstBlock.id, cursor: 'start' };