Сделал элементы согласно нотации, убрал хэш при билде
This commit is contained in:
+339
-180
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
@@ -23,53 +23,63 @@ import {
|
||||
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';
|
||||
} 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;
|
||||
}
|
||||
|
||||
const DEFAULT_EDGE_TYPE = 'smoothstep';
|
||||
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_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',
|
||||
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: '+ Искл. шлюз' },
|
||||
{ kind: 'parallelGateway', label: '+ Паралл. шлюз' },
|
||||
{ kind: 'inclusiveGateway', label: '+ Вкл. шлюз' },
|
||||
{ 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 MODE_ITEMS: { label: string; mode: "edit" | "view" }[] = [
|
||||
{ label: "Просмотр", mode: "view" },
|
||||
{ label: "Редактирование", mode: "edit" },
|
||||
];
|
||||
|
||||
const DEFAULT_NODE_SIZE: Record<NodeKind, { width: number; height: number }> = {
|
||||
@@ -80,10 +90,14 @@ const DEFAULT_NODE_SIZE: Record<NodeKind, { width: number; height: number }> = {
|
||||
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';
|
||||
axis: "x" | "y";
|
||||
position: number;
|
||||
}
|
||||
|
||||
@@ -101,7 +115,10 @@ const SNAP_THRESHOLD = 6;
|
||||
* 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[] } {
|
||||
function computeSnapAndGuides(
|
||||
dragged: Box,
|
||||
others: Box[],
|
||||
): { x: number; y: number; guides: AlignmentGuide[] } {
|
||||
const guides: AlignmentGuide[] = [];
|
||||
let snappedX = dragged.x;
|
||||
let snappedY = dragged.y;
|
||||
@@ -115,7 +132,7 @@ function computeSnapAndGuides(dragged: Box, others: Box[]): { x: number; y: numb
|
||||
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 });
|
||||
guides.push({ axis: "x", position: otherCenterX });
|
||||
matchedX = true;
|
||||
}
|
||||
}
|
||||
@@ -123,7 +140,7 @@ function computeSnapAndGuides(dragged: Box, others: Box[]): { x: number; y: numb
|
||||
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 });
|
||||
guides.push({ axis: "y", position: otherCenterY });
|
||||
matchedY = true;
|
||||
}
|
||||
}
|
||||
@@ -172,7 +189,11 @@ function toRfEdges(doc: DiagramDocument | null): Edge[] {
|
||||
|
||||
function isEditableTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
return target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
|
||||
return (
|
||||
target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.isContentEditable
|
||||
);
|
||||
}
|
||||
|
||||
function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
@@ -181,7 +202,7 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
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 [mode, setMode] = useState<"edit" | "view">("edit");
|
||||
const [alignmentGuides, setAlignmentGuides] = useState<AlignmentGuide[]>([]);
|
||||
const nodeCounter = useRef(0);
|
||||
const historyRef = useRef<{ nodes: Node[]; edges: Edge[] }[]>([]);
|
||||
@@ -192,7 +213,11 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
const { screenToFlowPosition, zoomIn, zoomOut, fitView } = useReactFlow();
|
||||
|
||||
const emitChange = useMemo(
|
||||
() => debounce((n: Node[], e: Edge[]) => onChange(serializeDocument(n, e)), 400),
|
||||
() =>
|
||||
debounce(
|
||||
(n: Node[], e: Edge[]) => onChange(serializeDocument(n, e)),
|
||||
400,
|
||||
),
|
||||
[onChange],
|
||||
);
|
||||
|
||||
@@ -251,7 +276,9 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
const visibleEdges = useMemo(() => {
|
||||
const visibleIds = new Set(visibleNodes.map((node) => node.id));
|
||||
return edges
|
||||
.filter((edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target))
|
||||
.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;
|
||||
@@ -261,9 +288,11 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
...edge.style,
|
||||
stroke: edgeColor,
|
||||
strokeWidth: edgeData?.strokeWidth ?? DEFAULT_EDGE_STROKE_WIDTH,
|
||||
strokeDasharray: EDGE_DASH_ARRAY[edgeData?.lineStyle ?? 'solid'],
|
||||
strokeDasharray: EDGE_DASH_ARRAY[edgeData?.lineStyle ?? "solid"],
|
||||
},
|
||||
pathOptions: {
|
||||
borderRadius: edgeData?.borderRadius ?? DEFAULT_EDGE_BORDER_RADIUS,
|
||||
},
|
||||
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 },
|
||||
@@ -309,9 +338,13 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
// 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));
|
||||
const hasContainerNode = nodesToDelete.some((node) =>
|
||||
nodes.some((n) => n.parentId === node.id),
|
||||
);
|
||||
if (hasContainerNode) {
|
||||
window.alert('Сначала удалите вложенные узлы — у этого узла есть под-процесс.');
|
||||
window.alert(
|
||||
"Сначала удалите вложенные узлы — у этого узла есть под-процесс.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -321,16 +354,28 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
|
||||
const onNodesChange = useCallback(
|
||||
(changes: NodeChange[]) => {
|
||||
const positionChanges = changes.filter((c) => c.type === 'position' && c.position);
|
||||
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 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 }));
|
||||
.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,
|
||||
@@ -340,7 +385,9 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
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,
|
||||
c === change ?
|
||||
{ ...c, position: { x: snapped.x, y: snapped.y } }
|
||||
: c,
|
||||
);
|
||||
}
|
||||
} else if (alignmentGuides.length > 0) {
|
||||
@@ -349,9 +396,9 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
|
||||
const isHistoryWorthy = changes.some(
|
||||
(change) =>
|
||||
change.type === 'remove' ||
|
||||
(change.type === 'position' && change.dragging === false) ||
|
||||
(change.type === 'dimensions' && change.resizing === false),
|
||||
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);
|
||||
@@ -361,10 +408,16 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
// 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),
|
||||
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))
|
||||
const nextEdges =
|
||||
removedIds.size ?
|
||||
edges.filter(
|
||||
(edge) =>
|
||||
!removedIds.has(edge.source) && !removedIds.has(edge.target),
|
||||
)
|
||||
: edges;
|
||||
|
||||
setNodes(next);
|
||||
@@ -376,7 +429,9 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
|
||||
const onEdgesChange = useCallback(
|
||||
(changes: EdgeChange[]) => {
|
||||
const isHistoryWorthy = changes.some((change) => change.type === 'remove');
|
||||
const isHistoryWorthy = changes.some(
|
||||
(change) => change.type === "remove",
|
||||
);
|
||||
if (isHistoryWorthy) pushHistory(nodes, edges);
|
||||
const next = applyEdgeChanges(changes, edges);
|
||||
setEdges(next);
|
||||
@@ -388,8 +443,22 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
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 },
|
||||
{
|
||||
...connection,
|
||||
id: crypto.randomUUID(),
|
||||
type: DEFAULT_EDGE_TYPE,
|
||||
markerEnd: DEFAULT_MARKER_END,
|
||||
...(involvesDataObject ?
|
||||
{ data: { lineStyle: "dashed" } as DiagramEdgeData }
|
||||
: {}),
|
||||
},
|
||||
edges,
|
||||
);
|
||||
setEdges(next);
|
||||
@@ -402,7 +471,9 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
(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 });
|
||||
const next = reconnectEdge(oldEdge, connection, edges, {
|
||||
shouldReplaceId: false,
|
||||
});
|
||||
setEdges(next);
|
||||
emitChange(nodes, next);
|
||||
},
|
||||
@@ -416,7 +487,7 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
|
||||
const onEdgeClick: EdgeMouseHandler = useCallback(
|
||||
(_event, edge) => {
|
||||
if (mode !== 'edit') return;
|
||||
if (mode !== "edit") return;
|
||||
setSelectedEdgeId(edge.id);
|
||||
setSelectedNodeId(null);
|
||||
},
|
||||
@@ -433,7 +504,9 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
if (!selectedNodeId) return;
|
||||
pushHistoryThrottled(nodes, edges);
|
||||
const next = nodes.map((node) =>
|
||||
node.id === selectedNodeId ? { ...node, data: { ...node.data, ...patch } } : node,
|
||||
node.id === selectedNodeId ?
|
||||
{ ...node, data: { ...node.data, ...patch } }
|
||||
: node,
|
||||
);
|
||||
setNodes(next);
|
||||
emitChange(next, edges);
|
||||
@@ -445,7 +518,9 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
(label: string) => {
|
||||
if (!selectedEdgeId) return;
|
||||
pushHistoryThrottled(nodes, edges);
|
||||
const next = edges.map((edge) => (edge.id === selectedEdgeId ? { ...edge, label } : edge));
|
||||
const next = edges.map((edge) =>
|
||||
edge.id === selectedEdgeId ? { ...edge, label } : edge,
|
||||
);
|
||||
setEdges(next);
|
||||
emitChange(nodes, next);
|
||||
},
|
||||
@@ -457,7 +532,9 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
if (!selectedEdgeId) return;
|
||||
pushHistoryThrottled(nodes, edges);
|
||||
const next = edges.map((edge) =>
|
||||
edge.id === selectedEdgeId ? { ...edge, data: { ...edge.data, ...patch } } : edge,
|
||||
edge.id === selectedEdgeId ?
|
||||
{ ...edge, data: { ...edge.data, ...patch } }
|
||||
: edge,
|
||||
);
|
||||
setEdges(next);
|
||||
emitChange(nodes, next);
|
||||
@@ -468,7 +545,10 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
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 });
|
||||
return screenToFlowPosition({
|
||||
x: rect.x + rect.width / 2,
|
||||
y: rect.y + rect.height / 2,
|
||||
});
|
||||
}, [screenToFlowPosition]);
|
||||
|
||||
const addNode = useCallback(
|
||||
@@ -487,7 +567,12 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
},
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
data: { label: NODE_KINDS.find((n) => n.kind === kind)!.label.replace('+ ', '') } as DiagramNodeData,
|
||||
data: {
|
||||
label: NODE_KINDS.find((n) => n.kind === kind)!.label.replace(
|
||||
"+ ",
|
||||
"",
|
||||
),
|
||||
} as DiagramNodeData,
|
||||
...(focusNodeId ? { parentId: focusNodeId } : {}),
|
||||
};
|
||||
pushHistory(nodes, edges);
|
||||
@@ -499,7 +584,8 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
);
|
||||
|
||||
const copySelectedNode = useCallback(() => {
|
||||
const node = selectedNodeId ? nodes.find((n) => n.id === selectedNodeId) : undefined;
|
||||
const node =
|
||||
selectedNodeId ? nodes.find((n) => n.id === selectedNodeId) : undefined;
|
||||
if (!node) return;
|
||||
clipboardRef.current = node;
|
||||
}, [selectedNodeId, nodes]);
|
||||
@@ -509,7 +595,11 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
if (!source) return;
|
||||
nodeCounter.current += 1;
|
||||
const newId = crypto.randomUUID();
|
||||
const { parentId: _sourceParentId, selected: _sourceSelected, ...restSource } = source;
|
||||
const {
|
||||
parentId: _sourceParentId,
|
||||
selected: _sourceSelected,
|
||||
...restSource
|
||||
} = source;
|
||||
const newNode: Node = {
|
||||
...restSource,
|
||||
id: newId,
|
||||
@@ -533,12 +623,16 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
const deleteSelectedNode = useCallback(() => {
|
||||
if (!selectedNodeId) return;
|
||||
if (nodes.some((n) => n.parentId === selectedNodeId)) {
|
||||
window.alert('Сначала удалите вложенные узлы — у этого узла есть под-процесс.');
|
||||
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);
|
||||
const nextEdges = edges.filter(
|
||||
(e) => e.source !== selectedNodeId && e.target !== selectedNodeId,
|
||||
);
|
||||
setNodes(nextNodes);
|
||||
setEdges(nextEdges);
|
||||
emitChange(nextNodes, nextEdges);
|
||||
@@ -557,11 +651,21 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
// 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)),
|
||||
() =>
|
||||
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)),
|
||||
() =>
|
||||
Math.min(
|
||||
0,
|
||||
...nodes.map((n) => n.zIndex ?? 0),
|
||||
...edges.map((e) => e.zIndex ?? 0),
|
||||
),
|
||||
[nodes, edges],
|
||||
);
|
||||
|
||||
@@ -569,7 +673,9 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
if (!selectedNodeId) return;
|
||||
const z = maxZIndex() + 1;
|
||||
pushHistory(nodes, edges);
|
||||
const next = nodes.map((n) => (n.id === selectedNodeId ? { ...n, zIndex: z } : n));
|
||||
const next = nodes.map((n) =>
|
||||
n.id === selectedNodeId ? { ...n, zIndex: z } : n,
|
||||
);
|
||||
setNodes(next);
|
||||
emitChange(next, edges);
|
||||
}, [selectedNodeId, nodes, edges, emitChange, pushHistory, maxZIndex]);
|
||||
@@ -578,7 +684,9 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
if (!selectedNodeId) return;
|
||||
const z = minZIndex() - 1;
|
||||
pushHistory(nodes, edges);
|
||||
const next = nodes.map((n) => (n.id === selectedNodeId ? { ...n, zIndex: z } : n));
|
||||
const next = nodes.map((n) =>
|
||||
n.id === selectedNodeId ? { ...n, zIndex: z } : n,
|
||||
);
|
||||
setNodes(next);
|
||||
emitChange(next, edges);
|
||||
}, [selectedNodeId, nodes, edges, emitChange, pushHistory, minZIndex]);
|
||||
@@ -587,7 +695,9 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
if (!selectedEdgeId) return;
|
||||
const z = maxZIndex() + 1;
|
||||
pushHistory(nodes, edges);
|
||||
const next = edges.map((e) => (e.id === selectedEdgeId ? { ...e, zIndex: z } : e));
|
||||
const next = edges.map((e) =>
|
||||
e.id === selectedEdgeId ? { ...e, zIndex: z } : e,
|
||||
);
|
||||
setEdges(next);
|
||||
emitChange(nodes, next);
|
||||
}, [selectedEdgeId, nodes, edges, emitChange, pushHistory, maxZIndex]);
|
||||
@@ -596,140 +706,185 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
if (!selectedEdgeId) return;
|
||||
const z = minZIndex() - 1;
|
||||
pushHistory(nodes, edges);
|
||||
const next = edges.map((e) => (e.id === selectedEdgeId ? { ...e, zIndex: z } : e));
|
||||
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 (mode !== "edit" || isEditableTarget(event.target)) return;
|
||||
if (!(event.ctrlKey || event.metaKey)) return;
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === 'z') {
|
||||
if (key === "z") {
|
||||
event.preventDefault();
|
||||
undo();
|
||||
} else if (key === 'c') {
|
||||
} else if (key === "c") {
|
||||
event.preventDefault();
|
||||
copySelectedNode();
|
||||
} else if (key === 'v') {
|
||||
} else if (key === "v") {
|
||||
event.preventDefault();
|
||||
pasteClipboardNode();
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
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
|
||||
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;
|
||||
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 />}
|
||||
<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) => setMode(item.mode)}
|
||||
/>
|
||||
</Panel>
|
||||
{mode === 'edit' && (
|
||||
<Panel position="top-left" className="diagram-toolbar">
|
||||
{NODE_KINDS.map(({ kind, label }) => (
|
||||
<Button key={kind} size="xs" view="secondary" label={label} onClick={() => addNode(kind)} />
|
||||
<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 />}
|
||||
<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) => setMode(item.mode)}
|
||||
/>
|
||||
</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>
|
||||
{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 ? (
|
||||
{selectedNode ?
|
||||
<NodeInspector
|
||||
nodeId={selectedNode.id}
|
||||
nodeType={(selectedNode.type ?? 'task') as NodeKind}
|
||||
nodeType={(selectedNode.type ?? "task") as NodeKind}
|
||||
data={selectedNode.data as DiagramNodeData}
|
||||
childCount={selectedNodeChildCount}
|
||||
mode={mode}
|
||||
@@ -740,10 +895,12 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
onBringToFront={bringSelectedNodeToFront}
|
||||
onSendToBack={sendSelectedNodeToBack}
|
||||
/>
|
||||
) : selectedEdge ? (
|
||||
: selectedEdge ?
|
||||
<EdgeInspector
|
||||
edgeId={selectedEdge.id}
|
||||
label={typeof selectedEdge.label === 'string' ? selectedEdge.label : ''}
|
||||
label={
|
||||
typeof selectedEdge.label === "string" ? selectedEdge.label : ""
|
||||
}
|
||||
data={(selectedEdge.data as DiagramEdgeData) ?? {}}
|
||||
onLabelChange={updateSelectedEdgeLabel}
|
||||
onDataChange={updateSelectedEdgeData}
|
||||
@@ -751,18 +908,20 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||
onBringToFront={bringSelectedEdgeToFront}
|
||||
onSendToBack={sendSelectedEdgeToBack}
|
||||
/>
|
||||
) : (
|
||||
<p className="diagram-aside__hint">
|
||||
{mode === 'edit'
|
||||
? 'Выберите узел или связь на схеме — появятся кнопки «Дублировать»/«Удалить» и поля данных.'
|
||||
: 'Кликните по узлу, чтобы увидеть описание. Если у узла есть вложенный процесс, появится кнопка перехода.'}
|
||||
: <p className="diagram-aside__hint">
|
||||
{mode === "edit" ?
|
||||
"Выберите узел или связь на схеме — появятся кнопки «Дублировать»/«Удалить» и поля данных."
|
||||
: "Кликните по узлу, чтобы увидеть описание. Если у узла есть вложенный процесс, появится кнопка перехода."
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
}
|
||||
|
||||
{mode === 'edit' && (
|
||||
{mode === "edit" && (
|
||||
<details className="diagram-aside__json">
|
||||
<summary>Текущий doc (для демо)</summary>
|
||||
<pre>{JSON.stringify(serializeDocument(nodes, edges), null, 2)}</pre>
|
||||
<pre>
|
||||
{JSON.stringify(serializeDocument(nodes, edges), null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
Reference in New Issue
Block a user