From 33939dffafb700568d45d236d8874ae1bfdeca7a Mon Sep 17 00:00:00 2001 From: NikitaKalitov Date: Tue, 28 Jul 2026 14:03:16 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=B4=D0=B5=D0=BB=D0=B0=D0=BB=20=D1=8D?= =?UTF-8?q?=D0=BB=D0=B5=D0=BC=D0=B5=D0=BD=D1=82=D1=8B=20=D1=81=D0=BE=D0=B3?= =?UTF-8?q?=D0=BB=D0=B0=D1=81=D0=BD=D0=BE=20=D0=BD=D0=BE=D1=82=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B8,=20=D1=83=D0=B1=D1=80=D0=B0=D0=BB=20=D1=85=D1=8D?= =?UTF-8?q?=D1=88=20=D0=BF=D1=80=D0=B8=20=D0=B1=D0=B8=D0=BB=D0=B4=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/DiagramEditor.tsx | 519 +++++++++++++++++++++++++++--------------- src/EdgeInspector.tsx | 2 +- src/NodeInspector.tsx | 132 +++++++++-- src/diagram.css | 58 +++++ src/nodes.tsx | 220 +++++++++++++++++- src/types.ts | 33 ++- vite.config.ts | 11 + 7 files changed, 767 insertions(+), 208 deletions(-) diff --git a/src/DiagramEditor.tsx b/src/DiagramEditor.tsx index eba9772..babba0f 100644 --- a/src/DiagramEditor.tsx +++ b/src/DiagramEditor.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ReactFlow, ReactFlowProvider, @@ -23,53 +23,63 @@ import { type EdgeChange, type NodeMouseHandler, type EdgeMouseHandler, -} from '@xyflow/react'; -import '@xyflow/react/dist/style.css'; -import { Button } from '@consta/uikit/Button'; -import { ChoiceGroup } from '@consta/uikit/ChoiceGroup'; -import { Breadcrumbs } from '@consta/uikit/Breadcrumbs'; -import { nodeTypes, NodesWithChildrenContext, ModeContext } from './nodes'; -import { NodeInspector } from './NodeInspector'; -import { EdgeInspector } from './EdgeInspector'; -import { debounce, serializeDocument } from './serialize'; -import type { DiagramDocument, DiagramEdgeData, DiagramNodeData, EdgeLineStyle, NodeKind } from './types'; -import './diagram.css'; +} from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; +import { Button } from "@consta/uikit/Button"; +import { ChoiceGroup } from "@consta/uikit/ChoiceGroup"; +import { Breadcrumbs } from "@consta/uikit/Breadcrumbs"; +import { nodeTypes, NodesWithChildrenContext, ModeContext } from "./nodes"; +import { NodeInspector } from "./NodeInspector"; +import { EdgeInspector } from "./EdgeInspector"; +import { debounce, serializeDocument } from "./serialize"; +import type { + DiagramDocument, + DiagramEdgeData, + DiagramNodeData, + EdgeLineStyle, + NodeKind, +} from "./types"; +import "./diagram.css"; interface DiagramEditorProps { initialValue: DiagramDocument | null; onChange: (doc: DiagramDocument) => void; } -const DEFAULT_EDGE_TYPE = 'smoothstep'; +const DEFAULT_EDGE_TYPE = "smoothstep"; const DEFAULT_MARKER_END = { type: MarkerType.ArrowClosed }; const DEFAULT_EDGE_STROKE_WIDTH = 1; const DEFAULT_EDGE_BORDER_RADIUS = 5; // matches getSmoothStepPath's own default -const DEFAULT_EDGE_COLOR = '#b1b1b7'; // xyflow's own default edge color -const DEFAULT_EDGE_TEXT_COLOR = '#FFFFFF'; +const DEFAULT_EDGE_COLOR = "#b1b1b7"; // xyflow's own default edge color +const DEFAULT_EDGE_TEXT_COLOR = "#FFFFFF"; const DEFAULT_EDGE_FONT_SIZE = 11; const EDGE_DASH_ARRAY: Record = { solid: undefined, - dashed: '8 4', - dotted: '1.5 4', + dashed: "8 4", + dotted: "1.5 4", }; const MAX_HISTORY = 50; const TYPING_HISTORY_THROTTLE_MS = 1000; const NODE_KINDS: { kind: NodeKind; label: string }[] = [ - { kind: 'task', label: '+ Задача' }, - { kind: 'startEvent', label: '+ Начало' }, - { kind: 'intermediateEvent', label: '+ Промеж. событие' }, - { kind: 'endEvent', label: '+ Конец' }, - { kind: 'exclusiveGateway', label: '+ Искл. шлюз' }, - { kind: 'parallelGateway', label: '+ Паралл. шлюз' }, - { kind: 'inclusiveGateway', label: '+ Вкл. шлюз' }, + { kind: "task", label: "+ Действие" }, + { kind: "startEvent", label: "+ Начало" }, + { kind: "intermediateEvent", label: "+ Промеж. событие" }, + { kind: "endEvent", label: "+ Конец" }, + { kind: "exclusiveGateway", label: "+ Искл. шлюз (XOR)" }, + { kind: "parallelGateway", label: "+ Паралл. шлюз (AND)" }, + { kind: "inclusiveGateway", label: "+ Вкл. шлюз (OR)" }, + { kind: "complexGateway", label: "+ Сложный шлюз" }, + { kind: "eventBasedGateway", label: "+ Шлюз по событию" }, + { kind: "hint", label: "+ Подсказка" }, + { kind: "dataObject", label: "+ Документ" }, ]; // Stable references — ChoiceGroup's controlled `value` is compared against // entries in `items` by identity, so this can't be recreated on every render. -const MODE_ITEMS: { label: string; mode: 'edit' | 'view' }[] = [ - { label: 'Просмотр', mode: 'view' }, - { label: 'Редактирование', mode: 'edit' }, +const MODE_ITEMS: { label: string; mode: "edit" | "view" }[] = [ + { label: "Просмотр", mode: "view" }, + { label: "Редактирование", mode: "edit" }, ]; const DEFAULT_NODE_SIZE: Record = { @@ -80,10 +90,14 @@ const DEFAULT_NODE_SIZE: Record = { exclusiveGateway: { width: 64, height: 64 }, parallelGateway: { width: 64, height: 64 }, inclusiveGateway: { width: 64, height: 64 }, + complexGateway: { width: 64, height: 64 }, + eventBasedGateway: { width: 64, height: 64 }, + hint: { width: 160, height: 70 }, + dataObject: { width: 90, height: 70 }, }; interface AlignmentGuide { - axis: 'x' | 'y'; + axis: "x" | "y"; position: number; } @@ -101,7 +115,10 @@ const SNAP_THRESHOLD = 6; * same level and, for the first close-enough match on each axis, snaps to it * and records a guide line to draw across the canvas. */ -function computeSnapAndGuides(dragged: Box, others: Box[]): { x: number; y: number; guides: AlignmentGuide[] } { +function computeSnapAndGuides( + dragged: Box, + others: Box[], +): { x: number; y: number; guides: AlignmentGuide[] } { const guides: AlignmentGuide[] = []; let snappedX = dragged.x; let snappedY = dragged.y; @@ -115,7 +132,7 @@ function computeSnapAndGuides(dragged: Box, others: Box[]): { x: number; y: numb const otherCenterX = other.x + other.width / 2; if (Math.abs(draggedCenterX - otherCenterX) <= SNAP_THRESHOLD) { snappedX = dragged.x + (otherCenterX - draggedCenterX); - guides.push({ axis: 'x', position: otherCenterX }); + guides.push({ axis: "x", position: otherCenterX }); matchedX = true; } } @@ -123,7 +140,7 @@ function computeSnapAndGuides(dragged: Box, others: Box[]): { x: number; y: numb const otherCenterY = other.y + other.height / 2; if (Math.abs(draggedCenterY - otherCenterY) <= SNAP_THRESHOLD) { snappedY = dragged.y + (otherCenterY - draggedCenterY); - guides.push({ axis: 'y', position: otherCenterY }); + guides.push({ axis: "y", position: otherCenterY }); matchedY = true; } } @@ -172,7 +189,11 @@ function toRfEdges(doc: DiagramDocument | null): Edge[] { function isEditableTarget(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) return false; - return target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable; + return ( + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable + ); } function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) { @@ -181,7 +202,7 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) { const [selectedNodeId, setSelectedNodeId] = useState(null); const [selectedEdgeId, setSelectedEdgeId] = useState(null); const [focusNodeId, setFocusNodeId] = useState(null); - const [mode, setMode] = useState<'edit' | 'view'>('edit'); + const [mode, setMode] = useState<"edit" | "view">("edit"); const [alignmentGuides, setAlignmentGuides] = useState([]); const nodeCounter = useRef(0); const historyRef = useRef<{ nodes: Node[]; edges: Edge[] }[]>([]); @@ -192,7 +213,11 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) { const { screenToFlowPosition, zoomIn, zoomOut, fitView } = useReactFlow(); const emitChange = useMemo( - () => debounce((n: Node[], e: Edge[]) => onChange(serializeDocument(n, e)), 400), + () => + debounce( + (n: Node[], e: Edge[]) => onChange(serializeDocument(n, e)), + 400, + ), [onChange], ); @@ -251,7 +276,9 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) { const visibleEdges = useMemo(() => { const visibleIds = new Set(visibleNodes.map((node) => node.id)); return edges - .filter((edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target)) + .filter( + (edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target), + ) .map((edge) => { const edgeData = edge.data as DiagramEdgeData | undefined; const edgeColor = edgeData?.color ?? DEFAULT_EDGE_COLOR; @@ -261,9 +288,11 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) { ...edge.style, stroke: edgeColor, strokeWidth: edgeData?.strokeWidth ?? DEFAULT_EDGE_STROKE_WIDTH, - strokeDasharray: EDGE_DASH_ARRAY[edgeData?.lineStyle ?? 'solid'], + strokeDasharray: EDGE_DASH_ARRAY[edgeData?.lineStyle ?? "solid"], + }, + pathOptions: { + borderRadius: edgeData?.borderRadius ?? DEFAULT_EDGE_BORDER_RADIUS, }, - pathOptions: { borderRadius: edgeData?.borderRadius ?? DEFAULT_EDGE_BORDER_RADIUS }, markerEnd: { type: MarkerType.ArrowClosed, color: edgeColor }, // Label renders as its own small chip — same color as the line, configurable text color. labelBgStyle: { fill: edgeColor }, @@ -309,9 +338,13 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) { // inside onNodesChange — stops the connected edges from being deleted too. const onBeforeDelete = useCallback( async ({ nodes: nodesToDelete }: { nodes: Node[]; edges: Edge[] }) => { - const hasContainerNode = nodesToDelete.some((node) => nodes.some((n) => n.parentId === node.id)); + const hasContainerNode = nodesToDelete.some((node) => + nodes.some((n) => n.parentId === node.id), + ); if (hasContainerNode) { - window.alert('Сначала удалите вложенные узлы — у этого узла есть под-процесс.'); + window.alert( + "Сначала удалите вложенные узлы — у этого узла есть под-процесс.", + ); return false; } return true; @@ -321,16 +354,28 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) { const onNodesChange = useCallback( (changes: NodeChange[]) => { - const positionChanges = changes.filter((c) => c.type === 'position' && c.position); + const positionChanges = changes.filter( + (c) => c.type === "position" && c.position, + ); let adjustedChanges = changes; if (positionChanges.length === 1) { const change = positionChanges[0]; - const draggedNode = change.type === 'position' ? nodes.find((n) => n.id === change.id) : undefined; - if (change.type === 'position' && change.position && draggedNode) { + const draggedNode = + change.type === "position" ? + nodes.find((n) => n.id === change.id) + : undefined; + if (change.type === "position" && change.position && draggedNode) { const level = draggedNode.parentId ?? null; const others: Box[] = nodes - .filter((n) => n.id !== draggedNode.id && (n.parentId ?? null) === level) - .map((n) => ({ x: n.position.x, y: n.position.y, width: n.width ?? 0, height: n.height ?? 0 })); + .filter( + (n) => n.id !== draggedNode.id && (n.parentId ?? null) === level, + ) + .map((n) => ({ + x: n.position.x, + y: n.position.y, + width: n.width ?? 0, + height: n.height ?? 0, + })); const dragged: Box = { x: change.position.x, y: change.position.y, @@ -340,7 +385,9 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) { const snapped = computeSnapAndGuides(dragged, others); setAlignmentGuides(change.dragging === false ? [] : snapped.guides); adjustedChanges = changes.map((c) => - c === change ? { ...c, position: { x: snapped.x, y: snapped.y } } : c, + c === change ? + { ...c, position: { x: snapped.x, y: snapped.y } } + : c, ); } } else if (alignmentGuides.length > 0) { @@ -349,9 +396,9 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) { const isHistoryWorthy = changes.some( (change) => - change.type === 'remove' || - (change.type === 'position' && change.dragging === false) || - (change.type === 'dimensions' && change.resizing === false), + change.type === "remove" || + (change.type === "position" && change.dragging === false) || + (change.type === "dimensions" && change.resizing === false), ); if (isHistoryWorthy) pushHistory(nodes, edges); const next = applyNodeChanges(adjustedChanges, nodes); @@ -361,10 +408,16 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) { // emitChange — filtering edges here too (redundantly with onEdgesChange) // guarantees a removed node never leaves a dangling edge behind. const removedIds = new Set( - changes.filter((change) => change.type === 'remove').map((change) => change.id), + changes + .filter((change) => change.type === "remove") + .map((change) => change.id), ); - const nextEdges = removedIds.size - ? edges.filter((edge) => !removedIds.has(edge.source) && !removedIds.has(edge.target)) + const nextEdges = + removedIds.size ? + edges.filter( + (edge) => + !removedIds.has(edge.source) && !removedIds.has(edge.target), + ) : edges; setNodes(next); @@ -376,7 +429,9 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) { const onEdgesChange = useCallback( (changes: EdgeChange[]) => { - const isHistoryWorthy = changes.some((change) => change.type === 'remove'); + const isHistoryWorthy = changes.some( + (change) => change.type === "remove", + ); if (isHistoryWorthy) pushHistory(nodes, edges); const next = applyEdgeChanges(changes, edges); setEdges(next); @@ -388,8 +443,22 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) { const onConnect: OnConnect = useCallback( (connection) => { pushHistory(nodes, edges); + // A Data Object endpoint means this is a BPMN Data Association, not a + // Sequence Flow — those are conventionally drawn dashed, so default to + // that (still just a starting point — line style stays editable after). + const involvesDataObject = [connection.source, connection.target].some( + (id) => nodes.find((n) => n.id === id)?.type === "dataObject", + ); const next = addEdge( - { ...connection, id: crypto.randomUUID(), type: DEFAULT_EDGE_TYPE, markerEnd: DEFAULT_MARKER_END }, + { + ...connection, + id: crypto.randomUUID(), + type: DEFAULT_EDGE_TYPE, + markerEnd: DEFAULT_MARKER_END, + ...(involvesDataObject ? + { data: { lineStyle: "dashed" } as DiagramEdgeData } + : {}), + }, edges, ); setEdges(next); @@ -402,7 +471,9 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) { (oldEdge: Edge, connection: Connection) => { pushHistory(nodes, edges); // Keep the original edge id stable so selection/history don't go stale mid-drag. - const next = reconnectEdge(oldEdge, connection, edges, { shouldReplaceId: false }); + const next = reconnectEdge(oldEdge, connection, edges, { + shouldReplaceId: false, + }); setEdges(next); emitChange(nodes, next); }, @@ -416,7 +487,7 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) { const onEdgeClick: EdgeMouseHandler = useCallback( (_event, edge) => { - if (mode !== 'edit') return; + if (mode !== "edit") return; setSelectedEdgeId(edge.id); setSelectedNodeId(null); }, @@ -433,7 +504,9 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) { if (!selectedNodeId) return; pushHistoryThrottled(nodes, edges); const next = nodes.map((node) => - node.id === selectedNodeId ? { ...node, data: { ...node.data, ...patch } } : node, + node.id === selectedNodeId ? + { ...node, data: { ...node.data, ...patch } } + : node, ); setNodes(next); emitChange(next, edges); @@ -445,7 +518,9 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) { (label: string) => { if (!selectedEdgeId) return; pushHistoryThrottled(nodes, edges); - const next = edges.map((edge) => (edge.id === selectedEdgeId ? { ...edge, label } : edge)); + const next = edges.map((edge) => + edge.id === selectedEdgeId ? { ...edge, label } : edge, + ); setEdges(next); emitChange(nodes, next); }, @@ -457,7 +532,9 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) { if (!selectedEdgeId) return; pushHistoryThrottled(nodes, edges); const next = edges.map((edge) => - edge.id === selectedEdgeId ? { ...edge, data: { ...edge.data, ...patch } } : edge, + edge.id === selectedEdgeId ? + { ...edge, data: { ...edge.data, ...patch } } + : edge, ); setEdges(next); emitChange(nodes, next); @@ -468,7 +545,10 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) { const getViewportCenter = useCallback(() => { const rect = canvasWrapperRef.current?.getBoundingClientRect(); if (!rect) return { x: 0, y: 0 }; - return screenToFlowPosition({ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }); + return screenToFlowPosition({ + x: rect.x + rect.width / 2, + y: rect.y + rect.height / 2, + }); }, [screenToFlowPosition]); const addNode = useCallback( @@ -487,7 +567,12 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) { }, width: size.width, height: size.height, - data: { label: NODE_KINDS.find((n) => n.kind === kind)!.label.replace('+ ', '') } as DiagramNodeData, + data: { + label: NODE_KINDS.find((n) => n.kind === kind)!.label.replace( + "+ ", + "", + ), + } as DiagramNodeData, ...(focusNodeId ? { parentId: focusNodeId } : {}), }; pushHistory(nodes, edges); @@ -499,7 +584,8 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) { ); const copySelectedNode = useCallback(() => { - const node = selectedNodeId ? nodes.find((n) => n.id === selectedNodeId) : undefined; + const node = + selectedNodeId ? nodes.find((n) => n.id === selectedNodeId) : undefined; if (!node) return; clipboardRef.current = node; }, [selectedNodeId, nodes]); @@ -509,7 +595,11 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) { if (!source) return; nodeCounter.current += 1; const newId = crypto.randomUUID(); - const { parentId: _sourceParentId, selected: _sourceSelected, ...restSource } = source; + const { + parentId: _sourceParentId, + selected: _sourceSelected, + ...restSource + } = source; const newNode: Node = { ...restSource, id: newId, @@ -533,12 +623,16 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) { const deleteSelectedNode = useCallback(() => { if (!selectedNodeId) return; if (nodes.some((n) => n.parentId === selectedNodeId)) { - window.alert('Сначала удалите вложенные узлы — у этого узла есть под-процесс.'); + window.alert( + "Сначала удалите вложенные узлы — у этого узла есть под-процесс.", + ); return; } pushHistory(nodes, edges); const nextNodes = nodes.filter((n) => n.id !== selectedNodeId); - const nextEdges = edges.filter((e) => e.source !== selectedNodeId && e.target !== selectedNodeId); + const nextEdges = edges.filter( + (e) => e.source !== selectedNodeId && e.target !== selectedNodeId, + ); setNodes(nextNodes); setEdges(nextEdges); emitChange(nextNodes, nextEdges); @@ -557,11 +651,21 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) { // Nodes and edges share one z-index space (zIndexMode="manual"), so front/back // always means "above/below everything currently on the canvas", not just siblings. const maxZIndex = useCallback( - () => Math.max(0, ...nodes.map((n) => n.zIndex ?? 0), ...edges.map((e) => e.zIndex ?? 0)), + () => + Math.max( + 0, + ...nodes.map((n) => n.zIndex ?? 0), + ...edges.map((e) => e.zIndex ?? 0), + ), [nodes, edges], ); const minZIndex = useCallback( - () => Math.min(0, ...nodes.map((n) => n.zIndex ?? 0), ...edges.map((e) => e.zIndex ?? 0)), + () => + Math.min( + 0, + ...nodes.map((n) => n.zIndex ?? 0), + ...edges.map((e) => e.zIndex ?? 0), + ), [nodes, edges], ); @@ -569,7 +673,9 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) { if (!selectedNodeId) return; const z = maxZIndex() + 1; pushHistory(nodes, edges); - const next = nodes.map((n) => (n.id === selectedNodeId ? { ...n, zIndex: z } : n)); + const next = nodes.map((n) => + n.id === selectedNodeId ? { ...n, zIndex: z } : n, + ); setNodes(next); emitChange(next, edges); }, [selectedNodeId, nodes, edges, emitChange, pushHistory, maxZIndex]); @@ -578,7 +684,9 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) { if (!selectedNodeId) return; const z = minZIndex() - 1; pushHistory(nodes, edges); - const next = nodes.map((n) => (n.id === selectedNodeId ? { ...n, zIndex: z } : n)); + const next = nodes.map((n) => + n.id === selectedNodeId ? { ...n, zIndex: z } : n, + ); setNodes(next); emitChange(next, edges); }, [selectedNodeId, nodes, edges, emitChange, pushHistory, minZIndex]); @@ -587,7 +695,9 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) { if (!selectedEdgeId) return; const z = maxZIndex() + 1; pushHistory(nodes, edges); - const next = edges.map((e) => (e.id === selectedEdgeId ? { ...e, zIndex: z } : e)); + const next = edges.map((e) => + e.id === selectedEdgeId ? { ...e, zIndex: z } : e, + ); setEdges(next); emitChange(nodes, next); }, [selectedEdgeId, nodes, edges, emitChange, pushHistory, maxZIndex]); @@ -596,140 +706,185 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) { if (!selectedEdgeId) return; const z = minZIndex() - 1; pushHistory(nodes, edges); - const next = edges.map((e) => (e.id === selectedEdgeId ? { ...e, zIndex: z } : e)); + const next = edges.map((e) => + e.id === selectedEdgeId ? { ...e, zIndex: z } : e, + ); setEdges(next); emitChange(nodes, next); }, [selectedEdgeId, nodes, edges, emitChange, pushHistory, minZIndex]); useEffect(() => { function onKeyDown(event: KeyboardEvent) { - if (mode !== 'edit' || isEditableTarget(event.target)) return; + if (mode !== "edit" || isEditableTarget(event.target)) return; if (!(event.ctrlKey || event.metaKey)) return; const key = event.key.toLowerCase(); - if (key === 'z') { + if (key === "z") { event.preventDefault(); undo(); - } else if (key === 'c') { + } else if (key === "c") { event.preventDefault(); copySelectedNode(); - } else if (key === 'v') { + } else if (key === "v") { event.preventDefault(); pasteClipboardNode(); } } - window.addEventListener('keydown', onKeyDown); - return () => window.removeEventListener('keydown', onKeyDown); + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); }, [mode, undo, copySelectedNode, pasteClipboardNode]); - const selectedNode = selectedNodeId ? nodes.find((node) => node.id === selectedNodeId) : undefined; - const selectedNodeChildCount = selectedNode - ? nodes.filter((node) => node.parentId === selectedNode.id).length + const selectedNode = + selectedNodeId ? + nodes.find((node) => node.id === selectedNodeId) + : undefined; + const selectedNodeChildCount = + selectedNode ? + nodes.filter((node) => node.parentId === selectedNode.id).length : 0; - const selectedEdge = selectedEdgeId ? edges.find((edge) => edge.id === selectedEdgeId) : undefined; + const selectedEdge = + selectedEdgeId ? + edges.find((edge) => edge.id === selectedEdgeId) + : undefined; return (
- - - - {alignmentGuides.map((guide, index) => ( -
- ))} - - - zoomIn()} title="Увеличить"> - + - - zoomOut()} title="Уменьшить"> - − - - fitView()} title="Показать всё"> - ⛶ - - {mode === 'edit' && ( - <> - - ↶ - - - ↷ - - - )} - - {mode === 'edit' && } - - item.mode === mode) ?? null} - onChange={(item) => setMode(item.mode)} - /> - - {mode === 'edit' && ( - - {NODE_KINDS.map(({ kind, label }) => ( -
diff --git a/src/EdgeInspector.tsx b/src/EdgeInspector.tsx index aab3c78..ff52f9b 100644 --- a/src/EdgeInspector.tsx +++ b/src/EdgeInspector.tsx @@ -92,7 +92,7 @@ export function EdgeInspector({
- Связь + Поток управления (Sequence Flow) {edgeId} diff --git a/src/NodeInspector.tsx b/src/NodeInspector.tsx index b363de4..3c9e66f 100644 --- a/src/NodeInspector.tsx +++ b/src/NodeInspector.tsx @@ -10,8 +10,24 @@ import { DEFAULT_NODE_FONT_SIZE, DEFAULT_NODE_TEXT_COLOR, DEFAULT_TASK_BORDER_RADIUS, + EVENT_SUBTYPE_ICON, + EVENT_SUBTYPE_LABEL, + EVENT_SUBTYPES_BY_KIND, + TASK_SUBTYPE_ICON, + TASK_SUBTYPE_LABEL, } 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 / // flowcharts / semantic highlights" sections. Consta's ColorPicker `palette` @@ -48,15 +64,21 @@ const TEXT_COLOR_PRESETS: { hex: string; label: string }[] = [ ]; const TYPE_LABELS: Record = { - task: "Задача", - startEvent: "Начальное событие", - intermediateEvent: "Промежуточное событие", - endEvent: "Конечное событие", - exclusiveGateway: "Исключающий шлюз", - parallelGateway: "Параллельный шлюз", - inclusiveGateway: "Включающий шлюз", + task: "Действие (Task)", + startEvent: "Начальное событие (Start Event)", + intermediateEvent: "Промежуточное событие (Intermediate Event)", + endEvent: "Конечное событие (End Event)", + exclusiveGateway: "Исключающий шлюз (XOR)", + parallelGateway: "Параллельный шлюз (AND)", + inclusiveGateway: "Включающий шлюз (OR)", + complexGateway: "Сложный шлюз (Complex)", + eventBasedGateway: "Шлюз на основе событий (Event-based)", + hint: "Подсказка", + dataObject: "Документ (Data Object)", }; +const EVENT_KINDS = new Set(["startEvent", "intermediateEvent", "endEvent"]); + interface NodeInspectorProps { nodeId: string; nodeType: NodeKind; @@ -109,6 +131,12 @@ export function NodeInspector({ if (mode === "view") { 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 (
@@ -117,6 +145,12 @@ export function NodeInspector({
+ {subtypeLabel && ( + + {subtypeLabel} + + )} + {data.label} @@ -196,6 +230,60 @@ export function NodeInspector({ />
+ {nodeType === "task" && ( +
+ + Тип задачи + +
+ {TASK_SUBTYPES.map((subtype) => { + const Icon = TASK_SUBTYPE_ICON[subtype]; + const isActive = (data.subtype ?? "none") === subtype; + return ( + + ); + })} +
+
+ )} + + {EVENT_KINDS.has(nodeType) && ( +
+ + Тип триггера + +
+ {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 ( + + ); + }, + )} +
+
+ )} +
Цвет фона @@ -306,6 +394,21 @@ export function NodeInspector({ onChange={(value) => onChange({ description: value ?? "" })} /> + {(nodeType === "task" || nodeType === "hint") && ( + { + const n = Number(value); + if (value && !Number.isNaN(n)) onChange({ borderRadius: n }); + }} + /> + )} + {nodeType === "task" && ( <> onChange({ duration: value ?? "" })} /> - { - const n = Number(value); - if (value && !Number.isNaN(n)) onChange({ borderRadius: n }); - }} - /> -