← Files BranchaARCHIVED FILE

skills/brancha/app/src/ui/pages/Canvas.tsx

9.55 KB · Oct 2, 2026 · 00:29 UTC

↓ Download file

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