Сделал элементы согласно нотации, убрал хэш при билде
This commit is contained in:
+268
-109
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
ReactFlowProvider,
|
ReactFlowProvider,
|
||||||
@@ -23,53 +23,63 @@ import {
|
|||||||
type EdgeChange,
|
type EdgeChange,
|
||||||
type NodeMouseHandler,
|
type NodeMouseHandler,
|
||||||
type EdgeMouseHandler,
|
type EdgeMouseHandler,
|
||||||
} from '@xyflow/react';
|
} from "@xyflow/react";
|
||||||
import '@xyflow/react/dist/style.css';
|
import "@xyflow/react/dist/style.css";
|
||||||
import { Button } from '@consta/uikit/Button';
|
import { Button } from "@consta/uikit/Button";
|
||||||
import { ChoiceGroup } from '@consta/uikit/ChoiceGroup';
|
import { ChoiceGroup } from "@consta/uikit/ChoiceGroup";
|
||||||
import { Breadcrumbs } from '@consta/uikit/Breadcrumbs';
|
import { Breadcrumbs } from "@consta/uikit/Breadcrumbs";
|
||||||
import { nodeTypes, NodesWithChildrenContext, ModeContext } from './nodes';
|
import { nodeTypes, NodesWithChildrenContext, ModeContext } from "./nodes";
|
||||||
import { NodeInspector } from './NodeInspector';
|
import { NodeInspector } from "./NodeInspector";
|
||||||
import { EdgeInspector } from './EdgeInspector';
|
import { EdgeInspector } from "./EdgeInspector";
|
||||||
import { debounce, serializeDocument } from './serialize';
|
import { debounce, serializeDocument } from "./serialize";
|
||||||
import type { DiagramDocument, DiagramEdgeData, DiagramNodeData, EdgeLineStyle, NodeKind } from './types';
|
import type {
|
||||||
import './diagram.css';
|
DiagramDocument,
|
||||||
|
DiagramEdgeData,
|
||||||
|
DiagramNodeData,
|
||||||
|
EdgeLineStyle,
|
||||||
|
NodeKind,
|
||||||
|
} from "./types";
|
||||||
|
import "./diagram.css";
|
||||||
|
|
||||||
interface DiagramEditorProps {
|
interface DiagramEditorProps {
|
||||||
initialValue: DiagramDocument | null;
|
initialValue: DiagramDocument | null;
|
||||||
onChange: (doc: DiagramDocument) => void;
|
onChange: (doc: DiagramDocument) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_EDGE_TYPE = 'smoothstep';
|
const DEFAULT_EDGE_TYPE = "smoothstep";
|
||||||
const DEFAULT_MARKER_END = { type: MarkerType.ArrowClosed };
|
const DEFAULT_MARKER_END = { type: MarkerType.ArrowClosed };
|
||||||
const DEFAULT_EDGE_STROKE_WIDTH = 1;
|
const DEFAULT_EDGE_STROKE_WIDTH = 1;
|
||||||
const DEFAULT_EDGE_BORDER_RADIUS = 5; // matches getSmoothStepPath's own default
|
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_COLOR = "#b1b1b7"; // xyflow's own default edge color
|
||||||
const DEFAULT_EDGE_TEXT_COLOR = '#FFFFFF';
|
const DEFAULT_EDGE_TEXT_COLOR = "#FFFFFF";
|
||||||
const DEFAULT_EDGE_FONT_SIZE = 11;
|
const DEFAULT_EDGE_FONT_SIZE = 11;
|
||||||
const EDGE_DASH_ARRAY: Record<EdgeLineStyle, string | undefined> = {
|
const EDGE_DASH_ARRAY: Record<EdgeLineStyle, string | undefined> = {
|
||||||
solid: undefined,
|
solid: undefined,
|
||||||
dashed: '8 4',
|
dashed: "8 4",
|
||||||
dotted: '1.5 4',
|
dotted: "1.5 4",
|
||||||
};
|
};
|
||||||
const MAX_HISTORY = 50;
|
const MAX_HISTORY = 50;
|
||||||
const TYPING_HISTORY_THROTTLE_MS = 1000;
|
const TYPING_HISTORY_THROTTLE_MS = 1000;
|
||||||
|
|
||||||
const NODE_KINDS: { kind: NodeKind; label: string }[] = [
|
const NODE_KINDS: { kind: NodeKind; label: string }[] = [
|
||||||
{ kind: 'task', label: '+ Задача' },
|
{ kind: "task", label: "+ Действие" },
|
||||||
{ kind: 'startEvent', label: '+ Начало' },
|
{ kind: "startEvent", label: "+ Начало" },
|
||||||
{ kind: 'intermediateEvent', label: '+ Промеж. событие' },
|
{ kind: "intermediateEvent", label: "+ Промеж. событие" },
|
||||||
{ kind: 'endEvent', label: '+ Конец' },
|
{ kind: "endEvent", label: "+ Конец" },
|
||||||
{ kind: 'exclusiveGateway', label: '+ Искл. шлюз' },
|
{ kind: "exclusiveGateway", label: "+ Искл. шлюз (XOR)" },
|
||||||
{ kind: 'parallelGateway', label: '+ Паралл. шлюз' },
|
{ kind: "parallelGateway", label: "+ Паралл. шлюз (AND)" },
|
||||||
{ kind: 'inclusiveGateway', label: '+ Вкл. шлюз' },
|
{ 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
|
// Stable references — ChoiceGroup's controlled `value` is compared against
|
||||||
// entries in `items` by identity, so this can't be recreated on every render.
|
// entries in `items` by identity, so this can't be recreated on every render.
|
||||||
const MODE_ITEMS: { label: string; mode: 'edit' | 'view' }[] = [
|
const MODE_ITEMS: { label: string; mode: "edit" | "view" }[] = [
|
||||||
{ label: 'Просмотр', mode: 'view' },
|
{ label: "Просмотр", mode: "view" },
|
||||||
{ label: 'Редактирование', mode: 'edit' },
|
{ label: "Редактирование", mode: "edit" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const DEFAULT_NODE_SIZE: Record<NodeKind, { width: number; height: number }> = {
|
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 },
|
exclusiveGateway: { width: 64, height: 64 },
|
||||||
parallelGateway: { width: 64, height: 64 },
|
parallelGateway: { width: 64, height: 64 },
|
||||||
inclusiveGateway: { 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 {
|
interface AlignmentGuide {
|
||||||
axis: 'x' | 'y';
|
axis: "x" | "y";
|
||||||
position: number;
|
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
|
* 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.
|
* 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[] = [];
|
const guides: AlignmentGuide[] = [];
|
||||||
let snappedX = dragged.x;
|
let snappedX = dragged.x;
|
||||||
let snappedY = dragged.y;
|
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;
|
const otherCenterX = other.x + other.width / 2;
|
||||||
if (Math.abs(draggedCenterX - otherCenterX) <= SNAP_THRESHOLD) {
|
if (Math.abs(draggedCenterX - otherCenterX) <= SNAP_THRESHOLD) {
|
||||||
snappedX = dragged.x + (otherCenterX - draggedCenterX);
|
snappedX = dragged.x + (otherCenterX - draggedCenterX);
|
||||||
guides.push({ axis: 'x', position: otherCenterX });
|
guides.push({ axis: "x", position: otherCenterX });
|
||||||
matchedX = true;
|
matchedX = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,7 +140,7 @@ function computeSnapAndGuides(dragged: Box, others: Box[]): { x: number; y: numb
|
|||||||
const otherCenterY = other.y + other.height / 2;
|
const otherCenterY = other.y + other.height / 2;
|
||||||
if (Math.abs(draggedCenterY - otherCenterY) <= SNAP_THRESHOLD) {
|
if (Math.abs(draggedCenterY - otherCenterY) <= SNAP_THRESHOLD) {
|
||||||
snappedY = dragged.y + (otherCenterY - draggedCenterY);
|
snappedY = dragged.y + (otherCenterY - draggedCenterY);
|
||||||
guides.push({ axis: 'y', position: otherCenterY });
|
guides.push({ axis: "y", position: otherCenterY });
|
||||||
matchedY = true;
|
matchedY = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,7 +189,11 @@ function toRfEdges(doc: DiagramDocument | null): Edge[] {
|
|||||||
|
|
||||||
function isEditableTarget(target: EventTarget | null): boolean {
|
function isEditableTarget(target: EventTarget | null): boolean {
|
||||||
if (!(target instanceof HTMLElement)) return false;
|
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) {
|
function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
||||||
@@ -181,7 +202,7 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
|||||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
||||||
const [focusNodeId, setFocusNodeId] = 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 [alignmentGuides, setAlignmentGuides] = useState<AlignmentGuide[]>([]);
|
||||||
const nodeCounter = useRef(0);
|
const nodeCounter = useRef(0);
|
||||||
const historyRef = useRef<{ nodes: Node[]; edges: Edge[] }[]>([]);
|
const historyRef = useRef<{ nodes: Node[]; edges: Edge[] }[]>([]);
|
||||||
@@ -192,7 +213,11 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
|||||||
const { screenToFlowPosition, zoomIn, zoomOut, fitView } = useReactFlow();
|
const { screenToFlowPosition, zoomIn, zoomOut, fitView } = useReactFlow();
|
||||||
|
|
||||||
const emitChange = useMemo(
|
const emitChange = useMemo(
|
||||||
() => debounce((n: Node[], e: Edge[]) => onChange(serializeDocument(n, e)), 400),
|
() =>
|
||||||
|
debounce(
|
||||||
|
(n: Node[], e: Edge[]) => onChange(serializeDocument(n, e)),
|
||||||
|
400,
|
||||||
|
),
|
||||||
[onChange],
|
[onChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -251,7 +276,9 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
|||||||
const visibleEdges = useMemo(() => {
|
const visibleEdges = useMemo(() => {
|
||||||
const visibleIds = new Set(visibleNodes.map((node) => node.id));
|
const visibleIds = new Set(visibleNodes.map((node) => node.id));
|
||||||
return edges
|
return edges
|
||||||
.filter((edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target))
|
.filter(
|
||||||
|
(edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target),
|
||||||
|
)
|
||||||
.map((edge) => {
|
.map((edge) => {
|
||||||
const edgeData = edge.data as DiagramEdgeData | undefined;
|
const edgeData = edge.data as DiagramEdgeData | undefined;
|
||||||
const edgeColor = edgeData?.color ?? DEFAULT_EDGE_COLOR;
|
const edgeColor = edgeData?.color ?? DEFAULT_EDGE_COLOR;
|
||||||
@@ -261,9 +288,11 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
|||||||
...edge.style,
|
...edge.style,
|
||||||
stroke: edgeColor,
|
stroke: edgeColor,
|
||||||
strokeWidth: edgeData?.strokeWidth ?? DEFAULT_EDGE_STROKE_WIDTH,
|
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 },
|
markerEnd: { type: MarkerType.ArrowClosed, color: edgeColor },
|
||||||
// Label renders as its own small chip — same color as the line, configurable text color.
|
// Label renders as its own small chip — same color as the line, configurable text color.
|
||||||
labelBgStyle: { fill: edgeColor },
|
labelBgStyle: { fill: edgeColor },
|
||||||
@@ -309,9 +338,13 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
|||||||
// inside onNodesChange — stops the connected edges from being deleted too.
|
// inside onNodesChange — stops the connected edges from being deleted too.
|
||||||
const onBeforeDelete = useCallback(
|
const onBeforeDelete = useCallback(
|
||||||
async ({ nodes: nodesToDelete }: { nodes: Node[]; edges: Edge[] }) => {
|
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) {
|
if (hasContainerNode) {
|
||||||
window.alert('Сначала удалите вложенные узлы — у этого узла есть под-процесс.');
|
window.alert(
|
||||||
|
"Сначала удалите вложенные узлы — у этого узла есть под-процесс.",
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -321,16 +354,28 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
|||||||
|
|
||||||
const onNodesChange = useCallback(
|
const onNodesChange = useCallback(
|
||||||
(changes: NodeChange[]) => {
|
(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;
|
let adjustedChanges = changes;
|
||||||
if (positionChanges.length === 1) {
|
if (positionChanges.length === 1) {
|
||||||
const change = positionChanges[0];
|
const change = positionChanges[0];
|
||||||
const draggedNode = change.type === 'position' ? nodes.find((n) => n.id === change.id) : undefined;
|
const draggedNode =
|
||||||
if (change.type === 'position' && change.position && 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 level = draggedNode.parentId ?? null;
|
||||||
const others: Box[] = nodes
|
const others: Box[] = nodes
|
||||||
.filter((n) => n.id !== draggedNode.id && (n.parentId ?? null) === level)
|
.filter(
|
||||||
.map((n) => ({ x: n.position.x, y: n.position.y, width: n.width ?? 0, height: n.height ?? 0 }));
|
(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 = {
|
const dragged: Box = {
|
||||||
x: change.position.x,
|
x: change.position.x,
|
||||||
y: change.position.y,
|
y: change.position.y,
|
||||||
@@ -340,7 +385,9 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
|||||||
const snapped = computeSnapAndGuides(dragged, others);
|
const snapped = computeSnapAndGuides(dragged, others);
|
||||||
setAlignmentGuides(change.dragging === false ? [] : snapped.guides);
|
setAlignmentGuides(change.dragging === false ? [] : snapped.guides);
|
||||||
adjustedChanges = changes.map((c) =>
|
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) {
|
} else if (alignmentGuides.length > 0) {
|
||||||
@@ -349,9 +396,9 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
|||||||
|
|
||||||
const isHistoryWorthy = changes.some(
|
const isHistoryWorthy = changes.some(
|
||||||
(change) =>
|
(change) =>
|
||||||
change.type === 'remove' ||
|
change.type === "remove" ||
|
||||||
(change.type === 'position' && change.dragging === false) ||
|
(change.type === "position" && change.dragging === false) ||
|
||||||
(change.type === 'dimensions' && change.resizing === false),
|
(change.type === "dimensions" && change.resizing === false),
|
||||||
);
|
);
|
||||||
if (isHistoryWorthy) pushHistory(nodes, edges);
|
if (isHistoryWorthy) pushHistory(nodes, edges);
|
||||||
const next = applyNodeChanges(adjustedChanges, nodes);
|
const next = applyNodeChanges(adjustedChanges, nodes);
|
||||||
@@ -361,10 +408,16 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
|||||||
// emitChange — filtering edges here too (redundantly with onEdgesChange)
|
// emitChange — filtering edges here too (redundantly with onEdgesChange)
|
||||||
// guarantees a removed node never leaves a dangling edge behind.
|
// guarantees a removed node never leaves a dangling edge behind.
|
||||||
const removedIds = new Set(
|
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
|
const nextEdges =
|
||||||
? edges.filter((edge) => !removedIds.has(edge.source) && !removedIds.has(edge.target))
|
removedIds.size ?
|
||||||
|
edges.filter(
|
||||||
|
(edge) =>
|
||||||
|
!removedIds.has(edge.source) && !removedIds.has(edge.target),
|
||||||
|
)
|
||||||
: edges;
|
: edges;
|
||||||
|
|
||||||
setNodes(next);
|
setNodes(next);
|
||||||
@@ -376,7 +429,9 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
|||||||
|
|
||||||
const onEdgesChange = useCallback(
|
const onEdgesChange = useCallback(
|
||||||
(changes: EdgeChange[]) => {
|
(changes: EdgeChange[]) => {
|
||||||
const isHistoryWorthy = changes.some((change) => change.type === 'remove');
|
const isHistoryWorthy = changes.some(
|
||||||
|
(change) => change.type === "remove",
|
||||||
|
);
|
||||||
if (isHistoryWorthy) pushHistory(nodes, edges);
|
if (isHistoryWorthy) pushHistory(nodes, edges);
|
||||||
const next = applyEdgeChanges(changes, edges);
|
const next = applyEdgeChanges(changes, edges);
|
||||||
setEdges(next);
|
setEdges(next);
|
||||||
@@ -388,8 +443,22 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
|||||||
const onConnect: OnConnect = useCallback(
|
const onConnect: OnConnect = useCallback(
|
||||||
(connection) => {
|
(connection) => {
|
||||||
pushHistory(nodes, edges);
|
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(
|
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,
|
edges,
|
||||||
);
|
);
|
||||||
setEdges(next);
|
setEdges(next);
|
||||||
@@ -402,7 +471,9 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
|||||||
(oldEdge: Edge, connection: Connection) => {
|
(oldEdge: Edge, connection: Connection) => {
|
||||||
pushHistory(nodes, edges);
|
pushHistory(nodes, edges);
|
||||||
// Keep the original edge id stable so selection/history don't go stale mid-drag.
|
// 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);
|
setEdges(next);
|
||||||
emitChange(nodes, next);
|
emitChange(nodes, next);
|
||||||
},
|
},
|
||||||
@@ -416,7 +487,7 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
|||||||
|
|
||||||
const onEdgeClick: EdgeMouseHandler = useCallback(
|
const onEdgeClick: EdgeMouseHandler = useCallback(
|
||||||
(_event, edge) => {
|
(_event, edge) => {
|
||||||
if (mode !== 'edit') return;
|
if (mode !== "edit") return;
|
||||||
setSelectedEdgeId(edge.id);
|
setSelectedEdgeId(edge.id);
|
||||||
setSelectedNodeId(null);
|
setSelectedNodeId(null);
|
||||||
},
|
},
|
||||||
@@ -433,7 +504,9 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
|||||||
if (!selectedNodeId) return;
|
if (!selectedNodeId) return;
|
||||||
pushHistoryThrottled(nodes, edges);
|
pushHistoryThrottled(nodes, edges);
|
||||||
const next = nodes.map((node) =>
|
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);
|
setNodes(next);
|
||||||
emitChange(next, edges);
|
emitChange(next, edges);
|
||||||
@@ -445,7 +518,9 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
|||||||
(label: string) => {
|
(label: string) => {
|
||||||
if (!selectedEdgeId) return;
|
if (!selectedEdgeId) return;
|
||||||
pushHistoryThrottled(nodes, edges);
|
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);
|
setEdges(next);
|
||||||
emitChange(nodes, next);
|
emitChange(nodes, next);
|
||||||
},
|
},
|
||||||
@@ -457,7 +532,9 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
|||||||
if (!selectedEdgeId) return;
|
if (!selectedEdgeId) return;
|
||||||
pushHistoryThrottled(nodes, edges);
|
pushHistoryThrottled(nodes, edges);
|
||||||
const next = edges.map((edge) =>
|
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);
|
setEdges(next);
|
||||||
emitChange(nodes, next);
|
emitChange(nodes, next);
|
||||||
@@ -468,7 +545,10 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
|||||||
const getViewportCenter = useCallback(() => {
|
const getViewportCenter = useCallback(() => {
|
||||||
const rect = canvasWrapperRef.current?.getBoundingClientRect();
|
const rect = canvasWrapperRef.current?.getBoundingClientRect();
|
||||||
if (!rect) return { x: 0, y: 0 };
|
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]);
|
}, [screenToFlowPosition]);
|
||||||
|
|
||||||
const addNode = useCallback(
|
const addNode = useCallback(
|
||||||
@@ -487,7 +567,12 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
|||||||
},
|
},
|
||||||
width: size.width,
|
width: size.width,
|
||||||
height: size.height,
|
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 } : {}),
|
...(focusNodeId ? { parentId: focusNodeId } : {}),
|
||||||
};
|
};
|
||||||
pushHistory(nodes, edges);
|
pushHistory(nodes, edges);
|
||||||
@@ -499,7 +584,8 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const copySelectedNode = useCallback(() => {
|
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;
|
if (!node) return;
|
||||||
clipboardRef.current = node;
|
clipboardRef.current = node;
|
||||||
}, [selectedNodeId, nodes]);
|
}, [selectedNodeId, nodes]);
|
||||||
@@ -509,7 +595,11 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
|||||||
if (!source) return;
|
if (!source) return;
|
||||||
nodeCounter.current += 1;
|
nodeCounter.current += 1;
|
||||||
const newId = crypto.randomUUID();
|
const newId = crypto.randomUUID();
|
||||||
const { parentId: _sourceParentId, selected: _sourceSelected, ...restSource } = source;
|
const {
|
||||||
|
parentId: _sourceParentId,
|
||||||
|
selected: _sourceSelected,
|
||||||
|
...restSource
|
||||||
|
} = source;
|
||||||
const newNode: Node = {
|
const newNode: Node = {
|
||||||
...restSource,
|
...restSource,
|
||||||
id: newId,
|
id: newId,
|
||||||
@@ -533,12 +623,16 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
|||||||
const deleteSelectedNode = useCallback(() => {
|
const deleteSelectedNode = useCallback(() => {
|
||||||
if (!selectedNodeId) return;
|
if (!selectedNodeId) return;
|
||||||
if (nodes.some((n) => n.parentId === selectedNodeId)) {
|
if (nodes.some((n) => n.parentId === selectedNodeId)) {
|
||||||
window.alert('Сначала удалите вложенные узлы — у этого узла есть под-процесс.');
|
window.alert(
|
||||||
|
"Сначала удалите вложенные узлы — у этого узла есть под-процесс.",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pushHistory(nodes, edges);
|
pushHistory(nodes, edges);
|
||||||
const nextNodes = nodes.filter((n) => n.id !== selectedNodeId);
|
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);
|
setNodes(nextNodes);
|
||||||
setEdges(nextEdges);
|
setEdges(nextEdges);
|
||||||
emitChange(nextNodes, 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
|
// 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.
|
// always means "above/below everything currently on the canvas", not just siblings.
|
||||||
const maxZIndex = useCallback(
|
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],
|
[nodes, edges],
|
||||||
);
|
);
|
||||||
const minZIndex = useCallback(
|
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],
|
[nodes, edges],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -569,7 +673,9 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
|||||||
if (!selectedNodeId) return;
|
if (!selectedNodeId) return;
|
||||||
const z = maxZIndex() + 1;
|
const z = maxZIndex() + 1;
|
||||||
pushHistory(nodes, edges);
|
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);
|
setNodes(next);
|
||||||
emitChange(next, edges);
|
emitChange(next, edges);
|
||||||
}, [selectedNodeId, nodes, edges, emitChange, pushHistory, maxZIndex]);
|
}, [selectedNodeId, nodes, edges, emitChange, pushHistory, maxZIndex]);
|
||||||
@@ -578,7 +684,9 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
|||||||
if (!selectedNodeId) return;
|
if (!selectedNodeId) return;
|
||||||
const z = minZIndex() - 1;
|
const z = minZIndex() - 1;
|
||||||
pushHistory(nodes, edges);
|
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);
|
setNodes(next);
|
||||||
emitChange(next, edges);
|
emitChange(next, edges);
|
||||||
}, [selectedNodeId, nodes, edges, emitChange, pushHistory, minZIndex]);
|
}, [selectedNodeId, nodes, edges, emitChange, pushHistory, minZIndex]);
|
||||||
@@ -587,7 +695,9 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
|||||||
if (!selectedEdgeId) return;
|
if (!selectedEdgeId) return;
|
||||||
const z = maxZIndex() + 1;
|
const z = maxZIndex() + 1;
|
||||||
pushHistory(nodes, edges);
|
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);
|
setEdges(next);
|
||||||
emitChange(nodes, next);
|
emitChange(nodes, next);
|
||||||
}, [selectedEdgeId, nodes, edges, emitChange, pushHistory, maxZIndex]);
|
}, [selectedEdgeId, nodes, edges, emitChange, pushHistory, maxZIndex]);
|
||||||
@@ -596,36 +706,45 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
|||||||
if (!selectedEdgeId) return;
|
if (!selectedEdgeId) return;
|
||||||
const z = minZIndex() - 1;
|
const z = minZIndex() - 1;
|
||||||
pushHistory(nodes, edges);
|
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);
|
setEdges(next);
|
||||||
emitChange(nodes, next);
|
emitChange(nodes, next);
|
||||||
}, [selectedEdgeId, nodes, edges, emitChange, pushHistory, minZIndex]);
|
}, [selectedEdgeId, nodes, edges, emitChange, pushHistory, minZIndex]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onKeyDown(event: KeyboardEvent) {
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
if (mode !== 'edit' || isEditableTarget(event.target)) return;
|
if (mode !== "edit" || isEditableTarget(event.target)) return;
|
||||||
if (!(event.ctrlKey || event.metaKey)) return;
|
if (!(event.ctrlKey || event.metaKey)) return;
|
||||||
const key = event.key.toLowerCase();
|
const key = event.key.toLowerCase();
|
||||||
if (key === 'z') {
|
if (key === "z") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
undo();
|
undo();
|
||||||
} else if (key === 'c') {
|
} else if (key === "c") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
copySelectedNode();
|
copySelectedNode();
|
||||||
} else if (key === 'v') {
|
} else if (key === "v") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
pasteClipboardNode();
|
pasteClipboardNode();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', onKeyDown);
|
window.addEventListener("keydown", onKeyDown);
|
||||||
return () => window.removeEventListener('keydown', onKeyDown);
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
}, [mode, undo, copySelectedNode, pasteClipboardNode]);
|
}, [mode, undo, copySelectedNode, pasteClipboardNode]);
|
||||||
|
|
||||||
const selectedNode = selectedNodeId ? nodes.find((node) => node.id === selectedNodeId) : undefined;
|
const selectedNode =
|
||||||
const selectedNodeChildCount = selectedNode
|
selectedNodeId ?
|
||||||
? nodes.filter((node) => node.parentId === selectedNode.id).length
|
nodes.find((node) => node.id === selectedNodeId)
|
||||||
|
: undefined;
|
||||||
|
const selectedNodeChildCount =
|
||||||
|
selectedNode ?
|
||||||
|
nodes.filter((node) => node.parentId === selectedNode.id).length
|
||||||
: 0;
|
: 0;
|
||||||
const selectedEdge = selectedEdgeId ? edges.find((edge) => edge.id === selectedEdgeId) : undefined;
|
const selectedEdge =
|
||||||
|
selectedEdgeId ?
|
||||||
|
edges.find((edge) => edge.id === selectedEdgeId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="diagram-shell">
|
<div className="diagram-shell">
|
||||||
@@ -641,17 +760,19 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
|||||||
onConnect={onConnect}
|
onConnect={onConnect}
|
||||||
onReconnect={onReconnect}
|
onReconnect={onReconnect}
|
||||||
onBeforeDelete={onBeforeDelete}
|
onBeforeDelete={onBeforeDelete}
|
||||||
edgesReconnectable={mode === 'edit'}
|
edgesReconnectable={mode === "edit"}
|
||||||
onNodeClick={onNodeClick}
|
onNodeClick={onNodeClick}
|
||||||
onEdgeClick={onEdgeClick}
|
onEdgeClick={onEdgeClick}
|
||||||
onPaneClick={onPaneClick}
|
onPaneClick={onPaneClick}
|
||||||
nodesDraggable={mode === 'edit'}
|
nodesDraggable={mode === "edit"}
|
||||||
nodesConnectable={mode === 'edit'}
|
nodesConnectable={mode === "edit"}
|
||||||
deleteKeyCode={mode === 'edit' ? undefined : null}
|
deleteKeyCode={mode === "edit" ? undefined : null}
|
||||||
zoomOnDoubleClick={false}
|
zoomOnDoubleClick={false}
|
||||||
connectionMode={ConnectionMode.Loose}
|
connectionMode={ConnectionMode.Loose}
|
||||||
zIndexMode="manual"
|
zIndexMode="manual"
|
||||||
className={mode === 'view' ? 'diagram-flow--view-mode' : undefined}
|
className={
|
||||||
|
mode === "view" ? "diagram-flow--view-mode" : undefined
|
||||||
|
}
|
||||||
fitView
|
fitView
|
||||||
proOptions={{ hideAttribution: true }}
|
proOptions={{ hideAttribution: true }}
|
||||||
>
|
>
|
||||||
@@ -662,14 +783,28 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
|||||||
key={`${guide.axis}-${index}`}
|
key={`${guide.axis}-${index}`}
|
||||||
className="diagram-alignment-guide"
|
className="diagram-alignment-guide"
|
||||||
style={
|
style={
|
||||||
guide.axis === 'x'
|
guide.axis === "x" ?
|
||||||
? { left: guide.position, top: -10000, width: 1, height: 20000 }
|
{
|
||||||
: { top: guide.position, left: -10000, height: 1, width: 20000 }
|
left: guide.position,
|
||||||
|
top: -10000,
|
||||||
|
width: 1,
|
||||||
|
height: 20000,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
top: guide.position,
|
||||||
|
left: -10000,
|
||||||
|
height: 1,
|
||||||
|
width: 20000,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ViewportPortal>
|
</ViewportPortal>
|
||||||
<Controls showZoom={false} showFitView={false} showInteractive={false}>
|
<Controls
|
||||||
|
showZoom={false}
|
||||||
|
showFitView={false}
|
||||||
|
showInteractive={false}
|
||||||
|
>
|
||||||
<ControlButton onClick={() => zoomIn()} title="Увеличить">
|
<ControlButton onClick={() => zoomIn()} title="Увеличить">
|
||||||
+
|
+
|
||||||
</ControlButton>
|
</ControlButton>
|
||||||
@@ -679,18 +814,26 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
|||||||
<ControlButton onClick={() => fitView()} title="Показать всё">
|
<ControlButton onClick={() => fitView()} title="Показать всё">
|
||||||
⛶
|
⛶
|
||||||
</ControlButton>
|
</ControlButton>
|
||||||
{mode === 'edit' && (
|
{mode === "edit" && (
|
||||||
<>
|
<>
|
||||||
<ControlButton onClick={undo} disabled={historyRef.current.length === 0} title="Отменить (Ctrl+Z)">
|
<ControlButton
|
||||||
|
onClick={undo}
|
||||||
|
disabled={historyRef.current.length === 0}
|
||||||
|
title="Отменить (Ctrl+Z)"
|
||||||
|
>
|
||||||
↶
|
↶
|
||||||
</ControlButton>
|
</ControlButton>
|
||||||
<ControlButton onClick={redo} disabled={redoRef.current.length === 0} title="Повторить">
|
<ControlButton
|
||||||
|
onClick={redo}
|
||||||
|
disabled={redoRef.current.length === 0}
|
||||||
|
title="Повторить"
|
||||||
|
>
|
||||||
↷
|
↷
|
||||||
</ControlButton>
|
</ControlButton>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Controls>
|
</Controls>
|
||||||
{mode === 'edit' && <MiniMap />}
|
{mode === "edit" && <MiniMap />}
|
||||||
<Panel position="top-right" className="diagram-mode-toggle">
|
<Panel position="top-right" className="diagram-mode-toggle">
|
||||||
<ChoiceGroup
|
<ChoiceGroup
|
||||||
size="xs"
|
size="xs"
|
||||||
@@ -700,10 +843,16 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
|||||||
onChange={(item) => setMode(item.mode)}
|
onChange={(item) => setMode(item.mode)}
|
||||||
/>
|
/>
|
||||||
</Panel>
|
</Panel>
|
||||||
{mode === 'edit' && (
|
{mode === "edit" && (
|
||||||
<Panel position="top-left" className="diagram-toolbar">
|
<Panel position="top-left" className="diagram-toolbar">
|
||||||
{NODE_KINDS.map(({ kind, label }) => (
|
{NODE_KINDS.map(({ kind, label }) => (
|
||||||
<Button key={kind} size="xs" view="secondary" label={label} onClick={() => addNode(kind)} />
|
<Button
|
||||||
|
key={kind}
|
||||||
|
size="xs"
|
||||||
|
view="ghost"
|
||||||
|
label={label}
|
||||||
|
onClick={() => addNode(kind)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</Panel>
|
</Panel>
|
||||||
)}
|
)}
|
||||||
@@ -711,10 +860,16 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
|||||||
<Breadcrumbs
|
<Breadcrumbs
|
||||||
size="xs"
|
size="xs"
|
||||||
items={[
|
items={[
|
||||||
{ label: 'Корень', onClick: focusNodeId ? () => goToLevel(null) : undefined },
|
{
|
||||||
|
label: "Корень",
|
||||||
|
onClick: focusNodeId ? () => goToLevel(null) : undefined,
|
||||||
|
},
|
||||||
...breadcrumbPath.map((node, index) => ({
|
...breadcrumbPath.map((node, index) => ({
|
||||||
label: (node.data as DiagramNodeData).label,
|
label: (node.data as DiagramNodeData).label,
|
||||||
onClick: index === breadcrumbPath.length - 1 ? undefined : () => goToLevel(node.id),
|
onClick:
|
||||||
|
index === breadcrumbPath.length - 1 ?
|
||||||
|
undefined
|
||||||
|
: () => goToLevel(node.id),
|
||||||
})),
|
})),
|
||||||
]}
|
]}
|
||||||
getItemOnClick={(item) => item.onClick}
|
getItemOnClick={(item) => item.onClick}
|
||||||
@@ -726,10 +881,10 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside className="diagram-aside">
|
<aside className="diagram-aside">
|
||||||
{selectedNode ? (
|
{selectedNode ?
|
||||||
<NodeInspector
|
<NodeInspector
|
||||||
nodeId={selectedNode.id}
|
nodeId={selectedNode.id}
|
||||||
nodeType={(selectedNode.type ?? 'task') as NodeKind}
|
nodeType={(selectedNode.type ?? "task") as NodeKind}
|
||||||
data={selectedNode.data as DiagramNodeData}
|
data={selectedNode.data as DiagramNodeData}
|
||||||
childCount={selectedNodeChildCount}
|
childCount={selectedNodeChildCount}
|
||||||
mode={mode}
|
mode={mode}
|
||||||
@@ -740,10 +895,12 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
|||||||
onBringToFront={bringSelectedNodeToFront}
|
onBringToFront={bringSelectedNodeToFront}
|
||||||
onSendToBack={sendSelectedNodeToBack}
|
onSendToBack={sendSelectedNodeToBack}
|
||||||
/>
|
/>
|
||||||
) : selectedEdge ? (
|
: selectedEdge ?
|
||||||
<EdgeInspector
|
<EdgeInspector
|
||||||
edgeId={selectedEdge.id}
|
edgeId={selectedEdge.id}
|
||||||
label={typeof selectedEdge.label === 'string' ? selectedEdge.label : ''}
|
label={
|
||||||
|
typeof selectedEdge.label === "string" ? selectedEdge.label : ""
|
||||||
|
}
|
||||||
data={(selectedEdge.data as DiagramEdgeData) ?? {}}
|
data={(selectedEdge.data as DiagramEdgeData) ?? {}}
|
||||||
onLabelChange={updateSelectedEdgeLabel}
|
onLabelChange={updateSelectedEdgeLabel}
|
||||||
onDataChange={updateSelectedEdgeData}
|
onDataChange={updateSelectedEdgeData}
|
||||||
@@ -751,18 +908,20 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
|||||||
onBringToFront={bringSelectedEdgeToFront}
|
onBringToFront={bringSelectedEdgeToFront}
|
||||||
onSendToBack={sendSelectedEdgeToBack}
|
onSendToBack={sendSelectedEdgeToBack}
|
||||||
/>
|
/>
|
||||||
) : (
|
: <p className="diagram-aside__hint">
|
||||||
<p className="diagram-aside__hint">
|
{mode === "edit" ?
|
||||||
{mode === 'edit'
|
"Выберите узел или связь на схеме — появятся кнопки «Дублировать»/«Удалить» и поля данных."
|
||||||
? 'Выберите узел или связь на схеме — появятся кнопки «Дублировать»/«Удалить» и поля данных.'
|
: "Кликните по узлу, чтобы увидеть описание. Если у узла есть вложенный процесс, появится кнопка перехода."
|
||||||
: 'Кликните по узлу, чтобы увидеть описание. Если у узла есть вложенный процесс, появится кнопка перехода.'}
|
}
|
||||||
</p>
|
</p>
|
||||||
)}
|
}
|
||||||
|
|
||||||
{mode === 'edit' && (
|
{mode === "edit" && (
|
||||||
<details className="diagram-aside__json">
|
<details className="diagram-aside__json">
|
||||||
<summary>Текущий doc (для демо)</summary>
|
<summary>Текущий doc (для демо)</summary>
|
||||||
<pre>{JSON.stringify(serializeDocument(nodes, edges), null, 2)}</pre>
|
<pre>
|
||||||
|
{JSON.stringify(serializeDocument(nodes, edges), null, 2)}
|
||||||
|
</pre>
|
||||||
</details>
|
</details>
|
||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export function EdgeInspector({
|
|||||||
<div className="node-inspector">
|
<div className="node-inspector">
|
||||||
<div className="node-inspector__header">
|
<div className="node-inspector__header">
|
||||||
<Text size="s" weight="semibold">
|
<Text size="s" weight="semibold">
|
||||||
Связь
|
Поток управления (Sequence Flow)
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" view="secondary" font="mono">
|
<Text size="xs" view="secondary" font="mono">
|
||||||
{edgeId}
|
{edgeId}
|
||||||
|
|||||||
+111
-21
@@ -10,8 +10,24 @@ import {
|
|||||||
DEFAULT_NODE_FONT_SIZE,
|
DEFAULT_NODE_FONT_SIZE,
|
||||||
DEFAULT_NODE_TEXT_COLOR,
|
DEFAULT_NODE_TEXT_COLOR,
|
||||||
DEFAULT_TASK_BORDER_RADIUS,
|
DEFAULT_TASK_BORDER_RADIUS,
|
||||||
|
EVENT_SUBTYPE_ICON,
|
||||||
|
EVENT_SUBTYPE_LABEL,
|
||||||
|
EVENT_SUBTYPES_BY_KIND,
|
||||||
|
TASK_SUBTYPE_ICON,
|
||||||
|
TASK_SUBTYPE_LABEL,
|
||||||
} from "./nodes";
|
} from "./nodes";
|
||||||
import type { DiagramNodeData, NodeKind } from "./types";
|
import type { DiagramNodeData, EventSubtype, NodeKind, TaskSubtype } from "./types";
|
||||||
|
|
||||||
|
const TASK_SUBTYPES: TaskSubtype[] = [
|
||||||
|
"none",
|
||||||
|
"user",
|
||||||
|
"service",
|
||||||
|
"script",
|
||||||
|
"manual",
|
||||||
|
"businessRule",
|
||||||
|
"send",
|
||||||
|
"receive",
|
||||||
|
];
|
||||||
|
|
||||||
// Every gradient defined in palette.css, across its three "for diagrams /
|
// Every gradient defined in palette.css, across its three "for diagrams /
|
||||||
// flowcharts / semantic highlights" sections. Consta's ColorPicker `palette`
|
// flowcharts / semantic highlights" sections. Consta's ColorPicker `palette`
|
||||||
@@ -48,15 +64,21 @@ const TEXT_COLOR_PRESETS: { hex: string; label: string }[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const TYPE_LABELS: Record<NodeKind, string> = {
|
const TYPE_LABELS: Record<NodeKind, string> = {
|
||||||
task: "Задача",
|
task: "Действие (Task)",
|
||||||
startEvent: "Начальное событие",
|
startEvent: "Начальное событие (Start Event)",
|
||||||
intermediateEvent: "Промежуточное событие",
|
intermediateEvent: "Промежуточное событие (Intermediate Event)",
|
||||||
endEvent: "Конечное событие",
|
endEvent: "Конечное событие (End Event)",
|
||||||
exclusiveGateway: "Исключающий шлюз",
|
exclusiveGateway: "Исключающий шлюз (XOR)",
|
||||||
parallelGateway: "Параллельный шлюз",
|
parallelGateway: "Параллельный шлюз (AND)",
|
||||||
inclusiveGateway: "Включающий шлюз",
|
inclusiveGateway: "Включающий шлюз (OR)",
|
||||||
|
complexGateway: "Сложный шлюз (Complex)",
|
||||||
|
eventBasedGateway: "Шлюз на основе событий (Event-based)",
|
||||||
|
hint: "Подсказка",
|
||||||
|
dataObject: "Документ (Data Object)",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const EVENT_KINDS = new Set<NodeKind>(["startEvent", "intermediateEvent", "endEvent"]);
|
||||||
|
|
||||||
interface NodeInspectorProps {
|
interface NodeInspectorProps {
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
nodeType: NodeKind;
|
nodeType: NodeKind;
|
||||||
@@ -109,6 +131,12 @@ export function NodeInspector({
|
|||||||
|
|
||||||
if (mode === "view") {
|
if (mode === "view") {
|
||||||
const hasMeta = Boolean(data.responsible || data.duration);
|
const hasMeta = Boolean(data.responsible || data.duration);
|
||||||
|
const subtypeLabel =
|
||||||
|
nodeType === "task"
|
||||||
|
? TASK_SUBTYPE_LABEL[(data.subtype as TaskSubtype | undefined) ?? "none"]
|
||||||
|
: EVENT_KINDS.has(nodeType)
|
||||||
|
? EVENT_SUBTYPE_LABEL[(data.subtype as EventSubtype | undefined) ?? "none"]
|
||||||
|
: undefined;
|
||||||
return (
|
return (
|
||||||
<div className="node-inspector">
|
<div className="node-inspector">
|
||||||
<div className="node-inspector__header">
|
<div className="node-inspector__header">
|
||||||
@@ -117,6 +145,12 @@ export function NodeInspector({
|
|||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{subtypeLabel && (
|
||||||
|
<Text size="xs" view="secondary">
|
||||||
|
{subtypeLabel}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
<Text size="l" className="node-inspector__label">
|
<Text size="l" className="node-inspector__label">
|
||||||
{data.label}
|
{data.label}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -196,6 +230,60 @@ export function NodeInspector({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{nodeType === "task" && (
|
||||||
|
<div className="node-inspector__field">
|
||||||
|
<Text size="xs" view="secondary">
|
||||||
|
Тип задачи
|
||||||
|
</Text>
|
||||||
|
<div className="node-inspector__presets">
|
||||||
|
{TASK_SUBTYPES.map((subtype) => {
|
||||||
|
const Icon = TASK_SUBTYPE_ICON[subtype];
|
||||||
|
const isActive = (data.subtype ?? "none") === subtype;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={subtype}
|
||||||
|
type="button"
|
||||||
|
title={TASK_SUBTYPE_LABEL[subtype]}
|
||||||
|
aria-label={TASK_SUBTYPE_LABEL[subtype]}
|
||||||
|
className={`node-inspector__subtype${isActive ? " node-inspector__subtype--active" : ""}`}
|
||||||
|
onClick={() => onChange({ subtype })}
|
||||||
|
>
|
||||||
|
{Icon ? <Icon size="s" /> : <Text size="xs">—</Text>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{EVENT_KINDS.has(nodeType) && (
|
||||||
|
<div className="node-inspector__field">
|
||||||
|
<Text size="xs" view="secondary">
|
||||||
|
Тип триггера
|
||||||
|
</Text>
|
||||||
|
<div className="node-inspector__presets">
|
||||||
|
{EVENT_SUBTYPES_BY_KIND[nodeType as "startEvent" | "intermediateEvent" | "endEvent"].map(
|
||||||
|
(subtype: EventSubtype) => {
|
||||||
|
const Icon = EVENT_SUBTYPE_ICON[subtype];
|
||||||
|
const isActive = (data.subtype ?? "none") === subtype;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={subtype}
|
||||||
|
type="button"
|
||||||
|
title={EVENT_SUBTYPE_LABEL[subtype]}
|
||||||
|
aria-label={EVENT_SUBTYPE_LABEL[subtype]}
|
||||||
|
className={`node-inspector__subtype${isActive ? " node-inspector__subtype--active" : ""}`}
|
||||||
|
onClick={() => onChange({ subtype })}
|
||||||
|
>
|
||||||
|
{Icon ? <Icon size="s" /> : <Text size="xs">—</Text>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="node-inspector__field">
|
<div className="node-inspector__field">
|
||||||
<Text size="xs" view="secondary">
|
<Text size="xs" view="secondary">
|
||||||
Цвет фона
|
Цвет фона
|
||||||
@@ -306,6 +394,21 @@ export function NodeInspector({
|
|||||||
onChange={(value) => onChange({ description: value ?? "" })}
|
onChange={(value) => onChange({ description: value ?? "" })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{(nodeType === "task" || nodeType === "hint") && (
|
||||||
|
<TextField
|
||||||
|
label="Скругление углов"
|
||||||
|
type="number"
|
||||||
|
size="s"
|
||||||
|
min={0}
|
||||||
|
max={25}
|
||||||
|
value={String(data.borderRadius ?? DEFAULT_TASK_BORDER_RADIUS)}
|
||||||
|
onChange={(value) => {
|
||||||
|
const n = Number(value);
|
||||||
|
if (value && !Number.isNaN(n)) onChange({ borderRadius: n });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{nodeType === "task" && (
|
{nodeType === "task" && (
|
||||||
<>
|
<>
|
||||||
<TextField
|
<TextField
|
||||||
@@ -322,19 +425,6 @@ export function NodeInspector({
|
|||||||
onChange={(value) => onChange({ duration: value ?? "" })}
|
onChange={(value) => onChange({ duration: value ?? "" })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
|
||||||
label="Скругление углов"
|
|
||||||
type="number"
|
|
||||||
size="s"
|
|
||||||
min={0}
|
|
||||||
max={25}
|
|
||||||
value={String(data.borderRadius ?? DEFAULT_TASK_BORDER_RADIUS)}
|
|
||||||
onChange={(value) => {
|
|
||||||
const n = Number(value);
|
|
||||||
if (value && !Number.isNaN(n)) onChange({ borderRadius: n });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="s"
|
size="s"
|
||||||
view="ghost"
|
view="ghost"
|
||||||
|
|||||||
@@ -123,6 +123,45 @@
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.diagram-node__subtype-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
left: 4px;
|
||||||
|
display: inline-flex;
|
||||||
|
line-height: 0;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagram-node__event-icon {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagram-node--data-object-outer {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 6px 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Folded top-right corner — the classic BPMN Data Object glyph. `clip-path`
|
||||||
|
* establishes its own stacking context, so a later, non-`z-index`d sibling
|
||||||
|
* (the label) doesn't reliably paint on top by DOM order alone — z-index
|
||||||
|
* makes the stacking explicit instead of relying on that.
|
||||||
|
*/
|
||||||
|
.diagram-node--data-object-bg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
clip-path: polygon(0 0, calc(100% - 14px) 0, 100% 14px, 100% 100%, 0 100%);
|
||||||
|
}
|
||||||
|
|
||||||
.diagram-aside {
|
.diagram-aside {
|
||||||
width: 340px;
|
width: 340px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -196,6 +235,25 @@
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.node-inspector__subtype {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid color-mix(in srgb, canvastext 25%, transparent);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: canvas;
|
||||||
|
color: canvastext;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-inspector__subtype--active {
|
||||||
|
border-color: canvastext;
|
||||||
|
background: color-mix(in srgb, canvastext 12%, canvas);
|
||||||
|
}
|
||||||
|
|
||||||
.node-inspector__color-trigger {
|
.node-inspector__color-trigger {
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
}
|
}
|
||||||
|
|||||||
+215
-5
@@ -6,7 +6,24 @@ import {
|
|||||||
type Node,
|
type Node,
|
||||||
type NodeProps,
|
type NodeProps,
|
||||||
} from "@xyflow/react";
|
} from "@xyflow/react";
|
||||||
import type { DiagramNodeData, NodeKind } from "./types";
|
import { IconUser } from "@consta/icons/IconUser";
|
||||||
|
import { IconSettings } from "@consta/icons/IconSettings";
|
||||||
|
import { IconCode } from "@consta/icons/IconCode";
|
||||||
|
import { IconHand } from "@consta/icons/IconHand";
|
||||||
|
import { IconTable } from "@consta/icons/IconTable";
|
||||||
|
import { IconSendMessage } from "@consta/icons/IconSendMessage";
|
||||||
|
import { IconMail } from "@consta/icons/IconMail";
|
||||||
|
import { IconWatchStroked } from "@consta/icons/IconWatchStroked";
|
||||||
|
import { IconTable2 } from "@consta/icons/IconTable2";
|
||||||
|
import { IconAlert } from "@consta/icons/IconAlert";
|
||||||
|
import { IconLightningBolt } from "@consta/icons/IconLightningBolt";
|
||||||
|
import { IconArrowUp } from "@consta/icons/IconArrowUp";
|
||||||
|
import { IconArrowPrevious } from "@consta/icons/IconArrowPrevious";
|
||||||
|
import { IconRecord } from "@consta/icons/IconRecord";
|
||||||
|
import { IconPolygonMultiple } from "@consta/icons/IconPolygonMultiple";
|
||||||
|
import { IconGisMultiple } from "@consta/icons/IconGisMultiple";
|
||||||
|
import type { IconComponent } from "@consta/icons/Icon";
|
||||||
|
import type { DiagramNodeData, EventSubtype, NodeKind, TaskSubtype } from "./types";
|
||||||
|
|
||||||
type ShapeNode = Node<DiagramNodeData, NodeKind>;
|
type ShapeNode = Node<DiagramNodeData, NodeKind>;
|
||||||
|
|
||||||
@@ -27,6 +44,10 @@ export const DEFAULT_NODE_BACKGROUND: Record<NodeKind, string> = {
|
|||||||
exclusiveGateway: "var(--gradient-purple)",
|
exclusiveGateway: "var(--gradient-purple)",
|
||||||
parallelGateway: "var(--gradient-cyan)",
|
parallelGateway: "var(--gradient-cyan)",
|
||||||
inclusiveGateway: "var(--gradient-olive)",
|
inclusiveGateway: "var(--gradient-olive)",
|
||||||
|
complexGateway: "var(--gradient-violet)",
|
||||||
|
eventBasedGateway: "var(--gradient-teal-dark)",
|
||||||
|
hint: "var(--gradient-frost)",
|
||||||
|
dataObject: "var(--gradient-ice)",
|
||||||
};
|
};
|
||||||
|
|
||||||
// The color picker only understands flat hex, so it needs a solid fallback
|
// The color picker only understands flat hex, so it needs a solid fallback
|
||||||
@@ -39,6 +60,92 @@ export const DEFAULT_NODE_COLOR: Record<NodeKind, string> = {
|
|||||||
exclusiveGateway: "#A12C7E",
|
exclusiveGateway: "#A12C7E",
|
||||||
parallelGateway: "#1BD3F7",
|
parallelGateway: "#1BD3F7",
|
||||||
inclusiveGateway: "#BBBD39",
|
inclusiveGateway: "#BBBD39",
|
||||||
|
complexGateway: "#8A5CE7",
|
||||||
|
eventBasedGateway: "#3C81A0",
|
||||||
|
hint: "#D7E5ED",
|
||||||
|
dataObject: "#CEEAFF",
|
||||||
|
};
|
||||||
|
|
||||||
|
// BPMN task-type icon shown as a small badge in the shape's top-left corner —
|
||||||
|
// 'none' (the plain abstract Task) intentionally has no entry, so it renders bare.
|
||||||
|
export const TASK_SUBTYPE_ICON: Partial<Record<TaskSubtype, IconComponent>> = {
|
||||||
|
user: IconUser,
|
||||||
|
service: IconSettings,
|
||||||
|
script: IconCode,
|
||||||
|
manual: IconHand,
|
||||||
|
businessRule: IconTable,
|
||||||
|
send: IconSendMessage,
|
||||||
|
receive: IconMail,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TASK_SUBTYPE_LABEL: Record<TaskSubtype, string> = {
|
||||||
|
none: "Действие",
|
||||||
|
user: "Действие пользователя",
|
||||||
|
service: "Сервисное действие",
|
||||||
|
script: "Действие-скрипт",
|
||||||
|
manual: "Ручное действие",
|
||||||
|
businessRule: "Действие по бизнес-правилам",
|
||||||
|
send: "Действие отправки",
|
||||||
|
receive: "Действие получения",
|
||||||
|
};
|
||||||
|
|
||||||
|
// BPMN event-trigger icon shown centered inside the event circle — 'none'
|
||||||
|
// (the plain, untyped event) intentionally has no entry.
|
||||||
|
export const EVENT_SUBTYPE_ICON: Partial<Record<EventSubtype, IconComponent>> = {
|
||||||
|
message: IconMail,
|
||||||
|
timer: IconWatchStroked,
|
||||||
|
conditional: IconTable2,
|
||||||
|
signal: IconAlert,
|
||||||
|
error: IconLightningBolt,
|
||||||
|
escalation: IconArrowUp,
|
||||||
|
compensation: IconArrowPrevious,
|
||||||
|
terminate: IconRecord,
|
||||||
|
multiple: IconPolygonMultiple,
|
||||||
|
parallelMultiple: IconGisMultiple,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EVENT_SUBTYPE_LABEL: Record<EventSubtype, string> = {
|
||||||
|
none: "Без триггера",
|
||||||
|
message: "Сообщение",
|
||||||
|
timer: "Таймер",
|
||||||
|
conditional: "Условие",
|
||||||
|
signal: "Сигнал",
|
||||||
|
error: "Ошибка",
|
||||||
|
escalation: "Эскалация",
|
||||||
|
compensation: "Компенсация",
|
||||||
|
terminate: "Завершение процесса",
|
||||||
|
multiple: "Множественное",
|
||||||
|
parallelMultiple: "Параллельное множественное",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Which trigger subtypes are valid on each event kind, per BPMN 2.0 — used to
|
||||||
|
// filter the subtype picker in NodeInspector so it never offers an invalid combination.
|
||||||
|
export const EVENT_SUBTYPES_BY_KIND: Record<
|
||||||
|
"startEvent" | "intermediateEvent" | "endEvent",
|
||||||
|
EventSubtype[]
|
||||||
|
> = {
|
||||||
|
startEvent: ["none", "message", "timer", "conditional", "signal", "multiple", "parallelMultiple"],
|
||||||
|
intermediateEvent: [
|
||||||
|
"none",
|
||||||
|
"message",
|
||||||
|
"timer",
|
||||||
|
"conditional",
|
||||||
|
"signal",
|
||||||
|
"escalation",
|
||||||
|
"compensation",
|
||||||
|
"multiple",
|
||||||
|
"parallelMultiple",
|
||||||
|
],
|
||||||
|
endEvent: [
|
||||||
|
"none",
|
||||||
|
"message",
|
||||||
|
"error",
|
||||||
|
"escalation",
|
||||||
|
"compensation",
|
||||||
|
"signal",
|
||||||
|
"terminate",
|
||||||
|
"multiple",
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_TASK_BORDER_RADIUS = 6;
|
export const DEFAULT_TASK_BORDER_RADIUS = 6;
|
||||||
@@ -109,6 +216,9 @@ function ShapeResizer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TaskNode({ id, data, selected }: NodeProps<ShapeNode>) {
|
function TaskNode({ id, data, selected }: NodeProps<ShapeNode>) {
|
||||||
|
const subtype = (data.subtype as TaskSubtype | undefined) ?? "none";
|
||||||
|
const SubtypeIcon = TASK_SUBTYPE_ICON[subtype];
|
||||||
|
const textColor = data.textColor ?? DEFAULT_NODE_TEXT_COLOR;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="diagram-node diagram-node--task"
|
className="diagram-node diagram-node--task"
|
||||||
@@ -125,10 +235,19 @@ function TaskNode({ id, data, selected }: NodeProps<ShapeNode>) {
|
|||||||
/>
|
/>
|
||||||
<ShapeHandles />
|
<ShapeHandles />
|
||||||
<SubProcessBadge id={id} />
|
<SubProcessBadge id={id} />
|
||||||
|
{SubtypeIcon && (
|
||||||
|
<span
|
||||||
|
className="diagram-node__subtype-badge"
|
||||||
|
style={{ color: textColor }}
|
||||||
|
title={TASK_SUBTYPE_LABEL[subtype]}
|
||||||
|
>
|
||||||
|
<SubtypeIcon size="xs" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span
|
<span
|
||||||
className="diagram-node__label"
|
className="diagram-node__label"
|
||||||
style={{
|
style={{
|
||||||
color: data.textColor ?? DEFAULT_NODE_TEXT_COLOR,
|
color: textColor,
|
||||||
fontSize: data.fontSize ?? DEFAULT_NODE_FONT_SIZE,
|
fontSize: data.fontSize ?? DEFAULT_NODE_FONT_SIZE,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -141,6 +260,8 @@ function TaskNode({ id, data, selected }: NodeProps<ShapeNode>) {
|
|||||||
function EventNode({ id, data, type, selected }: NodeProps<ShapeNode>) {
|
function EventNode({ id, data, type, selected }: NodeProps<ShapeNode>) {
|
||||||
const kind =
|
const kind =
|
||||||
(type as "startEvent" | "intermediateEvent" | "endEvent") ?? "startEvent";
|
(type as "startEvent" | "intermediateEvent" | "endEvent") ?? "startEvent";
|
||||||
|
const subtype = (data.subtype as EventSubtype | undefined) ?? "none";
|
||||||
|
const SubtypeIcon = EVENT_SUBTYPE_ICON[subtype];
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="diagram-node diagram-node--circle"
|
className="diagram-node diagram-node--circle"
|
||||||
@@ -149,6 +270,15 @@ function EventNode({ id, data, type, selected }: NodeProps<ShapeNode>) {
|
|||||||
<ShapeResizer selected={selected} minWidth={24} minHeight={24} />
|
<ShapeResizer selected={selected} minWidth={24} minHeight={24} />
|
||||||
<ShapeHandles />
|
<ShapeHandles />
|
||||||
<SubProcessBadge id={id} />
|
<SubProcessBadge id={id} />
|
||||||
|
{SubtypeIcon && (
|
||||||
|
<span
|
||||||
|
className="diagram-node__event-icon"
|
||||||
|
style={{ color: data.textColor ?? DEFAULT_NODE_TEXT_COLOR }}
|
||||||
|
title={EVENT_SUBTYPE_LABEL[subtype]}
|
||||||
|
>
|
||||||
|
<SubtypeIcon size="s" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span
|
<span
|
||||||
className="diagram-node__caption"
|
className="diagram-node__caption"
|
||||||
style={{
|
style={{
|
||||||
@@ -163,18 +293,24 @@ function EventNode({ id, data, type, selected }: NodeProps<ShapeNode>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GATEWAY_ICON: Record<
|
const GATEWAY_ICON: Record<
|
||||||
"exclusiveGateway" | "parallelGateway" | "inclusiveGateway",
|
"exclusiveGateway" | "parallelGateway" | "inclusiveGateway" | "complexGateway" | "eventBasedGateway",
|
||||||
string
|
string
|
||||||
> = {
|
> = {
|
||||||
exclusiveGateway: "✕",
|
exclusiveGateway: "✕",
|
||||||
parallelGateway: "+",
|
parallelGateway: "+",
|
||||||
inclusiveGateway: "○",
|
inclusiveGateway: "○",
|
||||||
|
complexGateway: "✳",
|
||||||
|
eventBasedGateway: "⬠",
|
||||||
};
|
};
|
||||||
|
|
||||||
function GatewayNode({ id, data, type, selected }: NodeProps<ShapeNode>) {
|
function GatewayNode({ id, data, type, selected }: NodeProps<ShapeNode>) {
|
||||||
const kind =
|
const kind =
|
||||||
(type as "exclusiveGateway" | "parallelGateway" | "inclusiveGateway") ??
|
(type as
|
||||||
"exclusiveGateway";
|
| "exclusiveGateway"
|
||||||
|
| "parallelGateway"
|
||||||
|
| "inclusiveGateway"
|
||||||
|
| "complexGateway"
|
||||||
|
| "eventBasedGateway") ?? "exclusiveGateway";
|
||||||
return (
|
return (
|
||||||
<div className="diagram-node--gateway-outer">
|
<div className="diagram-node--gateway-outer">
|
||||||
<ShapeResizer selected={selected} minWidth={32} minHeight={32} />
|
<ShapeResizer selected={selected} minWidth={32} minHeight={32} />
|
||||||
@@ -211,6 +347,76 @@ function GatewayNode({ id, data, type, selected }: NodeProps<ShapeNode>) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Not a BPMN element — a free-floating annotation the user places next to
|
||||||
|
// other shapes. No handles: it's never meant to be wired into the flow.
|
||||||
|
function HintNode({ data, selected }: NodeProps<ShapeNode>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="diagram-node diagram-node--hint"
|
||||||
|
style={{
|
||||||
|
background: data.color ?? DEFAULT_NODE_BACKGROUND.hint,
|
||||||
|
borderRadius: data.borderRadius ?? DEFAULT_TASK_BORDER_RADIUS,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ShapeResizer
|
||||||
|
selected={selected}
|
||||||
|
minWidth={60}
|
||||||
|
minHeight={32}
|
||||||
|
keepAspectRatio={false}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="diagram-node__label"
|
||||||
|
style={{
|
||||||
|
// The default hint fill (--gradient-frost) is pale, unlike the
|
||||||
|
// vivid task backgrounds, so it needs the dark caption color, not
|
||||||
|
// the light one — same reasoning as DataObjectNode below.
|
||||||
|
color: data.textColor ?? DEFAULT_CAPTION_TEXT_COLOR,
|
||||||
|
fontSize: data.fontSize ?? DEFAULT_NODE_FONT_SIZE,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// BPMN Data Object — represents a document/input/output attached to a task
|
||||||
|
// via a (typically dashed) Data Association edge. The folded top-right corner
|
||||||
|
// is clipped on an inner div, not the outer one, so ShapeHandles — siblings of
|
||||||
|
// it — still land at the plain bounding-box edges instead of being cut off
|
||||||
|
// (same reasoning as the gateway diamond above).
|
||||||
|
function DataObjectNode({ data, selected }: NodeProps<ShapeNode>) {
|
||||||
|
return (
|
||||||
|
<div className="diagram-node--data-object-outer">
|
||||||
|
<ShapeResizer
|
||||||
|
selected={selected}
|
||||||
|
minWidth={50}
|
||||||
|
minHeight={60}
|
||||||
|
keepAspectRatio={false}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="diagram-node--data-object-bg"
|
||||||
|
style={{ background: data.color ?? DEFAULT_NODE_BACKGROUND.dataObject }}
|
||||||
|
/>
|
||||||
|
<ShapeHandles />
|
||||||
|
<span
|
||||||
|
className="diagram-node__label"
|
||||||
|
style={{
|
||||||
|
// The folded-corner bg's `clip-path` gives it its own stacking
|
||||||
|
// context (z-index: 0, set in CSS), so the label needs to be
|
||||||
|
// positioned with a higher z-index to paint above it.
|
||||||
|
position: "relative",
|
||||||
|
zIndex: 1,
|
||||||
|
color: data.textColor ?? DEFAULT_CAPTION_TEXT_COLOR,
|
||||||
|
fontSize: data.fontSize ?? DEFAULT_NODE_FONT_SIZE,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const nodeTypes = {
|
export const nodeTypes = {
|
||||||
task: TaskNode,
|
task: TaskNode,
|
||||||
startEvent: EventNode,
|
startEvent: EventNode,
|
||||||
@@ -219,4 +425,8 @@ export const nodeTypes = {
|
|||||||
exclusiveGateway: GatewayNode,
|
exclusiveGateway: GatewayNode,
|
||||||
parallelGateway: GatewayNode,
|
parallelGateway: GatewayNode,
|
||||||
inclusiveGateway: GatewayNode,
|
inclusiveGateway: GatewayNode,
|
||||||
|
complexGateway: GatewayNode,
|
||||||
|
eventBasedGateway: GatewayNode,
|
||||||
|
hint: HintNode,
|
||||||
|
dataObject: DataObjectNode,
|
||||||
};
|
};
|
||||||
|
|||||||
+32
-1
@@ -5,7 +5,36 @@ export type NodeKind =
|
|||||||
| 'endEvent'
|
| 'endEvent'
|
||||||
| 'exclusiveGateway'
|
| 'exclusiveGateway'
|
||||||
| 'parallelGateway'
|
| 'parallelGateway'
|
||||||
| 'inclusiveGateway';
|
| 'inclusiveGateway'
|
||||||
|
| 'complexGateway'
|
||||||
|
| 'eventBasedGateway'
|
||||||
|
| 'hint'
|
||||||
|
| 'dataObject';
|
||||||
|
|
||||||
|
/** BPMN task subtypes — rendered as a small icon badge in the shape's corner. */
|
||||||
|
export type TaskSubtype =
|
||||||
|
| 'none'
|
||||||
|
| 'user'
|
||||||
|
| 'service'
|
||||||
|
| 'script'
|
||||||
|
| 'manual'
|
||||||
|
| 'businessRule'
|
||||||
|
| 'send'
|
||||||
|
| 'receive';
|
||||||
|
|
||||||
|
/** BPMN event trigger subtypes — which ones are valid depends on the event kind (start/intermediate/end). */
|
||||||
|
export type EventSubtype =
|
||||||
|
| 'none'
|
||||||
|
| 'message'
|
||||||
|
| 'timer'
|
||||||
|
| 'conditional'
|
||||||
|
| 'signal'
|
||||||
|
| 'error'
|
||||||
|
| 'escalation'
|
||||||
|
| 'compensation'
|
||||||
|
| 'terminate'
|
||||||
|
| 'multiple'
|
||||||
|
| 'parallelMultiple';
|
||||||
|
|
||||||
export interface DiagramNodeData extends Record<string, unknown> {
|
export interface DiagramNodeData extends Record<string, unknown> {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -17,6 +46,8 @@ export interface DiagramNodeData extends Record<string, unknown> {
|
|||||||
borderRadius?: number;
|
borderRadius?: number;
|
||||||
textColor?: string;
|
textColor?: string;
|
||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
|
/** BPMN subtype — a TaskSubtype for `task` nodes, an EventSubtype for event nodes. */
|
||||||
|
subtype?: TaskSubtype | EventSubtype;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiagramNode {
|
export interface DiagramNode {
|
||||||
|
|||||||
@@ -14,5 +14,16 @@ export default defineConfig(({ mode }) => {
|
|||||||
// docs/bitrix-integration.md §7.
|
// docs/bitrix-integration.md §7.
|
||||||
base: env.VITE_BASE_PATH || '/',
|
base: env.VITE_BASE_PATH || '/',
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
build: {
|
||||||
|
// No content hashes in output filenames — cache-busting on redeploy is
|
||||||
|
// handled at the hosting/nginx level instead (see docs/bitrix-integration.md).
|
||||||
|
rolldownOptions: {
|
||||||
|
output: {
|
||||||
|
entryFileNames: 'assets/[name].js',
|
||||||
|
chunkFileNames: 'assets/[name].js',
|
||||||
|
assetFileNames: 'assets/[name].[ext]',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user