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 { Button } from "@consta/uikit/Button"; import { ChoiceGroup } from "@consta/uikit/ChoiceGroup"; import { Breadcrumbs } from "@consta/uikit/Breadcrumbs"; import { nodeTypes, NodesWithChildrenContext, ModeContext } from "./nodes"; import { NodeInspector } from "./NodeInspector"; import { EdgeInspector } from "./EdgeInspector"; import { debounce, serializeDocument } from "./serialize"; import type { DiagramDocument, DiagramEdgeData, DiagramNodeData, EdgeLineStyle, NodeKind, } from "./types"; import "./diagram.css"; interface DiagramEditorProps { initialValue: DiagramDocument | null; onChange: (doc: DiagramDocument) => void; /** * Режим, зафиксированный хостом (см. `?mode=view` в `src/App.tsx`). * Когда задан, переключатель «Просмотр/Редактирование» скрыт: хост, * встроивший приложение только на чтение, всё равно игнорирует * `diagram:change`, и предлагать правки, которые никуда не сохранятся, — * обман пользователя. */ lockedMode?: "edit" | "view"; } const DEFAULT_EDGE_TYPE = "smoothstep"; const DEFAULT_MARKER_END = { type: MarkerType.ArrowClosed }; const DEFAULT_EDGE_STROKE_WIDTH = 1; const DEFAULT_EDGE_BORDER_RADIUS = 5; // matches getSmoothStepPath's own default const DEFAULT_EDGE_COLOR = "#b1b1b7"; // xyflow's own default edge color const DEFAULT_EDGE_TEXT_COLOR = "#FFFFFF"; const DEFAULT_EDGE_FONT_SIZE = 11; const EDGE_DASH_ARRAY: Record = { solid: undefined, dashed: "8 4", dotted: "1.5 4", }; 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: "+ Искл. шлюз (XOR)" }, { kind: "parallelGateway", label: "+ Паралл. шлюз (AND)" }, { kind: "inclusiveGateway", label: "+ Вкл. шлюз (OR)" }, { kind: "complexGateway", label: "+ Сложный шлюз" }, { kind: "eventBasedGateway", label: "+ Шлюз по событию" }, { kind: "hint", label: "+ Подсказка" }, { kind: "dataObject", label: "+ Документ" }, ]; // Stable references — ChoiceGroup's controlled `value` is compared against // entries in `items` by identity, so this can't be recreated on every render. const MODE_ITEMS: { label: string; mode: "edit" | "view" }[] = [ { label: "Просмотр", mode: "view" }, { label: "Редактирование", mode: "edit" }, ]; 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 }, complexGateway: { width: 64, height: 64 }, eventBasedGateway: { width: 64, height: 64 }, hint: { width: 160, height: 70 }, dataObject: { width: 90, height: 70 }, }; 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, lockedMode }: 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 [selectedMode, setSelectedMode] = useState<"edit" | "view">("edit"); const mode = lockedMode ?? selectedMode; 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), ) .map((edge) => { const edgeData = edge.data as DiagramEdgeData | undefined; const edgeColor = edgeData?.color ?? DEFAULT_EDGE_COLOR; return { ...edge, style: { ...edge.style, stroke: edgeColor, strokeWidth: edgeData?.strokeWidth ?? DEFAULT_EDGE_STROKE_WIDTH, strokeDasharray: EDGE_DASH_ARRAY[edgeData?.lineStyle ?? "solid"], }, pathOptions: { borderRadius: edgeData?.borderRadius ?? DEFAULT_EDGE_BORDER_RADIUS, }, markerEnd: { type: MarkerType.ArrowClosed, color: edgeColor }, // Label renders as its own small chip — same color as the line, configurable text color. labelBgStyle: { fill: edgeColor }, labelStyle: { fill: edgeData?.textColor ?? DEFAULT_EDGE_TEXT_COLOR, fontWeight: 600, fontSize: edgeData?.fontSize ?? DEFAULT_EDGE_FONT_SIZE, }, labelBgPadding: [6, 4] as [number, number], labelBgBorderRadius: 4, }; }); }, [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); }, []); // Runs before React Flow computes any removal changes at all (for both // nodes and their connected edges), so blocking here — unlike filtering // inside onNodesChange — stops the connected edges from being deleted too. const onBeforeDelete = useCallback( async ({ nodes: nodesToDelete }: { nodes: Node[]; edges: Edge[] }) => { const hasContainerNode = nodesToDelete.some((node) => nodes.some((n) => n.parentId === node.id), ); if (hasContainerNode) { window.alert( "Сначала удалите вложенные узлы — у этого узла есть под-процесс.", ); return false; } return true; }, [nodes], ); const onNodesChange = useCallback( (changes: NodeChange[]) => { const positionChanges = changes.filter( (c) => c.type === "position" && c.position, ); let adjustedChanges = changes; 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 = changes.map((c) => c === change ? { ...c, position: { x: snapped.x, y: snapped.y } } : c, ); } } else if (alignmentGuides.length > 0) { setAlignmentGuides([]); } const isHistoryWorthy = changes.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( changes .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); // A Data Object endpoint means this is a BPMN Data Association, not a // Sequence Flow — those are conventionally drawn dashed, so default to // that (still just a starting point — line style stays editable after). const involvesDataObject = [connection.source, connection.target].some( (id) => nodes.find((n) => n.id === id)?.type === "dataObject", ); const next = addEdge( { ...connection, id: crypto.randomUUID(), type: DEFAULT_EDGE_TYPE, markerEnd: DEFAULT_MARKER_END, ...(involvesDataObject ? { data: { lineStyle: "dashed" } as DiagramEdgeData } : {}), }, 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 updateSelectedEdgeData = useCallback( (patch: Partial) => { if (!selectedEdgeId) return; pushHistoryThrottled(nodes, edges); const next = edges.map((edge) => edge.id === selectedEdgeId ? { ...edge, data: { ...edge.data, ...patch } } : 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" && } {!lockedMode && ( item.mode === mode) ?? null} onChange={(item) => setSelectedMode(item.mode)} /> )} {mode === "edit" && ( {NODE_KINDS.map(({ kind, label }) => (
); } export function DiagramEditor(props: DiagramEditorProps) { return ( ); }