950 lines
32 KiB
TypeScript
950 lines
32 KiB
TypeScript
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<EdgeLineStyle, string | undefined> = {
|
||
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<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 },
|
||
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<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 [selectedMode, setSelectedMode] = useState<"edit" | "view">("edit");
|
||
const mode = lockedMode ?? selectedMode;
|
||
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),
|
||
)
|
||
.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<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);
|
||
}, []);
|
||
|
||
// 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<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 updateSelectedEdgeData = useCallback(
|
||
(patch: Partial<DiagramEdgeData>) => {
|
||
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 (
|
||
<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}
|
||
onBeforeDelete={onBeforeDelete}
|
||
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 />}
|
||
{!lockedMode && (
|
||
<Panel position="top-right" className="diagram-mode-toggle">
|
||
<ChoiceGroup
|
||
size="xs"
|
||
name="diagram-mode"
|
||
items={MODE_ITEMS}
|
||
value={MODE_ITEMS.find((item) => item.mode === mode) ?? null}
|
||
onChange={(item) => setSelectedMode(item.mode)}
|
||
/>
|
||
</Panel>
|
||
)}
|
||
{mode === "edit" && (
|
||
<Panel position="top-left" className="diagram-toolbar">
|
||
{NODE_KINDS.map(({ kind, label }) => (
|
||
<Button
|
||
key={kind}
|
||
size="xs"
|
||
view="ghost"
|
||
label={label}
|
||
onClick={() => addNode(kind)}
|
||
/>
|
||
))}
|
||
</Panel>
|
||
)}
|
||
<Panel position="top-center" className="diagram-breadcrumbs">
|
||
<Breadcrumbs
|
||
size="xs"
|
||
items={[
|
||
{
|
||
label: "Корень",
|
||
onClick: focusNodeId ? () => goToLevel(null) : undefined,
|
||
},
|
||
...breadcrumbPath.map((node, index) => ({
|
||
label: (node.data as DiagramNodeData).label,
|
||
onClick:
|
||
index === breadcrumbPath.length - 1 ?
|
||
undefined
|
||
: () => goToLevel(node.id),
|
||
})),
|
||
]}
|
||
getItemOnClick={(item) => item.onClick}
|
||
/>
|
||
</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 : ""
|
||
}
|
||
data={(selectedEdge.data as DiagramEdgeData) ?? {}}
|
||
onLabelChange={updateSelectedEdgeLabel}
|
||
onDataChange={updateSelectedEdgeData}
|
||
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>
|
||
);
|
||
}
|