import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ReactFlow, ReactFlowProvider, Background, Controls, ControlButton, MiniMap, Panel, ConnectionMode, MarkerType, ViewportPortal, useReactFlow, addEdge, reconnectEdge, applyNodeChanges, applyEdgeChanges, type Node, type Edge, type Connection, type OnConnect, type NodeChange, type EdgeChange, type NodeMouseHandler, type EdgeMouseHandler, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import { nodeTypes, NodesWithChildrenContext, ModeContext } from './nodes'; import { NodeInspector } from './NodeInspector'; import { EdgeInspector } from './EdgeInspector'; import { debounce, serializeDocument } from './serialize'; import type { DiagramDocument, DiagramNodeData, NodeKind } from './types'; import './diagram.css'; interface DiagramEditorProps { initialValue: DiagramDocument | null; onChange: (doc: DiagramDocument) => void; } const DEFAULT_EDGE_TYPE = 'smoothstep'; const DEFAULT_MARKER_END = { type: MarkerType.ArrowClosed }; const MAX_HISTORY = 50; const TYPING_HISTORY_THROTTLE_MS = 1000; const NODE_KINDS: { kind: NodeKind; label: string }[] = [ { kind: 'task', label: '+ Задача' }, { kind: 'startEvent', label: '+ Начало' }, { kind: 'intermediateEvent', label: '+ Промеж. событие' }, { kind: 'endEvent', label: '+ Конец' }, { kind: 'exclusiveGateway', label: '+ Искл. шлюз' }, { kind: 'parallelGateway', label: '+ Паралл. шлюз' }, { kind: 'inclusiveGateway', label: '+ Вкл. шлюз' }, ]; const DEFAULT_NODE_SIZE: Record = { task: { width: 140, height: 50 }, startEvent: { width: 48, height: 48 }, intermediateEvent: { width: 48, height: 48 }, endEvent: { width: 48, height: 48 }, exclusiveGateway: { width: 64, height: 64 }, parallelGateway: { width: 64, height: 64 }, inclusiveGateway: { width: 64, height: 64 }, }; interface AlignmentGuide { axis: 'x' | 'y'; position: number; } interface Box { x: number; y: number; width: number; height: number; } const SNAP_THRESHOLD = 6; /** * Compares the dragged box's center against every other box's center on the * same level and, for the first close-enough match on each axis, snaps to it * and records a guide line to draw across the canvas. */ function computeSnapAndGuides(dragged: Box, others: Box[]): { x: number; y: number; guides: AlignmentGuide[] } { const guides: AlignmentGuide[] = []; let snappedX = dragged.x; let snappedY = dragged.y; let matchedX = false; let matchedY = false; const draggedCenterX = dragged.x + dragged.width / 2; const draggedCenterY = dragged.y + dragged.height / 2; for (const other of others) { if (!matchedX) { const otherCenterX = other.x + other.width / 2; if (Math.abs(draggedCenterX - otherCenterX) <= SNAP_THRESHOLD) { snappedX = dragged.x + (otherCenterX - draggedCenterX); guides.push({ axis: 'x', position: otherCenterX }); matchedX = true; } } if (!matchedY) { const otherCenterY = other.y + other.height / 2; if (Math.abs(draggedCenterY - otherCenterY) <= SNAP_THRESHOLD) { snappedY = dragged.y + (otherCenterY - draggedCenterY); guides.push({ axis: 'y', position: otherCenterY }); matchedY = true; } } } return { x: snappedX, y: snappedY, guides }; } function toRfNodes(doc: DiagramDocument | null): Node[] { return (doc?.nodes ?? []).map((node) => { const defaults = DEFAULT_NODE_SIZE[node.type]; return { id: node.id, type: node.type, position: node.position, data: node.data, width: node.width ?? defaults.width, height: node.height ?? defaults.height, zIndex: node.zIndex, ...(node.parentId ? { parentId: node.parentId } : {}), }; }); } function toRfEdges(doc: DiagramDocument | null): Edge[] { const nodeIds = new Set((doc?.nodes ?? []).map((node) => node.id)); // Defends against documents saved by an older/buggy version, or edited by // hand — an edge pointing at a node id that no longer exists renders as a // degenerate path (xyflow falls back to position (0, 0) for the missing // endpoint), which is exactly the "arrows in the corner" bug. return (doc?.edges ?? []) .filter((edge) => nodeIds.has(edge.source) && nodeIds.has(edge.target)) .map((edge) => ({ id: edge.id, source: edge.source, target: edge.target, sourceHandle: edge.sourceHandle, targetHandle: edge.targetHandle, type: edge.type ?? DEFAULT_EDGE_TYPE, markerEnd: DEFAULT_MARKER_END, label: edge.label, zIndex: edge.zIndex, data: edge.data, })); } function isEditableTarget(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) return false; return target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable; } function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) { const [nodes, setNodes] = useState(() => toRfNodes(initialValue)); const [edges, setEdges] = useState(() => toRfEdges(initialValue)); const [selectedNodeId, setSelectedNodeId] = useState(null); const [selectedEdgeId, setSelectedEdgeId] = useState(null); const [focusNodeId, setFocusNodeId] = useState(null); const [mode, setMode] = useState<'edit' | 'view'>('edit'); const [alignmentGuides, setAlignmentGuides] = useState([]); const nodeCounter = useRef(0); const historyRef = useRef<{ nodes: Node[]; edges: Edge[] }[]>([]); const redoRef = useRef<{ nodes: Node[]; edges: Edge[] }[]>([]); const lastTypingHistoryPushRef = useRef(0); const clipboardRef = useRef(null); const canvasWrapperRef = useRef(null); const { screenToFlowPosition, zoomIn, zoomOut, fitView } = useReactFlow(); const emitChange = useMemo( () => debounce((n: Node[], e: Edge[]) => onChange(serializeDocument(n, e)), 400), [onChange], ); const pushHistory = useCallback((prevNodes: Node[], prevEdges: Edge[]) => { historyRef.current.push({ nodes: prevNodes, edges: prevEdges }); if (historyRef.current.length > MAX_HISTORY) historyRef.current.shift(); redoRef.current = []; }, []); const pushHistoryThrottled = useCallback( (prevNodes: Node[], prevEdges: Edge[]) => { const now = Date.now(); if (now - lastTypingHistoryPushRef.current > TYPING_HISTORY_THROTTLE_MS) { pushHistory(prevNodes, prevEdges); } lastTypingHistoryPushRef.current = now; }, [pushHistory], ); const undo = useCallback(() => { const previous = historyRef.current.pop(); if (!previous) return; redoRef.current.push({ nodes, edges }); setNodes(previous.nodes); setEdges(previous.edges); emitChange(previous.nodes, previous.edges); setSelectedNodeId(null); setSelectedEdgeId(null); }, [nodes, edges, emitChange]); const redo = useCallback(() => { const next = redoRef.current.pop(); if (!next) return; historyRef.current.push({ nodes, edges }); setNodes(next.nodes); setEdges(next.edges); emitChange(next.nodes, next.edges); setSelectedNodeId(null); setSelectedEdgeId(null); }, [nodes, edges, emitChange]); const nodesWithChildren = useMemo(() => { const set = new Set(); for (const node of nodes) { if (node.parentId) set.add(node.parentId); } return set; }, [nodes]); const visibleNodes = useMemo( () => nodes.filter((node) => (node.parentId ?? null) === focusNodeId), [nodes, focusNodeId], ); const visibleEdges = useMemo(() => { const visibleIds = new Set(visibleNodes.map((node) => node.id)); return edges.filter((edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target)); }, [edges, visibleNodes]); const breadcrumbPath = useMemo(() => { const path: Node[] = []; const visited = new Set(); let currentId = focusNodeId; while (currentId && !visited.has(currentId)) { visited.add(currentId); const node = nodes.find((n) => n.id === currentId); if (!node) break; path.unshift(node); currentId = node.parentId ?? null; } return path; }, [nodes, focusNodeId]); const enterSubProcess = useCallback((nodeId: string) => { setFocusNodeId(nodeId); setSelectedNodeId(null); setSelectedEdgeId(null); }, []); const goToLevel = useCallback((nodeId: string | null) => { setFocusNodeId(nodeId); setSelectedNodeId(null); setSelectedEdgeId(null); }, []); const onNodesChange = useCallback( (changes: NodeChange[]) => { const withChildren = new Set(nodes.filter((n) => n.parentId).map((n) => n.parentId!)); const filteredChanges = changes.filter((change) => { if (change.type !== 'remove' || !withChildren.has(change.id)) return true; window.alert('Сначала удалите вложенные узлы — у этого узла есть под-процесс.'); return false; }); const positionChanges = filteredChanges.filter((c) => c.type === 'position' && c.position); let adjustedChanges = filteredChanges; if (positionChanges.length === 1) { const change = positionChanges[0]; const draggedNode = change.type === 'position' ? nodes.find((n) => n.id === change.id) : undefined; if (change.type === 'position' && change.position && draggedNode) { const level = draggedNode.parentId ?? null; const others: Box[] = nodes .filter((n) => n.id !== draggedNode.id && (n.parentId ?? null) === level) .map((n) => ({ x: n.position.x, y: n.position.y, width: n.width ?? 0, height: n.height ?? 0 })); const dragged: Box = { x: change.position.x, y: change.position.y, width: draggedNode.width ?? 0, height: draggedNode.height ?? 0, }; const snapped = computeSnapAndGuides(dragged, others); setAlignmentGuides(change.dragging === false ? [] : snapped.guides); adjustedChanges = filteredChanges.map((c) => c === change ? { ...c, position: { x: snapped.x, y: snapped.y } } : c, ); } } else if (alignmentGuides.length > 0) { setAlignmentGuides([]); } const isHistoryWorthy = filteredChanges.some( (change) => change.type === 'remove' || (change.type === 'position' && change.dragging === false) || (change.type === 'dimensions' && change.resizing === false), ); if (isHistoryWorthy) pushHistory(nodes, edges); const next = applyNodeChanges(adjustedChanges, nodes); // React Flow's own keyboard delete dispatches node and edge removal as // separate change events that race through the same debounced // emitChange — filtering edges here too (redundantly with onEdgesChange) // guarantees a removed node never leaves a dangling edge behind. const removedIds = new Set( filteredChanges.filter((change) => change.type === 'remove').map((change) => change.id), ); const nextEdges = removedIds.size ? edges.filter((edge) => !removedIds.has(edge.source) && !removedIds.has(edge.target)) : edges; setNodes(next); if (nextEdges !== edges) setEdges(nextEdges); emitChange(next, nextEdges); }, [nodes, edges, emitChange, pushHistory, alignmentGuides], ); const onEdgesChange = useCallback( (changes: EdgeChange[]) => { const isHistoryWorthy = changes.some((change) => change.type === 'remove'); if (isHistoryWorthy) pushHistory(nodes, edges); const next = applyEdgeChanges(changes, edges); setEdges(next); emitChange(nodes, next); }, [nodes, edges, emitChange, pushHistory], ); const onConnect: OnConnect = useCallback( (connection) => { pushHistory(nodes, edges); const next = addEdge( { ...connection, id: crypto.randomUUID(), type: DEFAULT_EDGE_TYPE, markerEnd: DEFAULT_MARKER_END }, edges, ); setEdges(next); emitChange(nodes, next); }, [nodes, edges, emitChange, pushHistory], ); const onReconnect = useCallback( (oldEdge: Edge, connection: Connection) => { pushHistory(nodes, edges); // Keep the original edge id stable so selection/history don't go stale mid-drag. const next = reconnectEdge(oldEdge, connection, edges, { shouldReplaceId: false }); setEdges(next); emitChange(nodes, next); }, [nodes, edges, emitChange, pushHistory], ); const onNodeClick: NodeMouseHandler = useCallback((_event, node) => { setSelectedNodeId(node.id); setSelectedEdgeId(null); }, []); const onEdgeClick: EdgeMouseHandler = useCallback( (_event, edge) => { if (mode !== 'edit') return; setSelectedEdgeId(edge.id); setSelectedNodeId(null); }, [mode], ); const onPaneClick = useCallback(() => { setSelectedNodeId(null); setSelectedEdgeId(null); }, []); const updateSelectedNodeData = useCallback( (patch: Partial) => { if (!selectedNodeId) return; pushHistoryThrottled(nodes, edges); const next = nodes.map((node) => node.id === selectedNodeId ? { ...node, data: { ...node.data, ...patch } } : node, ); setNodes(next); emitChange(next, edges); }, [selectedNodeId, nodes, edges, emitChange, pushHistoryThrottled], ); const updateSelectedEdgeLabel = useCallback( (label: string) => { if (!selectedEdgeId) return; pushHistoryThrottled(nodes, edges); const next = edges.map((edge) => (edge.id === selectedEdgeId ? { ...edge, label } : edge)); setEdges(next); emitChange(nodes, next); }, [selectedEdgeId, nodes, edges, emitChange, pushHistoryThrottled], ); const getViewportCenter = useCallback(() => { const rect = canvasWrapperRef.current?.getBoundingClientRect(); if (!rect) return { x: 0, y: 0 }; return screenToFlowPosition({ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }); }, [screenToFlowPosition]); const addNode = useCallback( (kind: NodeKind) => { nodeCounter.current += 1; const id = crypto.randomUUID(); const center = getViewportCenter(); const fanOut = nodeCounter.current; const size = DEFAULT_NODE_SIZE[kind]; const newNode: Node = { id, type: kind, position: { x: center.x + ((fanOut * 24) % 120) - 60, y: center.y + ((fanOut * 18) % 90) - 45, }, width: size.width, height: size.height, data: { label: NODE_KINDS.find((n) => n.kind === kind)!.label.replace('+ ', '') } as DiagramNodeData, ...(focusNodeId ? { parentId: focusNodeId } : {}), }; pushHistory(nodes, edges); const next = [...nodes, newNode]; setNodes(next); emitChange(next, edges); }, [nodes, edges, emitChange, focusNodeId, pushHistory, getViewportCenter], ); const copySelectedNode = useCallback(() => { const node = selectedNodeId ? nodes.find((n) => n.id === selectedNodeId) : undefined; if (!node) return; clipboardRef.current = node; }, [selectedNodeId, nodes]); const pasteClipboardNode = useCallback(() => { const source = clipboardRef.current; if (!source) return; nodeCounter.current += 1; const newId = crypto.randomUUID(); const { parentId: _sourceParentId, selected: _sourceSelected, ...restSource } = source; const newNode: Node = { ...restSource, id: newId, position: { x: source.position.x + 40, y: source.position.y + 40 }, data: { ...(source.data as DiagramNodeData) }, ...(focusNodeId ? { parentId: focusNodeId } : {}), }; pushHistory(nodes, edges); const next = [...nodes, newNode]; setNodes(next); emitChange(next, edges); setSelectedNodeId(newId); setSelectedEdgeId(null); }, [nodes, edges, emitChange, focusNodeId, pushHistory]); const duplicateSelectedNode = useCallback(() => { copySelectedNode(); pasteClipboardNode(); }, [copySelectedNode, pasteClipboardNode]); const deleteSelectedNode = useCallback(() => { if (!selectedNodeId) return; if (nodes.some((n) => n.parentId === selectedNodeId)) { window.alert('Сначала удалите вложенные узлы — у этого узла есть под-процесс.'); return; } pushHistory(nodes, edges); const nextNodes = nodes.filter((n) => n.id !== selectedNodeId); const nextEdges = edges.filter((e) => e.source !== selectedNodeId && e.target !== selectedNodeId); setNodes(nextNodes); setEdges(nextEdges); emitChange(nextNodes, nextEdges); setSelectedNodeId(null); }, [selectedNodeId, nodes, edges, emitChange, pushHistory]); const deleteSelectedEdge = useCallback(() => { if (!selectedEdgeId) return; pushHistory(nodes, edges); const nextEdges = edges.filter((e) => e.id !== selectedEdgeId); setEdges(nextEdges); emitChange(nodes, nextEdges); setSelectedEdgeId(null); }, [selectedEdgeId, nodes, edges, emitChange, pushHistory]); // Nodes and edges share one z-index space (zIndexMode="manual"), so front/back // always means "above/below everything currently on the canvas", not just siblings. const maxZIndex = useCallback( () => Math.max(0, ...nodes.map((n) => n.zIndex ?? 0), ...edges.map((e) => e.zIndex ?? 0)), [nodes, edges], ); const minZIndex = useCallback( () => Math.min(0, ...nodes.map((n) => n.zIndex ?? 0), ...edges.map((e) => e.zIndex ?? 0)), [nodes, edges], ); const bringSelectedNodeToFront = useCallback(() => { if (!selectedNodeId) return; const z = maxZIndex() + 1; pushHistory(nodes, edges); const next = nodes.map((n) => (n.id === selectedNodeId ? { ...n, zIndex: z } : n)); setNodes(next); emitChange(next, edges); }, [selectedNodeId, nodes, edges, emitChange, pushHistory, maxZIndex]); const sendSelectedNodeToBack = useCallback(() => { if (!selectedNodeId) return; const z = minZIndex() - 1; pushHistory(nodes, edges); const next = nodes.map((n) => (n.id === selectedNodeId ? { ...n, zIndex: z } : n)); setNodes(next); emitChange(next, edges); }, [selectedNodeId, nodes, edges, emitChange, pushHistory, minZIndex]); const bringSelectedEdgeToFront = useCallback(() => { if (!selectedEdgeId) return; const z = maxZIndex() + 1; pushHistory(nodes, edges); const next = edges.map((e) => (e.id === selectedEdgeId ? { ...e, zIndex: z } : e)); setEdges(next); emitChange(nodes, next); }, [selectedEdgeId, nodes, edges, emitChange, pushHistory, maxZIndex]); const sendSelectedEdgeToBack = useCallback(() => { if (!selectedEdgeId) return; const z = minZIndex() - 1; pushHistory(nodes, edges); const next = edges.map((e) => (e.id === selectedEdgeId ? { ...e, zIndex: z } : e)); setEdges(next); emitChange(nodes, next); }, [selectedEdgeId, nodes, edges, emitChange, pushHistory, minZIndex]); useEffect(() => { function onKeyDown(event: KeyboardEvent) { if (mode !== 'edit' || isEditableTarget(event.target)) return; if (!(event.ctrlKey || event.metaKey)) return; const key = event.key.toLowerCase(); if (key === 'z') { event.preventDefault(); undo(); } else if (key === 'c') { event.preventDefault(); copySelectedNode(); } else if (key === 'v') { event.preventDefault(); pasteClipboardNode(); } } window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); }, [mode, undo, copySelectedNode, pasteClipboardNode]); const selectedNode = selectedNodeId ? nodes.find((node) => node.id === selectedNodeId) : undefined; const selectedNodeChildCount = selectedNode ? nodes.filter((node) => node.parentId === selectedNode.id).length : 0; const selectedEdge = selectedEdgeId ? edges.find((edge) => edge.id === selectedEdgeId) : undefined; return (
{alignmentGuides.map((guide, index) => (
))} zoomIn()} title="Увеличить"> + zoomOut()} title="Уменьшить"> − fitView()} title="Показать всё"> ⛶ {mode === 'edit' && ( <> ↶ ↷ )} {mode === 'edit' && } {mode === 'edit' && ( {NODE_KINDS.map(({ kind, label }) => ( ))} )} {breadcrumbPath.map((node, index) => ( {' / '} ))}
); } export function DiagramEditor(props: DiagramEditorProps) { return ( ); }