Сделал элементы согласно нотации, убрал хэш при билде

This commit is contained in:
NikitaKalitov
2026-07-28 14:03:16 +03:00
parent 99d48f393a
commit 33939dffaf
7 changed files with 767 additions and 208 deletions
+339 -180
View File
@@ -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,140 +706,185 @@ 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">
<div className="diagram-canvas" ref={canvasWrapperRef}> <div className="diagram-canvas" ref={canvasWrapperRef}>
<NodesWithChildrenContext.Provider value={nodesWithChildren}> <NodesWithChildrenContext.Provider value={nodesWithChildren}>
<ModeContext.Provider value={mode}> <ModeContext.Provider value={mode}>
<ReactFlow <ReactFlow
nodes={visibleNodes} nodes={visibleNodes}
edges={visibleEdges} edges={visibleEdges}
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
onNodesChange={onNodesChange} onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange} onEdgesChange={onEdgesChange}
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={
fitView mode === "view" ? "diagram-flow--view-mode" : undefined
proOptions={{ hideAttribution: true }} }
> fitView
<Background /> proOptions={{ hideAttribution: true }}
<ViewportPortal> >
{alignmentGuides.map((guide, index) => ( <Background />
<div <ViewportPortal>
key={`${guide.axis}-${index}`} {alignmentGuides.map((guide, index) => (
className="diagram-alignment-guide" <div
style={ key={`${guide.axis}-${index}`}
guide.axis === 'x' className="diagram-alignment-guide"
? { left: guide.position, top: -10000, width: 1, height: 20000 } style={
: { top: guide.position, left: -10000, height: 1, width: 20000 } guide.axis === "x" ?
} {
/> left: guide.position,
))} top: -10000,
</ViewportPortal> width: 1,
<Controls showZoom={false} showFitView={false} showInteractive={false}> height: 20000,
<ControlButton onClick={() => zoomIn()} title="Увеличить"> }
+ : {
</ControlButton> top: guide.position,
<ControlButton onClick={() => zoomOut()} title="Уменьшить"> left: -10000,
− height: 1,
</ControlButton> width: 20000,
<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)} />
))} ))}
</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>
)} {mode === "edit" && (
<Panel position="top-center" className="diagram-breadcrumbs"> <Panel position="top-left" className="diagram-toolbar">
<Breadcrumbs {NODE_KINDS.map(({ kind, label }) => (
size="xs" <Button
items={[ key={kind}
{ label: 'Корень', onClick: focusNodeId ? () => goToLevel(null) : undefined }, size="xs"
...breadcrumbPath.map((node, index) => ({ view="ghost"
label: (node.data as DiagramNodeData).label, label={label}
onClick: index === breadcrumbPath.length - 1 ? undefined : () => goToLevel(node.id), onClick={() => addNode(kind)}
})), />
]} ))}
getItemOnClick={(item) => item.onClick} </Panel>
/> )}
</Panel> <Panel position="top-center" className="diagram-breadcrumbs">
</ReactFlow> <Breadcrumbs
</ModeContext.Provider> 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> </NodesWithChildrenContext.Provider>
</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>
+1 -1
View File
@@ -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
View File
@@ -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"
+58
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+11
View File
@@ -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]',
},
},
},
} }
}) })