diff --git a/apps/web/package.json b/apps/web/package.json index 522a20e..0323fed 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,6 +17,7 @@ "@codemirror/lang-markdown": "6.3.3", "@codemirror/state": "6.5.2", "@codemirror/view": "6.38.6", + "@xyflow/react": "^12.11.0", "codemirror": "6.0.2", "fast-diff": "^1.3.0", "lucide-react": "^1.8.0", diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 485e991..be3ed3c 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -1,3 +1,5 @@ +@import '@xyflow/react/dist/style.css'; + :root { color-scheme: dark; --background: #08101f; @@ -576,6 +578,223 @@ code { color: #8b949e; } +.diagram-canvas-svg--reactflow svg g.node, +.diagram-canvas-svg--reactflow svg .edgePaths, +.diagram-canvas-svg--reactflow svg .edgeLabels, +.diagram-canvas-svg--reactflow svg g.edgePath, +.diagram-canvas-svg--reactflow svg g.edgeLabel, +.diagram-canvas-svg--reactflow svg path.flowchart-link { + opacity: 0; +} + +.diagram-reactflow-layer { + inset: 0; + position: absolute; + z-index: 5; +} + +.diagram-reactflow-layer .react-flow { + height: 100%; + width: 100%; +} + +.diagram-reactflow-layer .react-flow, +.diagram-reactflow-layer .react-flow__renderer, +.diagram-reactflow-layer .react-flow__pane { + background: transparent; +} + +.diagram-reactflow-layer .react-flow__node { + background: transparent; + border: none; + box-shadow: none; + padding: 0; +} + +.diagram-reactflow-layer .react-flow__edge-path { + stroke: #e2e8f0; + stroke-width: 1.8; +} + +.diagram-reactflow-layer .react-flow__edge.selected .react-flow__edge-path, +.diagram-reactflow-layer .react-flow__edge:focus .react-flow__edge-path { + filter: drop-shadow(0 0 5px rgba(56, 189, 248, 0.7)); + stroke: #38bdf8; +} + +.diagram-reactflow-layer .react-flow__edge:hover .react-flow__edge-path { + stroke: #7dd3fc; +} + +.diagram-reactflow-layer .react-flow__edge-interaction { + cursor: pointer; +} + +.diagram-reactflow-layer .react-flow__edge-textbg { + fill: #0d1117; +} + +.diagram-reactflow-layer .react-flow__edge-text { + fill: #e2e8f0; + font-family: var(--font-mono); + font-size: 11px; +} + +.diagram-reactflow-layer .react-flow__handle { + background: #38bdf8; + border: 1px solid #0d1117; + height: 8px; + width: 8px; +} + +.diagram-reactflow-layer .mermaid-flow-handle--target { + opacity: 0; + pointer-events: auto; +} + +.diagram-reactflow-layer .mermaid-flow-handle--source { + opacity: 0.95; + z-index: 2; +} + +.diagram-reactflow-layer .react-flow__node:hover .mermaid-flow-handle--target, +.diagram-reactflow-layer .react-flow__node.selected .mermaid-flow-handle--target, +.diagram-reactflow-layer .react-flow__node:hover .mermaid-flow-handle--source, +.diagram-reactflow-layer .react-flow__node.selected .mermaid-flow-handle--source { + opacity: 1; +} + +.diagram-reactflow-layer .mermaid-flow-handle--left, +.diagram-reactflow-layer .mermaid-flow-handle--right { + cursor: ew-resize; +} + +.diagram-reactflow-layer .mermaid-flow-handle--top, +.diagram-reactflow-layer .mermaid-flow-handle--bottom { + cursor: ns-resize; +} + +.mermaid-flow-node { + align-items: center; + background: #161b22; + border: 1px solid #30363d; + box-shadow: 0 10px 28px rgba(2, 6, 23, 0.35); + color: #e2e8f0; + display: flex; + font-size: 13px; + font-weight: 600; + height: 100%; + justify-content: center; + line-height: 1.25; + min-height: 28px; + min-width: 48px; + padding: 8px 12px; + position: relative; + text-align: center; + width: 100%; +} + +.mermaid-flow-node.is-selected { + border-color: #38bdf8; + box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.25), 0 10px 28px rgba(2, 6, 23, 0.35); +} + +.mermaid-flow-node.is-focused { + outline: 2px solid rgba(56, 189, 248, 0.85); + outline-offset: 3px; +} + +.mermaid-flow-node > span { + position: relative; + z-index: 1; +} + +.mermaid-flow-node--round, +.mermaid-flow-node--stadium { + border-radius: 999px; +} + +.mermaid-flow-node--circle, +.mermaid-flow-node--doublecircle, +.mermaid-flow-node--ellipse { + border-radius: 50%; +} + +.mermaid-flow-node--diamond { + clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%); + padding: 14px; +} + +.mermaid-flow-node--hexagon { + clip-path: polygon(18% 0, 82% 0, 100% 50%, 82% 100%, 18% 100%, 0 50%); +} + +.mermaid-flow-node--trapezoid { + clip-path: polygon(12% 0, 88% 0, 100% 100%, 0 100%); +} + +.mermaid-flow-node--inv_trapezoid { + clip-path: polygon(0 0, 100% 0, 88% 100%, 12% 100%); +} + +.mermaid-flow-node--lean_right { + clip-path: polygon(12% 0, 100% 0, 88% 100%, 0 100%); +} + +.mermaid-flow-node--lean_left { + clip-path: polygon(0 0, 88% 0, 100% 100%, 12% 100%); +} + +.mermaid-flow-node--odd { + clip-path: polygon(0 0, 88% 0, 100% 50%, 88% 100%, 0 100%); +} + +.mermaid-flow-node--cylinder { + border-bottom-color: transparent; + border-radius: 50% / 14%; + padding-top: 12px; +} + +.mermaid-flow-node--cylinder::before, +.mermaid-flow-node--cylinder::after { + border: 1px solid #30363d; + border-radius: 50%; + content: ''; + height: 22%; + left: -1px; + pointer-events: none; + position: absolute; + right: -1px; +} + +.mermaid-flow-node--cylinder::before { + background: rgba(13, 17, 23, 0.18); + top: -1px; +} + +.mermaid-flow-node--cylinder::after { + border-top-color: transparent; + bottom: -1px; +} + +.mermaid-flow-node--subroutine::before, +.mermaid-flow-node--subroutine::after { + background: #30363d; + content: ''; + height: 100%; + position: absolute; + top: 0; + width: 1px; +} + +.mermaid-flow-node--subroutine::before { + left: 10px; +} + +.mermaid-flow-node--subroutine::after { + right: 10px; +} + .activity-list { list-style: none; margin: 0; diff --git a/apps/web/src/components/diagram-canvas.tsx b/apps/web/src/components/diagram-canvas.tsx index 12b7c44..2de74e8 100644 --- a/apps/web/src/components/diagram-canvas.tsx +++ b/apps/web/src/components/diagram-canvas.tsx @@ -1,18 +1,41 @@ 'use client'; -import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react'; +import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent, ReactNode } from 'react'; +import { + ConnectionLineType, + Handle, + MarkerType, + Position, + ReactFlow, + type Connection, + type Edge, + type FinalConnectionState, + type Node, + type NodeChange, + type NodeProps, + type NodeTypes, + type OnConnectEnd, + type OnConnectStart, + type OnNodeDrag, + type Viewport, +} from '@xyflow/react'; import { ArrowRightFromLine, Pencil, Plus, + RotateCcw, ScanSearch, Shapes, Trash2, ZoomIn, ZoomOut, } from 'lucide-react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import type { DiagramLinkType, DiagramNode, DiagramNodeShape, DiagramSubgraph, FlowchartSnapshot } from '../lib/diagram-mutations'; +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import type { DiagramEdgeIdentity, DiagramLink, DiagramLinkType, DiagramNode, DiagramNodeShape, DiagramSubgraph, FlowchartSnapshot } from '../lib/diagram-mutations'; +import { getDiagramEdgeIdentity, resolveDiagramEdgeIndex } from '../lib/diagram-mutations'; +import { getDiagramEdgeIdentityForFlowEdge, getFlowEdgeId, getVisibleDiagramLinks } from '../lib/diagram-flow-identity'; +import type { DiagramNodePositions, NodePositionsSyncMode } from '../lib/diagram-layout'; +import { getConnectNodeActivation } from '../lib/diagram-connect-state'; import { buildSvgHitMap, getBoundsCenter, @@ -29,17 +52,21 @@ export interface DiagramCanvasProps { graph: FlowchartSnapshot | null; interactionMode?: 'select' | 'connect'; isFlowchart?: boolean; + nodePositions?: DiagramNodePositions; readOnly?: boolean; selectedNodeIds?: string[]; svg: string; onAddEdge?: (source: string, target: string, label?: string, type?: DiagramLinkType) => void; onAddNode?: (label: string, shape: DiagramNodeShape) => void; + onAddConnectedNode?: (source: string, label: string, shape: DiagramNodeShape, position: SvgPoint, type: DiagramLinkType) => void; onChangeNodeShape?: (nodeId: string, newShape: DiagramNodeShape) => void; - onDeleteEdge?: (edgeKey: string) => void; + onDeleteEdge?: (edge: DiagramEdgeIdentity) => void; onDeleteNodes?: (nodeIds: string[]) => void; + onEditEdgeLabel?: (edge: DiagramEdgeIdentity, label?: string) => void; onEditNodeLabel?: (nodeId: string, newLabel: string) => void; onGroupNodes?: (nodeIds: string[], label: string) => void; onInteractionModeChange?: (mode: 'select' | 'connect') => void; + onNodePositionsChange?: (positions: DiagramNodePositions, mode?: NodePositionsSyncMode) => void; onSelectedNodeIdsChange?: (nodeIds: string[]) => void; onUngroupNodes?: (subgraphId: string) => void; } @@ -58,10 +85,39 @@ interface PendingEdge { target: string; } +interface MermaidFlowNodeData extends Record { + ariaLabel: string; + label: string; + shape: DiagramNodeShape; +} + +type MermaidFlowNode = Node; + +interface FlowNodeInteractionContextValue { + focusedNodeId: string | null; + onFocus: (nodeId: string) => void; + onKeyDown: (nodeId: string, event: ReactKeyboardEvent) => void; + registerNodeElement: (nodeId: string, element: HTMLElement | null) => void; +} + +const FLOW_NODE_TYPES: NodeTypes = { + mermaidFlowNode: MermaidReactFlowNode, +}; +const FLOW_PRO_OPTIONS = { hideAttribution: true }; +const FLOW_EDGE_COLOR = '#e2e8f0'; +const FLOW_EDGE_MARKER_CIRCLE_ID = 'arielcharts-flow-edge-circle'; +const FLOW_EDGE_MARKER_CROSS_ID = 'arielcharts-flow-edge-cross'; +const FLOW_HANDLE_POSITIONS = [Position.Top, Position.Right, Position.Bottom, Position.Left] as const; +const GHOST_NODE_WIDTH = 144; +const GHOST_NODE_HEIGHT = 56; +const FlowNodeInteractionContext = createContext(null); + const MIN_ZOOM = 0.1; const MAX_ZOOM = 4; const EDITOR_MIN_ZOOM = 0.4; const FIT_PADDING = 64; +const DEFAULT_NEW_NODE_LABEL = 'New Node'; +const DEFAULT_NEW_NODE_SHAPE: DiagramNodeShape = 'rect'; const SHAPE_OPTIONS: Array<{ label: string; value: DiagramNodeShape }> = [ { label: 'rect', value: 'rect' }, { label: 'round', value: 'round' }, @@ -100,13 +156,18 @@ export function DiagramCanvas({ graph, interactionMode, isFlowchart = true, + nodePositions, onAddEdge, onAddNode, + onAddConnectedNode, onChangeNodeShape, + onDeleteEdge, onDeleteNodes, + onEditEdgeLabel, onEditNodeLabel, onGroupNodes, onInteractionModeChange, + onNodePositionsChange, onSelectedNodeIdsChange, onUngroupNodes, readOnly = false, @@ -115,9 +176,19 @@ export function DiagramCanvas({ }: DiagramCanvasProps) { const containerRef = useRef(null); const svgContainerRef = useRef(null); - const nodeButtonRefs = useRef(new Map()); + const nodeButtonRefs = useRef(new Map()); const [hitMap, setHitMap] = useState(null); + const [uncontrolledNodePositions, setUncontrolledNodePositions] = useState({}); + const [liveNodePositions, setLiveNodePositions] = useState({}); + const persistedNodePositions = nodePositions ?? uncontrolledNodePositions; + const persistedNodePositionsRef = useRef(persistedNodePositions); + const hasAutoFitInitialRenderRef = useRef(false); + const visibleNodePositions = useMemo( + () => ({ ...persistedNodePositions, ...liveNodePositions }), + [liveNodePositions, persistedNodePositions], + ); const dragStateRef = useRef<{ originX: number; originY: number; startPanX: number; startPanY: number } | null>(null); + const connectionStartNodeIdRef = useRef(null); const isControlledSelection = selectedNodeIds !== undefined; const [internalSelection, setInternalSelection] = useState(selectedNodeIds ?? []); const selection = isControlledSelection ? selectedNodeIds : internalSelection; @@ -129,24 +200,76 @@ export function DiagramCanvas({ const [spacePressed, setSpacePressed] = useState(false); const [editingNodeId, setEditingNodeId] = useState(null); const [editingLabel, setEditingLabel] = useState(''); + const [selectedEdgeIdentity, setSelectedEdgeIdentity] = useState(null); + const [editingEdgeIdentity, setEditingEdgeIdentity] = useState(null); + const [editingEdgeLabel, setEditingEdgeLabel] = useState(''); const [shapePickerOpen, setShapePickerOpen] = useState(false); const [connectSourceId, setConnectSourceId] = useState(null); + const [connectionPreviewSourceId, setConnectionPreviewSourceId] = useState(null); const [cursorPoint, setCursorPoint] = useState(null); const [pendingEdge, setPendingEdge] = useState(null); const [pendingEdgeLabel, setPendingEdgeLabel] = useState(''); const [groupPromptValue, setGroupPromptValue] = useState(''); const [showGroupPrompt, setShowGroupPrompt] = useState(false); + const [newNodeLabel, setNewNodeLabel] = useState(''); + const [newNodeShape, setNewNodeShape] = useState(DEFAULT_NEW_NODE_SHAPE); const [selectedConnectionType, setSelectedConnectionType] = useState('arrow_point'); const [focusedNodeId, setFocusedNodeId] = useState(null); const [toolbarOpen, setToolbarOpen] = useState(false); + const setNodePositions = useCallback(( + update: (current: DiagramNodePositions) => DiagramNodePositions, + mode: NodePositionsSyncMode = 'merge', + syncPositions?: DiagramNodePositions | null, + ) => { + const currentPositions = persistedNodePositionsRef.current; + const next = update(currentPositions); + if (next === currentPositions) { + return; + } + + persistedNodePositionsRef.current = next; + if (nodePositions === undefined) { + setUncontrolledNodePositions(next); + } + if (syncPositions !== null) { + onNodePositionsChange?.(syncPositions ?? (mode === 'remove' ? {} : next), mode); + } + }, [nodePositions, onNodePositionsChange]); + + useEffect(() => { + persistedNodePositionsRef.current = persistedNodePositions; + }, [persistedNodePositions]); + + const interactiveNodeBounds = useMemo(() => { + if (!hitMap) { + return null; + } + + const boundsByNodeId = new Map(hitMap.nodes); + Object.entries(visibleNodePositions).forEach(([nodeId, position]) => { + const bounds = boundsByNodeId.get(nodeId); + if (!bounds) { + return; + } + + boundsByNodeId.set(nodeId, { + ...bounds, + x: position.x, + y: position.y, + }); + }); + + return boundsByNodeId; + }, [hitMap, visibleNodePositions]); + const orderedNodeIds = useMemo(() => { if (graph?.nodes.length) { - return graph.nodes.map((node) => node.id).filter((nodeId) => hitMap?.nodes.has(nodeId) ?? false); + return graph.nodes.map((node) => node.id).filter((nodeId) => interactiveNodeBounds?.has(nodeId) ?? false); } - return hitMap ? [...hitMap.nodes.keys()] : []; - }, [graph?.nodes, hitMap]); + return interactiveNodeBounds ? [...interactiveNodeBounds.keys()] : []; + }, [graph?.nodes, interactiveNodeBounds]); const connectionMap = useMemo(() => { const incoming = new Map(); @@ -160,31 +283,114 @@ export function DiagramCanvas({ return { incoming, outgoing }; }, [graph?.links]); + const nodeById = useMemo(() => new Map(graph?.nodes.map((node) => [node.id, node]) ?? []), [graph?.nodes]); + const selectedEdgeIndex = useMemo(() => ( + graph && selectedEdgeIdentity ? resolveDiagramEdgeIndex(graph.links, selectedEdgeIdentity, { includeLabel: false }) : null + ), [graph, selectedEdgeIdentity]); + const editingEdgeIndex = useMemo(() => ( + graph && editingEdgeIdentity ? resolveDiagramEdgeIndex(graph.links, editingEdgeIdentity, { includeLabel: false }) : null + ), [editingEdgeIdentity, graph]); + const selectedCurrentEdgeIdentity = useMemo(() => { + if (!graph || selectedEdgeIndex === null) { + return null; + } + + const edge = graph.links[selectedEdgeIndex]; + return edge ? getDiagramEdgeIdentity(edge, selectedEdgeIndex) : null; + }, [graph, selectedEdgeIndex]); + const selectedBounds = useMemo(() => { if (!hitMap || selection.length === 0) { return null; } const boundsList = selection - .map((nodeId) => hitMap.nodes.get(nodeId)) + .map((nodeId) => interactiveNodeBounds?.get(nodeId)) .filter((bounds): bounds is SvgBounds => bounds !== undefined); return getBoundsUnion(boundsList); - }, [hitMap, selection]); + }, [hitMap, interactiveNodeBounds, selection]); const graphBounds = useMemo(() => { if (!hitMap) { return null; } - const allBounds = [ - ...hitMap.nodes.values(), - ...hitMap.subgraphs.values(), - ...[...hitMap.edges.values()].map((edge) => edge.bounds), - ]; + const nodeBounds = interactiveNodeBounds ? [...interactiveNodeBounds.values()] : [...hitMap.nodes.values()]; + const allBounds = nodeBounds.length > 0 + ? [...nodeBounds, ...hitMap.subgraphs.values()] + : [...hitMap.subgraphs.values(), ...[...hitMap.edges.values()].map((edge) => edge.bounds)]; return getBoundsUnion(allBounds); - }, [hitMap]); + }, [hitMap, interactiveNodeBounds]); + + const flowNodes = useMemo(() => { + if (!graph || !interactiveNodeBounds) { + return []; + } + + const nextNodes: MermaidFlowNode[] = []; + graph.nodes.forEach((node) => { + const bounds = interactiveNodeBounds.get(node.id); + if (!bounds) { + return; + } + + const nodeLabel = getNodeText(node); + const ariaLabel = getNodeAriaLabel(node.shape, nodeLabel); + nextNodes.push({ + ariaLabel, + data: { + ariaLabel, + label: nodeLabel, + shape: node.shape, + }, + draggable: !readOnly, + focusable: false, + id: node.id, + position: { x: bounds.x, y: bounds.y }, + selectable: true, + selected: selection.includes(node.id), + style: { + height: bounds.height, + width: bounds.width, + }, + type: 'mermaidFlowNode', + }); + }); + + return nextNodes; + }, [graph, interactiveNodeBounds, readOnly, selection]); + + const hasPersistedLayout = Object.keys(persistedNodePositions).length > 0; + + const flowEdges = useMemo(() => { + if (!graph || !interactiveNodeBounds) { + return []; + } + + return getVisibleDiagramLinks(graph.links, interactiveNodeBounds) + .map(({ graphIndex, link }) => ({ + animated: false, + data: { index: graphIndex }, + id: getFlowEdgeId(graphIndex), + label: getLinkText(link), + selectable: true, + selected: selectedEdgeIndex === graphIndex, + ...getFlowEdgeHandles(link, interactiveNodeBounds, graph.direction), + ...getFlowEdgePresentation(link), + source: link.source, + target: link.target, + type: 'smoothstep', + })); + }, [graph, interactiveNodeBounds, selectedEdgeIndex]); + + const useReactFlowRenderer = isFlowchart && flowNodes.length > 0; + const flowViewport = useMemo(() => ({ + x: viewport.panX, + y: viewport.panY, + zoom: viewport.zoom, + }), [viewport.panX, viewport.panY, viewport.zoom]); const screenSelectionBounds = useMemo(() => { if (!selectedBounds) { @@ -199,25 +405,39 @@ export function DiagramCanvas({ return null; } - return graph.nodes.find((node) => node.id === editingNodeId) ?? null; - }, [editingNodeId, graph]); + return nodeById.get(editingNodeId) ?? null; + }, [editingNodeId, graph, nodeById]); const editingNodeBounds = useMemo(() => { if (!hitMap || !editingNodeId) { return null; } - const bounds = hitMap.nodes.get(editingNodeId); + const bounds = interactiveNodeBounds?.get(editingNodeId); return bounds ? toScreenRect(bounds, viewport) : null; - }, [editingNodeId, hitMap, viewport]); + }, [editingNodeId, hitMap, interactiveNodeBounds, viewport]); + + const selectedEdge = selectedEdgeIndex === null ? null : graph?.links[selectedEdgeIndex] ?? null; + const selectedEdgeMidpoint = useMemo(() => ( + selectedEdge && interactiveNodeBounds ? getEdgeMidpoint(selectedEdge, interactiveNodeBounds) : null + ), [interactiveNodeBounds, selectedEdge]); + + const editingEdgeMidpoint = useMemo(() => { + if (editingEdgeIndex === null || !graph || !interactiveNodeBounds) { + return null; + } + + const edge = graph.links[editingEdgeIndex]; + return edge ? getEdgeMidpoint(edge, interactiveNodeBounds) : null; + }, [editingEdgeIndex, graph, interactiveNodeBounds]); const connectSourceBounds = useMemo(() => { - if (!connectSourceId || !hitMap) { + if (!connectSourceId || !interactiveNodeBounds) { return null; } - return hitMap.nodes.get(connectSourceId) ?? null; - }, [connectSourceId, hitMap]); + return interactiveNodeBounds.get(connectSourceId) ?? null; + }, [connectSourceId, interactiveNodeBounds]); const connectSourcePort = useMemo(() => { if (!connectSourceBounds) { @@ -238,6 +458,19 @@ export function DiagramCanvas({ }; }, [connectSourcePort, cursorPoint, viewport]); + const connectionGhostRect = useMemo(() => { + if (!connectionPreviewSourceId || !cursorPoint || readOnly) { + return null; + } + + return toScreenRect({ + height: GHOST_NODE_HEIGHT, + width: GHOST_NODE_WIDTH, + x: cursorPoint.x - (GHOST_NODE_WIDTH / 2), + y: cursorPoint.y - (GHOST_NODE_HEIGHT / 2), + }, viewport); + }, [connectionPreviewSourceId, cursorPoint, readOnly, viewport]); + const displayedToolbarRect = screenSelectionBounds ?? { height: 0, width: 0, x: 16, y: 16 }; const toolbarStyle: CSSProperties = { alignItems: 'center', @@ -269,26 +502,34 @@ export function DiagramCanvas({ } }, [interactionMode, onInteractionModeChange]); - const fitToDiagram = useCallback((animated: boolean) => { + const fitBoundsToViewport = useCallback((bounds: SvgBounds, animated: boolean) => { const container = containerRef.current; - if (!container || !graphBounds) { + if (!container) { return; } const availableWidth = Math.max(1, container.clientWidth - (FIT_PADDING * 2)); const availableHeight = Math.max(1, container.clientHeight - (FIT_PADDING * 2)); const zoom = clamp( - Math.min(availableWidth / Math.max(graphBounds.width, 1), availableHeight / Math.max(graphBounds.height, 1)), + Math.min(availableWidth / Math.max(bounds.width, 1), availableHeight / Math.max(bounds.height, 1)), MIN_ZOOM, MAX_ZOOM, ); - const panX = ((container.clientWidth - (graphBounds.width * zoom)) / 2) - (graphBounds.x * zoom); - const panY = ((container.clientHeight - (graphBounds.height * zoom)) / 2) - (graphBounds.y * zoom); + const panX = ((container.clientWidth - (bounds.width * zoom)) / 2) - (bounds.x * zoom); + const panY = ((container.clientHeight - (bounds.height * zoom)) / 2) - (bounds.y * zoom); setAnimateTransform(animated); setViewport({ panX, panY, zoom }); - }, [graphBounds]); + }, []); + + const fitToDiagram = useCallback((animated: boolean) => { + if (!graphBounds) { + return; + } + + fitBoundsToViewport(graphBounds, animated); + }, [fitBoundsToViewport, graphBounds]); const focusNode = useCallback((nodeId: string | null) => { if (!nodeId) { @@ -302,11 +543,11 @@ export function DiagramCanvas({ }, []); const moveFocus = useCallback((currentNodeId: string, direction: 'up' | 'down' | 'left' | 'right') => { - if (!hitMap) { + if (!interactiveNodeBounds) { return; } - const currentBounds = hitMap.nodes.get(currentNodeId); + const currentBounds = interactiveNodeBounds.get(currentNodeId); if (!currentBounds) { return; } @@ -319,11 +560,11 @@ export function DiagramCanvas({ ...directionalCandidates, ...(connectionMap.incoming.get(currentNodeId) ?? []), ...(connectionMap.outgoing.get(currentNodeId) ?? []), - ])).filter((candidateId) => candidateId !== currentNodeId && hitMap.nodes.has(candidateId)); + ])).filter((candidateId) => candidateId !== currentNodeId && interactiveNodeBounds.has(candidateId)); const ranked = connectedCandidates .map((candidateId) => { - const bounds = hitMap.nodes.get(candidateId); + const bounds = interactiveNodeBounds.get(candidateId); if (!bounds) { return null; } @@ -359,7 +600,7 @@ export function DiagramCanvas({ }); focusNode(ranked[0]?.candidateId ?? null); - }, [connectionMap.incoming, connectionMap.outgoing, focusNode, hitMap]); + }, [connectionMap.incoming, connectionMap.outgoing, focusNode, interactiveNodeBounds]); useEffect(() => { if (!selectedNodeIds) { @@ -397,6 +638,32 @@ export function DiagramCanvas({ }; }, [svg]); + useEffect(() => { + if (!graph) { + setNodePositions((current) => (Object.keys(current).length > 0 ? {} : current), 'merge', null); + setLiveNodePositions({}); + return; + } + + const currentNodeIds = new Set(graph.nodes.map((node) => node.id)); + const removedPositions: DiagramNodePositions = {}; + setNodePositions((current) => { + const next: DiagramNodePositions = {}; + for (const [nodeId, position] of Object.entries(current)) { + if (currentNodeIds.has(nodeId)) { + next[nodeId] = position; + } else { + removedPositions[nodeId] = position; + } + } + + return Object.keys(next).length === Object.keys(current).length ? current : next; + }, 'remove', removedPositions); + setLiveNodePositions((current) => Object.fromEntries( + Object.entries(current).filter(([nodeId]) => currentNodeIds.has(nodeId)), + )); + }, [graph, setNodePositions]); + useEffect(() => { if (selection.length === 0) { setToolbarOpen(false); @@ -408,6 +675,22 @@ export function DiagramCanvas({ } }, [focusedNodeId, selection]); + useEffect(() => { + if (selectedEdgeIdentity && selectedEdgeIndex === null) { + setSelectedEdgeIdentity(null); + } + if (editingEdgeIdentity && editingEdgeIndex === null) { + setEditingEdgeIdentity(null); + setEditingEdgeLabel(''); + } + }, [editingEdgeIdentity, editingEdgeIndex, selectedEdgeIdentity, selectedEdgeIndex]); + + useEffect(() => { + if (orderedNodeIds.length === 0 && graph) { + hasAutoFitInitialRenderRef.current = false; + } + }, [graph, orderedNodeIds.length]); + useEffect(() => { if (!orderedNodeIds.length) { setFocusedNodeId(null); @@ -420,12 +703,13 @@ export function DiagramCanvas({ }, [focusedNodeId, orderedNodeIds]); useEffect(() => { - if (!graphBounds || !svg) { + if (!graphBounds || !svg || hasAutoFitInitialRenderRef.current) { return; } - fitToDiagram(false); - }, [fitToDiagram, graphBounds, svg]); + hasAutoFitInitialRenderRef.current = true; + fitBoundsToViewport(graphBounds, false); + }, [fitBoundsToViewport, graphBounds, svg]); useEffect(() => { if (!animateTransform) { @@ -457,8 +741,13 @@ export function DiagramCanvas({ setPendingEdgeLabel(''); setShowGroupPrompt(false); setConnectSourceId(null); + connectionStartNodeIdRef.current = null; + setConnectionPreviewSourceId(null); setCursorPoint(null); setToolbarOpen(false); + setSelectedEdgeIdentity(null); + setEditingEdgeIdentity(null); + setEditingEdgeLabel(''); setMode('select'); containerRef.current?.focus(); } @@ -478,9 +767,18 @@ export function DiagramCanvas({ setShowGroupPrompt(true); } - if ((event.key === 'Delete' || event.key === 'Backspace') && selection.length > 0 && !readOnly) { - event.preventDefault(); - onDeleteNodes?.(selection); + if ((event.key === 'Delete' || event.key === 'Backspace') && !readOnly) { + if (selection.length > 0) { + event.preventDefault(); + onDeleteNodes?.(selection); + return; + } + + if (selectedCurrentEdgeIdentity) { + event.preventDefault(); + onDeleteEdge?.(selectedCurrentEdgeIdentity); + setSelectedEdgeIdentity(null); + } } }; @@ -497,7 +795,7 @@ export function DiagramCanvas({ window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keyup', handleKeyUp); }; - }, [editingNodeId, graph, onDeleteNodes, onUngroupNodes, readOnly, selection, setMode]); + }, [editingNodeId, graph, onDeleteEdge, onDeleteNodes, onUngroupNodes, readOnly, selectedCurrentEdgeIdentity, selection, setMode]); useEffect(() => { if (viewport.zoom >= EDITOR_MIN_ZOOM) { @@ -583,6 +881,8 @@ export function DiagramCanvas({ } setSelection([]); + setSelectedEdgeIdentity(null); + setEditingEdgeIdentity(null); setToolbarOpen(false); setShapePickerOpen(false); setEditingNodeId(null); @@ -590,29 +890,23 @@ export function DiagramCanvas({ const handleNodeClick = useCallback((nodeId: string, shiftKey: boolean) => { setShapePickerOpen(false); + setSelectedEdgeIdentity(null); + setEditingEdgeIdentity(null); setFocusedNodeId(nodeId); setToolbarOpen(true); if (mode === 'connect') { - if (!connectSourceId) { - setConnectSourceId(nodeId); + const activation = getConnectNodeActivation(nodeId, connectSourceId, interactiveNodeBounds); + if (activation.kind === 'choose-source') { + setConnectSourceId(activation.nodeId); return; } - if (connectSourceId === nodeId) { + if (activation.kind === 'noop') { return; } - const sourceBounds = hitMap?.nodes.get(connectSourceId); - const targetBounds = hitMap?.nodes.get(nodeId); - const midpoint = sourceBounds && targetBounds - ? { - x: (getBoundsCenter(sourceBounds).x + getBoundsCenter(targetBounds).x) / 2, - y: (getBoundsCenter(sourceBounds).y + getBoundsCenter(targetBounds).y) / 2, - } - : { x: 0, y: 0 }; - - setPendingEdge({ midpoint, source: connectSourceId, target: nodeId }); + setPendingEdge(activation.edge); setPendingEdgeLabel(''); setConnectSourceId(null); return; @@ -626,7 +920,7 @@ export function DiagramCanvas({ } setSelection([nodeId]); - }, [connectSourceId, hitMap?.nodes, mode, selection, setSelection]); + }, [connectSourceId, interactiveNodeBounds, mode, selection, setSelection]); const commitNodeEdit = useCallback(() => { if (!editingNodeId) { @@ -637,6 +931,27 @@ export function DiagramCanvas({ setEditingNodeId(null); }, [editingLabel, editingNodeId, onEditNodeLabel]); + const commitEdgeEdit = useCallback(() => { + if (!editingEdgeIdentity) { + return; + } + + onEditEdgeLabel?.(editingEdgeIdentity, editingEdgeLabel.trim() || undefined); + setEditingEdgeIdentity(null); + setEditingEdgeLabel(''); + }, [editingEdgeIdentity, editingEdgeLabel, onEditEdgeLabel]); + + const openEdgeEditor = useCallback((edge: DiagramEdgeIdentity) => { + const edgeIndex = graph ? resolveDiagramEdgeIndex(graph.links, edge, { includeLabel: false }) : null; + const currentEdge = edgeIndex === null ? null : graph?.links[edgeIndex]; + if (!currentEdge || readOnly) { + return; + } + + setEditingEdgeIdentity(edge); + setEditingEdgeLabel(getLinkText(currentEdge) ?? ''); + }, [graph, readOnly]); + const commitPendingEdge = useCallback((label?: string) => { if (!pendingEdge) { return; @@ -648,12 +963,150 @@ export function DiagramCanvas({ setMode('select'); }, [onAddEdge, pendingEdge, selectedConnectionType, setMode]); + const handleFlowNodesChange = useCallback((changes: NodeChange[]) => { + const movedNodes = changes.filter((change) => change.type === 'position' && change.position); + if (movedNodes.length === 0) { + return; + } + + setLiveNodePositions((current) => { + const next = { ...current }; + movedNodes.forEach((change) => { + if (change.type === 'position' && change.position) { + next[change.id] = change.position; + } + }); + return next; + }); + }, []); + + const handleFlowNodeDragStop = useCallback>((_event, node) => { + setNodePositions((current) => ({ + ...current, + [node.id]: node.position, + }), 'merge', { [node.id]: node.position }); + setLiveNodePositions((current) => { + const next = { ...current }; + delete next[node.id]; + return next; + }); + }, [setNodePositions]); + + const handleFlowConnectStart = useCallback((_event, params) => { + connectionStartNodeIdRef.current = params.nodeId ?? null; + setConnectionPreviewSourceId(params.nodeId ?? null); + setToolbarOpen(false); + }, []); + + const handleFlowConnectEnd = useCallback((event, connectionState: FinalConnectionState) => { + const sourceNodeId = connectionStartNodeIdRef.current; + connectionStartNodeIdRef.current = null; + setConnectionPreviewSourceId(null); + + if (!sourceNodeId || readOnly || connectionState.isValid) { + return; + } + + const clientPoint = getClientPoint(event); + if (!clientPoint || !containerRef.current) { + return; + } + + const rect = containerRef.current.getBoundingClientRect(); + const dropPoint = { + x: (clientPoint.x - rect.left - viewport.panX) / viewport.zoom, + y: (clientPoint.y - rect.top - viewport.panY) / viewport.zoom, + }; + const nodePosition = { + x: dropPoint.x - (GHOST_NODE_WIDTH / 2), + y: dropPoint.y - (GHOST_NODE_HEIGHT / 2), + }; + + onAddConnectedNode?.(sourceNodeId, DEFAULT_NEW_NODE_LABEL, DEFAULT_NEW_NODE_SHAPE, nodePosition, selectedConnectionType); + }, [onAddConnectedNode, readOnly, selectedConnectionType, viewport.panX, viewport.panY, viewport.zoom]); + + const handleFlowConnect = useCallback((connection: Connection) => { + connectionStartNodeIdRef.current = null; + setConnectionPreviewSourceId(null); + if (!connection.source || !connection.target || connection.source === connection.target || !interactiveNodeBounds) { + return; + } + + const midpoint = getEdgeMidpoint({ + source: connection.source, + target: connection.target, + }, interactiveNodeBounds) ?? { x: 0, y: 0 }; + + setPendingEdge({ midpoint, source: connection.source, target: connection.target }); + setPendingEdgeLabel(''); + }, [interactiveNodeBounds]); + + const addDefaultNode = useCallback(() => { + onAddNode?.(DEFAULT_NEW_NODE_LABEL, DEFAULT_NEW_NODE_SHAPE); + }, [onAddNode]); + + const addNodeFromToolbar = useCallback(() => { + onAddNode?.(newNodeLabel.trim() || DEFAULT_NEW_NODE_LABEL, newNodeShape); + setNewNodeLabel(''); + }, [newNodeLabel, newNodeShape, onAddNode]); + const openNodeEditor = useCallback((node: DiagramNode) => { setToolbarOpen(false); setEditingNodeId(node.id); setEditingLabel(getNodeText(node)); }, []); + const registerNodeElement = useCallback((nodeId: string, element: HTMLElement | null) => { + if (element) { + nodeButtonRefs.current.set(nodeId, element); + return; + } + + nodeButtonRefs.current.delete(nodeId); + }, []); + + const handleNodeKeyDown = useCallback((nodeId: string, event: ReactKeyboardEvent) => { + if (event.key === 'ArrowUp') { + event.preventDefault(); + moveFocus(nodeId, 'up'); + } + if (event.key === 'ArrowDown') { + event.preventDefault(); + moveFocus(nodeId, 'down'); + } + if (event.key === 'ArrowLeft') { + event.preventDefault(); + moveFocus(nodeId, 'left'); + } + if (event.key === 'ArrowRight') { + event.preventDefault(); + moveFocus(nodeId, 'right'); + } + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleNodeClick(nodeId, false); + } + if (event.key === 'F2') { + event.preventDefault(); + const node = nodeById.get(nodeId); + if (node && !readOnly) { + openNodeEditor(node); + } + } + if (event.key === 'Escape') { + event.preventDefault(); + setToolbarOpen(false); + containerRef.current?.focus(); + } + }, [handleNodeClick, moveFocus, nodeById, openNodeEditor, readOnly]); + + const flowNodeInteraction = useMemo(() => ({ + focusedNodeId, + onFocus: setFocusedNodeId, + onKeyDown: handleNodeKeyDown, + registerNodeElement, + }), [focusedNodeId, handleNodeKeyDown, registerNodeElement]); + const transformStyle: CSSProperties = { inset: 0, position: 'absolute', @@ -705,20 +1158,22 @@ export function DiagramCanvas({ {svg ? (