Files
Portal-Chart-Builder/src/DiagramEditor.tsx
T

950 lines
32 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 { 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>
);
}