← Files BranchaARCHIVED FILE
skills/brancha/app/src/ui/pages/Canvas.tsx
9.55 KB · Oct 5, 2026 · 18:29 UTC
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { api } from '../api';
import { BrandMark } from '../components/BrandMark';
import { Minimap } from '../components/Minimap';
import { NodeCard } from '../components/NodeCard';
import { PreviewPanel } from '../components/canvas/PreviewPanel';
import { useCanvasViewport } from '../hooks/useCanvasViewport';
import { useProjectTree } from '../hooks/useProjectTree';
import { edgePath, layoutTree } from '../lib/layout';
import type { Id } from '../types';
import { isArtifactType } from '../../shared/types';
import { useI18n } from '../components/I18nProvider';
import '../styles/pages/Canvas.css';
const RENDER_OVERSCAN_PX = 700;
const PREVIEW_MIN_ZOOM = .3;
function intersectsViewport(position: { x: number; y: number; w: number; h: number }, rect: { x1: number; y1: number; x2: number; y2: number }) {
const x1 = position.x - position.w / 2, x2 = position.x + position.w / 2;
const y1 = position.y - position.h / 2, y2 = position.y + position.h / 2;
return x2 >= rect.x1 && x1 <= rect.x2 && y2 >= rect.y1 && y1 <= rect.y2;
}
export default function Canvas() {
const { t } = useI18n();
const navigate = useNavigate();
const { projectId = '' } = useParams();
const [searchParams] = useSearchParams();
const { tree, nodes } = useProjectTree(projectId);
const [selectedId, setSelectedId] = useState<Id | null>(null);
const [previewId, setPreviewId] = useState<Id | null>(null);
const [toast, setToast] = useState<string | null>(null);
const focusPending = useRef(searchParams.get('focus'));
const byId = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]);
const seqOf = useMemo(() => new Map(nodes.map((node) => [node.id, node.seq])), [nodes]);
const childrenMap = useMemo(() => {
const children = new Map<Id, Id[]>();
for (const node of nodes) {
if (!node.parent_id) continue;
if (!children.has(node.parent_id)) children.set(node.parent_id, []);
children.get(node.parent_id)!.push(node.id);
}
return children;
}, [nodes]);
const collapseKey = `brancha-collapsed-${projectId}`;
const [collapsed, setCollapsed] = useState<Set<Id>>(() => {
try { return new Set(JSON.parse(localStorage.getItem(collapseKey) ?? '[]')); }
catch { return new Set(); }
});
function toggleCollapse(id: Id) {
setCollapsed((current) => {
const next = new Set(current);
next.has(id) ? next.delete(id) : next.add(id);
localStorage.setItem(collapseKey, JSON.stringify([...next]));
return next;
});
}
const visibleNodes = useMemo(() => {
const root = nodes.find((node) => node.parent_id === null);
if (!root) return [];
const visible: typeof nodes = [];
const walk = (id: Id) => {
const node = byId.get(id);
if (!node) return;
visible.push(node);
if (!collapsed.has(id)) (childrenMap.get(id) ?? []).forEach(walk);
};
walk(root.id);
return visible;
}, [nodes, byId, collapsed, childrenMap]);
const descendantCount = useCallback((id: Id): number => {
const children = childrenMap.get(id) ?? [];
return children.length + children.reduce((sum, child) => sum + descendantCount(child), 0);
}, [childrenMap]);
const positions = useMemo(() => layoutTree(visibleNodes), [visibleNodes]);
const clearSelection = useCallback(() => setSelectedId(null), []);
const viewport = useCanvasViewport(positions, clearSelection);
const renderRect = useMemo(() => {
const pad = RENDER_OVERSCAN_PX / viewport.view.k;
return {
x1: -viewport.view.x / viewport.view.k - pad,
y1: -viewport.view.y / viewport.view.k - pad,
x2: (viewport.canvasSize.w - viewport.view.x) / viewport.view.k + pad,
y2: (viewport.canvasSize.h - viewport.view.y) / viewport.view.k + pad,
};
}, [viewport.view, viewport.canvasSize]);
const renderNodes = useMemo(() => visibleNodes.filter((node) => {
const position = positions.get(node.id);
return position ? intersectsViewport(position, renderRect) : false;
}), [visibleNodes, positions, renderRect]);
const renderNodeIds = useMemo(() => new Set(renderNodes.map((node) => node.id)), [renderNodes]);
useEffect(() => { if (selectedId && !positions.has(selectedId)) setSelectedId(null); }, [positions, selectedId]);
useEffect(() => {
const wanted = focusPending.current;
if (!wanted || !positions.size || viewport.canvasSize.w <= 1) return;
const target = byId.has(wanted) ? wanted : nodes[Number(wanted) - 1]?.id;
const position = target ? positions.get(target) : undefined;
if (!target || !position) return;
focusPending.current = null;
viewport.focusAt(position);
setSelectedId(target);
const node = byId.get(target);
if ((node?.type === 'text' && node.content) || (node && isArtifactType(node.type) && node.content)) setPreviewId(target);
}, [positions, byId, nodes, viewport]);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
if (previewId) setPreviewId(null); else setSelectedId(null);
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [previewId]);
const highlightedEdges = useMemo(() => {
const edges = new Set<Id>();
let current = selectedId ? byId.get(selectedId) : undefined;
while (current?.parent_id) { edges.add(current.id); current = byId.get(current.parent_id); }
return edges;
}, [selectedId, byId]);
function showToast(message: string) {
setToast(message);
setTimeout(() => setToast((current) => current === message ? null : current), 2400);
}
function onNodeClick(id: Id) {
const node = byId.get(id);
if (!node) return;
setSelectedId(id);
if (isArtifactType(node.type) && !node.content) { setPreviewId(null); showToast(t('canvas.artifactGenerating')); return; }
if (node.type === 'error') { setPreviewId(null); showToast(node.content || t('canvas.artifactFailed')); return; }
setPreviewId(node.type === 'text' ? node.content ? id : null : isArtifactType(node.type) && node.content ? id : null);
}
const doneCount = nodes.filter((node) => isArtifactType(node.type) && node.content).length;
const generatingCount = nodes.filter((node) => isArtifactType(node.type) && !node.content).length;
const previewNode = previewId ? byId.get(previewId) : undefined;
return <div className="cb-canvas">
<div className="cb-canvas-bar">
<button className="cb-canvas-back" title={t('canvas.back')} onClick={() => navigate('/')}>←</button>
<span className="cb-bar-sep" /><BrandMark size={15} />
<div className="cb-canvas-project-name">{tree?.project.title ?? '…'}</div>
<span className="cb-bar-stat"><b>{doneCount}</b>{t('canvas.variants')}</span>
{generatingCount > 0 && <span className="cb-bar-stat is-live"><b>{generatingCount}</b>{t('canvas.generating')}</span>}
</div>
<div className={`cb-canvas-layout${previewId ? ' has-preview' : ''}`}>
<div ref={viewport.viewportRef} className="cb-viewport" onPointerDown={viewport.onPointerDown} onPointerMove={viewport.onPointerMove} onPointerUp={viewport.onPointerUp}>
<div className="cb-world" style={{ transform: `translate(${viewport.view.x}px, ${viewport.view.y}px) scale(${viewport.view.k})` }}>
<svg className="cb-edges">{visibleNodes.map((node) => {
if (!node.parent_id) return null;
const parent = positions.get(node.parent_id), child = positions.get(node.id);
if (!parent || !child || (!renderNodeIds.has(node.parent_id) && !renderNodeIds.has(node.id))) return null;
return <path key={node.id} className={`cb-edge${highlightedEdges.has(node.id) ? ' is-highlighted' : ''}`} d={edgePath(parent, child)} />;
})}</svg>
{renderNodes.map((node) => {
const position = positions.get(node.id);
if (!position) return null;
return <NodeCard key={node.id} node={node} seq={seqOf.get(node.id) ?? 0} pos={position}
selected={node.id === selectedId} collapsed={collapsed.has(node.id)}
hiddenCount={collapsed.has(node.id) ? descendantCount(node.id) : 0}
hasChildren={(childrenMap.get(node.id) ?? []).length > 0}
renderPreview={viewport.view.k >= PREVIEW_MIN_ZOOM} onClick={onNodeClick} onToggle={toggleCollapse} />;
})}
</div>
<div className="cb-zoom">
<button onClick={() => viewport.zoomAt(viewport.canvasSize.w / 2, viewport.canvasSize.h / 2, viewport.view.k / 1.25)}>−</button>
<span className="cb-zoom-value">{Math.round(viewport.view.k * 100)}%</span>
<button onClick={() => viewport.zoomAt(viewport.canvasSize.w / 2, viewport.canvasSize.h / 2, viewport.view.k * 1.25)}>+</button>
<button className="cb-zoom-fit" onClick={viewport.fitView}>{t('canvas.fit')}</button>
</div>
<Minimap nodes={visibleNodes} positions={positions} view={viewport.view} canvasSize={viewport.canvasSize}
onNavigate={(x, y) => viewport.setView((current) => ({ ...current, x: viewport.canvasSize.w / 2 - x * current.k, y: viewport.canvasSize.h / 2 - y * current.k }))} />
<div className="cb-canvas-hint">{t('canvas.hint')}</div>
{toast && <div className="cb-toast">{toast}</div>}
</div>
{previewNode && <PreviewPanel node={previewNode} seq={seqOf.get(previewNode.id) ?? 0} onClose={() => setPreviewId(null)} onToast={showToast} />}
</div>
</div>;
}
SHA-256: ddf31c7c8c737c53fcabf7d014dd70985cf4b08d913b18fb6d3f40e164cec6d4