Dev #7
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,6 +739,154 @@ 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">
|
||||
{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
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
@@ -700,6 +947,8 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
docId={docId}
|
||||
blocks={blocks}
|
||||
canEdit={canEdit}
|
||||
levelOffset={0}
|
||||
onZoomBlock={setZoomedBlockId}
|
||||
remoteCursors={remoteCursors}
|
||||
sendCursorMoved={sendCursorMoved}
|
||||
onUpdateBlock={handleUpdateBlock}
|
||||
@@ -709,6 +958,8 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
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' };
|
||||
|
||||
Reference in new issue
Block a user