Files
Portal-Chart-Builder/src/DiagramEditor.tsx
T
2026-07-20 13:17:41 +03:00

716 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<NodeKind, { width: number; height: number }> = {
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<Node[]>(() => toRfNodes(initialValue));
const [edges, setEdges] = useState<Edge[]>(() => toRfEdges(initialValue));
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
const [focusNodeId, setFocusNodeId] = useState<string | null>(null);
const [mode, setMode] = useState<'edit' | 'view'>('edit');
const [alignmentGuides, setAlignmentGuides] = useState<AlignmentGuide[]>([]);
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<Node | null>(null);
const canvasWrapperRef = useRef<HTMLDivElement>(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<string>();
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<string>();
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<DiagramNodeData>) => {
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 (
<div className="diagram-shell">
<div className="diagram-canvas" ref={canvasWrapperRef}>
<NodesWithChildrenContext.Provider value={nodesWithChildren}>
<ModeContext.Provider value={mode}>
<ReactFlow
nodes={visibleNodes}
edges={visibleEdges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onReconnect={onReconnect}
edgesReconnectable={mode === 'edit'}
onNodeClick={onNodeClick}
onEdgeClick={onEdgeClick}
onPaneClick={onPaneClick}
nodesDraggable={mode === 'edit'}
nodesConnectable={mode === 'edit'}
deleteKeyCode={mode === 'edit' ? undefined : null}
zoomOnDoubleClick={false}
connectionMode={ConnectionMode.Loose}
zIndexMode="manual"
className={mode === 'view' ? 'diagram-flow--view-mode' : undefined}
fitView
proOptions={{ hideAttribution: true }}
>
<Background />
<ViewportPortal>
{alignmentGuides.map((guide, index) => (
<div
key={`${guide.axis}-${index}`}
className="diagram-alignment-guide"
style={
guide.axis === 'x'
? { left: guide.position, top: -10000, width: 1, height: 20000 }
: { top: guide.position, left: -10000, height: 1, width: 20000 }
}
/>
))}
</ViewportPortal>
<Controls showZoom={false} showFitView={false} showInteractive={false}>
<ControlButton onClick={() => zoomIn()} title="Увеличить">
+
</ControlButton>
<ControlButton onClick={() => zoomOut()} title="Уменьшить">
−
</ControlButton>
<ControlButton onClick={() => fitView()} title="Показать всё">
⛶
</ControlButton>
{mode === 'edit' && (
<>
<ControlButton onClick={undo} disabled={historyRef.current.length === 0} title="Отменить (Ctrl+Z)">
↶
</ControlButton>
<ControlButton onClick={redo} disabled={redoRef.current.length === 0} title="Повторить">
↷
</ControlButton>
</>
)}
</Controls>
{mode === 'edit' && <MiniMap />}
<Panel position="top-right" className="diagram-mode-toggle">
<button type="button" disabled={mode === 'view'} onClick={() => setMode('view')}>
Просмотр
</button>
<button type="button" disabled={mode === 'edit'} onClick={() => setMode('edit')}>
Редактирование
</button>
</Panel>
{mode === 'edit' && (
<Panel position="top-left" className="diagram-toolbar">
{NODE_KINDS.map(({ kind, label }) => (
<button key={kind} type="button" onClick={() => addNode(kind)}>
{label}
</button>
))}
</Panel>
)}
<Panel position="top-center" className="diagram-breadcrumbs">
<button type="button" disabled={!focusNodeId} onClick={() => goToLevel(null)}>
Корень
</button>
{breadcrumbPath.map((node, index) => (
<span key={node.id}>
{' / '}
<button
type="button"
disabled={index === breadcrumbPath.length - 1}
onClick={() => goToLevel(node.id)}
>
{(node.data as DiagramNodeData).label}
</button>
</span>
))}
</Panel>
</ReactFlow>
</ModeContext.Provider>
</NodesWithChildrenContext.Provider>
</div>
<aside className="diagram-aside">
{selectedNode ? (
<NodeInspector
nodeId={selectedNode.id}
nodeType={(selectedNode.type ?? 'task') as NodeKind}
data={selectedNode.data as DiagramNodeData}
childCount={selectedNodeChildCount}
mode={mode}
onChange={updateSelectedNodeData}
onEnterSubProcess={enterSubProcess}
onDuplicate={duplicateSelectedNode}
onDelete={deleteSelectedNode}
onBringToFront={bringSelectedNodeToFront}
onSendToBack={sendSelectedNodeToBack}
/>
) : selectedEdge ? (
<EdgeInspector
edgeId={selectedEdge.id}
label={typeof selectedEdge.label === 'string' ? selectedEdge.label : ''}
onChange={updateSelectedEdgeLabel}
onDelete={deleteSelectedEdge}
onBringToFront={bringSelectedEdgeToFront}
onSendToBack={sendSelectedEdgeToBack}
/>
) : (
<p className="diagram-aside__hint">
{mode === 'edit'
? 'Выберите узел или связь на схеме — появятся кнопки «Дублировать»/«Удалить» и поля данных.'
: 'Кликните по узлу, чтобы увидеть описание. Если у узла есть вложенный процесс, появится кнопка перехода.'}
</p>
)}
{mode === 'edit' && (
<details className="diagram-aside__json">
<summary>Текущий doc (для демо)</summary>
<pre>{JSON.stringify(serializeDocument(nodes, edges), null, 2)}</pre>
</details>
)}
</aside>
</div>
);
}
export function DiagramEditor(props: DiagramEditorProps) {
return (
<ReactFlowProvider>
<DiagramCanvas {...props} />
</ReactFlowProvider>
);
}