Добавление нового функционала

This commit is contained in:
NikitaKalitov
2026-07-14 17:55:53 +03:00
parent fed7df1bea
commit 8bbee2dc7e
8 changed files with 744 additions and 114 deletions
+27 -5
View File
@@ -5,7 +5,7 @@ import type { DiagramDocument } from './types';
const SAMPLE_DOCUMENT: DiagramDocument = { const SAMPLE_DOCUMENT: DiagramDocument = {
nodes: [ nodes: [
{ id: 'start', type: 'event', position: { x: 0, y: 80 }, data: { label: 'Заявка получена' } }, { id: 'start', type: 'startEvent', position: { x: 0, y: 80 }, data: { label: 'Заявка получена' } },
{ {
id: 'review', id: 'review',
type: 'task', type: 'task',
@@ -17,7 +17,9 @@ const SAMPLE_DOCUMENT: DiagramDocument = {
duration: '1-2 дня', duration: '1-2 дня',
}, },
}, },
{ id: 'decision', type: 'gateway', position: { x: 480, y: 68 }, data: { label: 'Одобрено?' } }, { id: 'decision', type: 'exclusiveGateway', position: { x: 480, y: 68 }, data: { label: 'Одобрено?' } },
{ id: 'end-approved', type: 'endEvent', position: { x: 640, y: 40 }, data: { label: 'Заявка одобрена' } },
{ id: 'end-rejected', type: 'endEvent', position: { x: 640, y: 140 }, data: { label: 'Заявка отклонена' } },
{ {
id: 'review-check-docs', id: 'review-check-docs',
type: 'task', type: 'task',
@@ -34,9 +36,29 @@ const SAMPLE_DOCUMENT: DiagramDocument = {
}, },
], ],
edges: [ edges: [
{ id: 'start-review', source: 'start', target: 'review' }, { id: 'start-review', source: 'start', target: 'review', sourceHandle: 'right', targetHandle: 'left' },
{ id: 'review-decision', source: 'review', target: 'decision' }, { id: 'review-decision', source: 'review', target: 'decision', sourceHandle: 'right', targetHandle: 'left' },
{ id: 'review-check-docs-assign', source: 'review-check-docs', target: 'review-assign' }, {
id: 'decision-approved',
source: 'decision',
target: 'end-approved',
sourceHandle: 'right',
targetHandle: 'left',
},
{
id: 'decision-rejected',
source: 'decision',
target: 'end-rejected',
sourceHandle: 'right',
targetHandle: 'left',
},
{
id: 'review-check-docs-assign',
source: 'review-check-docs',
target: 'review-assign',
sourceHandle: 'right',
targetHandle: 'left',
},
], ],
}; };
+381 -60
View File
@@ -1,11 +1,16 @@
import { useCallback, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
ReactFlow, ReactFlow,
ReactFlowProvider, ReactFlowProvider,
Background, Background,
Controls, Controls,
ControlButton,
MiniMap, MiniMap,
Panel, Panel,
ConnectionMode,
MarkerType,
ViewportPortal,
useReactFlow,
addEdge, addEdge,
applyNodeChanges, applyNodeChanges,
applyEdgeChanges, applyEdgeChanges,
@@ -15,10 +20,12 @@ import {
type NodeChange, type NodeChange,
type EdgeChange, type EdgeChange,
type NodeMouseHandler, type NodeMouseHandler,
type EdgeMouseHandler,
} from '@xyflow/react'; } from '@xyflow/react';
import '@xyflow/react/dist/style.css'; import '@xyflow/react/dist/style.css';
import { nodeTypes, NodesWithChildrenContext } from './nodes'; import { nodeTypes, NodesWithChildrenContext, ModeContext } from './nodes';
import { NodeInspector } from './NodeInspector'; import { NodeInspector } from './NodeInspector';
import { EdgeInspector } from './EdgeInspector';
import { debounce, serializeDocument } from './serialize'; import { debounce, serializeDocument } from './serialize';
import type { DiagramDocument, DiagramNodeData, NodeKind } from './types'; import type { DiagramDocument, DiagramNodeData, NodeKind } from './types';
import './diagram.css'; import './diagram.css';
@@ -28,20 +35,106 @@ interface DiagramEditorProps {
onChange: (doc: DiagramDocument) => void; onChange: (doc: DiagramDocument) => void;
} }
const DEFAULT_EDGE_TYPE = 'smoothstep';
const DEFAULT_MARKER_END = { type: MarkerType.ArrowClosed };
const MAX_HISTORY = 50;
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: 'gateway', label: '+ Шлюз' }, { kind: 'startEvent', label: '+ Начало' },
{ kind: 'event', label: '+ Событие' }, { kind: 'intermediateEvent', label: '+ Промеж. событие' },
{ kind: 'endEvent', label: '+ Конец' },
{ kind: 'exclusiveGateway', label: '+ Искл. шлюз' },
{ kind: 'parallelGateway', label: '+ Паралл. шлюз' },
{ kind: 'inclusiveGateway', label: '+ Вкл. шлюз' },
]; ];
const DEFAULT_NODE_SIZE: Record<NodeKind, { width: number; height: number }> = {
task: { width: 140, height: 50 },
startEvent: { width: 48, height: 48 },
intermediateEvent: { width: 48, height: 48 },
endEvent: { width: 48, height: 48 },
exclusiveGateway: { width: 64, height: 64 },
parallelGateway: { width: 64, height: 64 },
inclusiveGateway: { width: 64, height: 64 },
};
interface AlignmentGuide {
axis: 'x' | 'y';
position: number;
}
interface Box {
x: number;
y: number;
width: number;
height: number;
}
const SNAP_THRESHOLD = 6;
/**
* Compares the dragged box's edges/center against every other box on the same
* level and, for the first close-enough match on each axis, snaps to it and
* records a guide line to draw across the canvas.
*/
function computeSnapAndGuides(dragged: Box, others: Box[]): { x: number; y: number; guides: AlignmentGuide[] } {
const guides: AlignmentGuide[] = [];
let snappedX = dragged.x;
let snappedY = dragged.y;
let matchedX = false;
let matchedY = false;
for (const other of others) {
if (!matchedX) {
const xCandidates: [number, number][] = [
[dragged.x, other.x],
[dragged.x + dragged.width, other.x + other.width],
[dragged.x + dragged.width / 2, other.x + other.width / 2],
];
for (const [dValue, oValue] of xCandidates) {
if (Math.abs(dValue - oValue) <= SNAP_THRESHOLD) {
snappedX = dragged.x + (oValue - dValue);
guides.push({ axis: 'x', position: oValue });
matchedX = true;
break;
}
}
}
if (!matchedY) {
const yCandidates: [number, number][] = [
[dragged.y, other.y],
[dragged.y + dragged.height, other.y + other.height],
[dragged.y + dragged.height / 2, other.y + other.height / 2],
];
for (const [dValue, oValue] of yCandidates) {
if (Math.abs(dValue - oValue) <= SNAP_THRESHOLD) {
snappedY = dragged.y + (oValue - dValue);
guides.push({ axis: 'y', position: oValue });
matchedY = true;
break;
}
}
}
}
return { x: snappedX, y: snappedY, guides };
}
function toRfNodes(doc: DiagramDocument | null): Node[] { function toRfNodes(doc: DiagramDocument | null): Node[] {
return (doc?.nodes ?? []).map((node) => ({ return (doc?.nodes ?? []).map((node) => {
id: node.id, const defaults = DEFAULT_NODE_SIZE[node.type];
type: node.type, return {
position: node.position, id: node.id,
data: node.data, type: node.type,
...(node.parentId ? { parentId: node.parentId } : {}), position: node.position,
})); data: node.data,
width: node.width ?? defaults.width,
height: node.height ?? defaults.height,
...(node.parentId ? { parentId: node.parentId } : {}),
};
});
} }
function toRfEdges(doc: DiagramDocument | null): Edge[] { function toRfEdges(doc: DiagramDocument | null): Edge[] {
@@ -49,24 +142,80 @@ function toRfEdges(doc: DiagramDocument | null): Edge[] {
id: edge.id, id: edge.id,
source: edge.source, source: edge.source,
target: edge.target, target: edge.target,
type: edge.type, sourceHandle: edge.sourceHandle,
targetHandle: edge.targetHandle,
type: edge.type ?? DEFAULT_EDGE_TYPE,
markerEnd: DEFAULT_MARKER_END,
label: edge.label,
data: edge.data, data: edge.data,
})); }));
} }
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
return target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
}
function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) { function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
const [nodes, setNodes] = useState<Node[]>(() => toRfNodes(initialValue)); const [nodes, setNodes] = useState<Node[]>(() => toRfNodes(initialValue));
const [edges, setEdges] = useState<Edge[]>(() => toRfEdges(initialValue)); const [edges, setEdges] = useState<Edge[]>(() => toRfEdges(initialValue));
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null); const [selectedNodeId, setSelectedNodeId] = 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 nodeCounter = useRef(0); const nodeCounter = useRef(0);
const historyRef = useRef<{ nodes: Node[]; edges: Edge[] }[]>([]);
const redoRef = useRef<{ nodes: Node[]; edges: Edge[] }[]>([]);
const lastTypingHistoryPushRef = useRef(0);
const clipboardRef = useRef<Node | null>(null);
const canvasWrapperRef = useRef<HTMLDivElement>(null);
const { screenToFlowPosition } = 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],
); );
const pushHistory = useCallback((prevNodes: Node[], prevEdges: Edge[]) => {
historyRef.current.push({ nodes: prevNodes, edges: prevEdges });
if (historyRef.current.length > MAX_HISTORY) historyRef.current.shift();
redoRef.current = [];
}, []);
const pushHistoryThrottled = useCallback(
(prevNodes: Node[], prevEdges: Edge[]) => {
const now = Date.now();
if (now - lastTypingHistoryPushRef.current > TYPING_HISTORY_THROTTLE_MS) {
pushHistory(prevNodes, prevEdges);
}
lastTypingHistoryPushRef.current = now;
},
[pushHistory],
);
const undo = useCallback(() => {
const previous = historyRef.current.pop();
if (!previous) return;
redoRef.current.push({ nodes, edges });
setNodes(previous.nodes);
setEdges(previous.edges);
emitChange(previous.nodes, previous.edges);
setSelectedNodeId(null);
setSelectedEdgeId(null);
}, [nodes, edges, emitChange]);
const redo = useCallback(() => {
const next = redoRef.current.pop();
if (!next) return;
historyRef.current.push({ nodes, edges });
setNodes(next.nodes);
setEdges(next.edges);
emitChange(next.nodes, next.edges);
setSelectedNodeId(null);
setSelectedEdgeId(null);
}, [nodes, edges, emitChange]);
const nodesWithChildren = useMemo(() => { const nodesWithChildren = useMemo(() => {
const set = new Set<string>(); const set = new Set<string>();
for (const node of nodes) { for (const node of nodes) {
@@ -102,112 +251,248 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
const enterSubProcess = useCallback((nodeId: string) => { const enterSubProcess = useCallback((nodeId: string) => {
setFocusNodeId(nodeId); setFocusNodeId(nodeId);
setSelectedNodeId(null); setSelectedNodeId(null);
setSelectedEdgeId(null);
}, []); }, []);
const goToLevel = useCallback((nodeId: string | null) => { const goToLevel = useCallback((nodeId: string | null) => {
setFocusNodeId(nodeId); setFocusNodeId(nodeId);
setSelectedNodeId(null); setSelectedNodeId(null);
setSelectedEdgeId(null);
}, []); }, []);
const onNodesChange = useCallback( const onNodesChange = useCallback(
(changes: NodeChange[]) => { (changes: NodeChange[]) => {
setNodes((current) => { const withChildren = new Set(nodes.filter((n) => n.parentId).map((n) => n.parentId!));
const withChildren = new Set(current.filter((n) => n.parentId).map((n) => n.parentId!)); const filteredChanges = changes.filter((change) => {
const filteredChanges = changes.filter((change) => { if (change.type !== 'remove' || !withChildren.has(change.id)) return true;
if (change.type !== 'remove' || !withChildren.has(change.id)) return true; window.alert('Сначала удалите вложенные узлы — у этого узла есть под-процесс.');
window.alert('Сначала удалите вложенные узлы — у этого узла есть под-процесс.'); return false;
return false;
});
const next = applyNodeChanges(filteredChanges, current);
emitChange(next, edges);
return next;
}); });
const positionChanges = filteredChanges.filter((c) => c.type === 'position' && c.position);
let adjustedChanges = filteredChanges;
if (positionChanges.length === 1) {
const change = positionChanges[0];
const draggedNode = change.type === 'position' ? nodes.find((n) => n.id === change.id) : undefined;
if (change.type === 'position' && change.position && draggedNode) {
const level = draggedNode.parentId ?? null;
const others: Box[] = nodes
.filter((n) => n.id !== draggedNode.id && (n.parentId ?? null) === level)
.map((n) => ({ x: n.position.x, y: n.position.y, width: n.width ?? 0, height: n.height ?? 0 }));
const dragged: Box = {
x: change.position.x,
y: change.position.y,
width: draggedNode.width ?? 0,
height: draggedNode.height ?? 0,
};
const snapped = computeSnapAndGuides(dragged, others);
setAlignmentGuides(change.dragging === false ? [] : snapped.guides);
adjustedChanges = filteredChanges.map((c) =>
c === change ? { ...c, position: { x: snapped.x, y: snapped.y } } : c,
);
}
} else if (alignmentGuides.length > 0) {
setAlignmentGuides([]);
}
const isHistoryWorthy = filteredChanges.some(
(change) =>
change.type === 'remove' ||
(change.type === 'position' && change.dragging === false) ||
(change.type === 'dimensions' && change.resizing === false),
);
if (isHistoryWorthy) pushHistory(nodes, edges);
const next = applyNodeChanges(adjustedChanges, nodes);
setNodes(next);
emitChange(next, edges);
}, },
[edges, emitChange], [nodes, edges, emitChange, pushHistory, alignmentGuides],
); );
const onEdgesChange = useCallback( const onEdgesChange = useCallback(
(changes: EdgeChange[]) => { (changes: EdgeChange[]) => {
setEdges((current) => { const isHistoryWorthy = changes.some((change) => change.type === 'remove');
const next = applyEdgeChanges(changes, current); if (isHistoryWorthy) pushHistory(nodes, edges);
emitChange(nodes, next); const next = applyEdgeChanges(changes, edges);
return next; setEdges(next);
}); emitChange(nodes, next);
}, },
[nodes, emitChange], [nodes, edges, emitChange, pushHistory],
); );
const onConnect: OnConnect = useCallback( const onConnect: OnConnect = useCallback(
(connection) => { (connection) => {
setEdges((current) => { pushHistory(nodes, edges);
const next = addEdge(connection, current); const next = addEdge({ ...connection, type: DEFAULT_EDGE_TYPE, markerEnd: DEFAULT_MARKER_END }, edges);
emitChange(nodes, next); setEdges(next);
return next; emitChange(nodes, next);
});
}, },
[nodes, emitChange], [nodes, edges, emitChange, pushHistory],
); );
const onNodeClick: NodeMouseHandler = useCallback((_event, node) => { const onNodeClick: NodeMouseHandler = useCallback((_event, node) => {
setSelectedNodeId(node.id); setSelectedNodeId(node.id);
setSelectedEdgeId(null);
}, []); }, []);
const onNodeDoubleClick: NodeMouseHandler = useCallback( const onEdgeClick: EdgeMouseHandler = useCallback(
(_event, node) => { (_event, edge) => {
if (mode === 'view' && !nodesWithChildren.has(node.id)) return; if (mode !== 'edit') return;
enterSubProcess(node.id); setSelectedEdgeId(edge.id);
setSelectedNodeId(null);
}, },
[enterSubProcess, mode, nodesWithChildren], [mode],
); );
const onPaneClick = useCallback(() => { const onPaneClick = useCallback(() => {
setSelectedNodeId(null); setSelectedNodeId(null);
setSelectedEdgeId(null);
}, []); }, []);
const updateSelectedNodeData = useCallback( const updateSelectedNodeData = useCallback(
(patch: Partial<DiagramNodeData>) => { (patch: Partial<DiagramNodeData>) => {
if (!selectedNodeId) return; if (!selectedNodeId) return;
setNodes((current) => { pushHistoryThrottled(nodes, edges);
const next = current.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,
); );
emitChange(next, edges); setNodes(next);
return next; emitChange(next, edges);
});
}, },
[selectedNodeId, edges, emitChange], [selectedNodeId, nodes, edges, emitChange, pushHistoryThrottled],
); );
const updateSelectedEdgeLabel = useCallback(
(label: string) => {
if (!selectedEdgeId) return;
pushHistoryThrottled(nodes, edges);
const next = edges.map((edge) => (edge.id === selectedEdgeId ? { ...edge, label } : edge));
setEdges(next);
emitChange(nodes, next);
},
[selectedEdgeId, nodes, edges, emitChange, pushHistoryThrottled],
);
const getViewportCenter = useCallback(() => {
const rect = canvasWrapperRef.current?.getBoundingClientRect();
if (!rect) return { x: 0, y: 0 };
return screenToFlowPosition({ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 });
}, [screenToFlowPosition]);
const addNode = useCallback( const addNode = useCallback(
(kind: NodeKind) => { (kind: NodeKind) => {
nodeCounter.current += 1; nodeCounter.current += 1;
const id = `${kind}-${nodeCounter.current}`; const id = `${kind}-${nodeCounter.current}`;
const offset = nodeCounter.current; const center = getViewportCenter();
const fanOut = nodeCounter.current;
const size = DEFAULT_NODE_SIZE[kind];
const newNode: Node = { const newNode: Node = {
id, id,
type: kind, type: kind,
position: { x: 40 + ((offset * 40) % 400), y: 320 + ((offset * 70) % 200) }, position: {
x: center.x + ((fanOut * 24) % 120) - 60,
y: center.y + ((fanOut * 18) % 90) - 45,
},
width: size.width,
height: size.height,
data: { label: NODE_KINDS.find((n) => n.kind === kind)!.label.replace('+ ', '') } as DiagramNodeData, data: { label: NODE_KINDS.find((n) => n.kind === kind)!.label.replace('+ ', '') } as DiagramNodeData,
...(focusNodeId ? { parentId: focusNodeId } : {}), ...(focusNodeId ? { parentId: focusNodeId } : {}),
}; };
setNodes((current) => { pushHistory(nodes, edges);
const next = [...current, newNode]; const next = [...nodes, newNode];
emitChange(next, edges); setNodes(next);
return next; emitChange(next, edges);
});
}, },
[edges, emitChange, focusNodeId], [nodes, edges, emitChange, focusNodeId, pushHistory, getViewportCenter],
); );
const copySelectedNode = useCallback(() => {
const node = selectedNodeId ? nodes.find((n) => n.id === selectedNodeId) : undefined;
if (!node) return;
clipboardRef.current = node;
}, [selectedNodeId, nodes]);
const pasteClipboardNode = useCallback(() => {
const source = clipboardRef.current;
if (!source) return;
nodeCounter.current += 1;
const newId = `${source.type}-${nodeCounter.current}`;
const { parentId: _sourceParentId, selected: _sourceSelected, ...restSource } = source;
const newNode: Node = {
...restSource,
id: newId,
position: { x: source.position.x + 40, y: source.position.y + 40 },
data: { ...(source.data as DiagramNodeData) },
...(focusNodeId ? { parentId: focusNodeId } : {}),
};
pushHistory(nodes, edges);
const next = [...nodes, newNode];
setNodes(next);
emitChange(next, edges);
setSelectedNodeId(newId);
setSelectedEdgeId(null);
}, [nodes, edges, emitChange, focusNodeId, pushHistory]);
const duplicateSelectedNode = useCallback(() => {
copySelectedNode();
pasteClipboardNode();
}, [copySelectedNode, pasteClipboardNode]);
const deleteSelectedNode = useCallback(() => {
if (!selectedNodeId) return;
if (nodes.some((n) => n.parentId === selectedNodeId)) {
window.alert('Сначала удалите вложенные узлы — у этого узла есть под-процесс.');
return;
}
pushHistory(nodes, edges);
const nextNodes = nodes.filter((n) => n.id !== selectedNodeId);
const nextEdges = edges.filter((e) => e.source !== selectedNodeId && e.target !== selectedNodeId);
setNodes(nextNodes);
setEdges(nextEdges);
emitChange(nextNodes, nextEdges);
setSelectedNodeId(null);
}, [selectedNodeId, nodes, edges, emitChange, pushHistory]);
const deleteSelectedEdge = useCallback(() => {
if (!selectedEdgeId) return;
pushHistory(nodes, edges);
const nextEdges = edges.filter((e) => e.id !== selectedEdgeId);
setEdges(nextEdges);
emitChange(nodes, nextEdges);
setSelectedEdgeId(null);
}, [selectedEdgeId, nodes, edges, emitChange, pushHistory]);
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (mode !== 'edit' || isEditableTarget(event.target)) return;
if (!(event.ctrlKey || event.metaKey)) return;
const key = event.key.toLowerCase();
if (key === 'z') {
event.preventDefault();
undo();
} else if (key === 'c') {
event.preventDefault();
copySelectedNode();
} else if (key === 'v') {
event.preventDefault();
pasteClipboardNode();
}
}
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [mode, undo, copySelectedNode, pasteClipboardNode]);
const selectedNode = selectedNodeId ? nodes.find((node) => node.id === selectedNodeId) : undefined; const selectedNode = selectedNodeId ? nodes.find((node) => node.id === selectedNodeId) : undefined;
const selectedNodeChildCount = selectedNode const selectedNodeChildCount = selectedNode
? nodes.filter((node) => node.parentId === selectedNode.id).length ? nodes.filter((node) => node.parentId === selectedNode.id).length
: 0; : 0;
const selectedEdge = selectedEdgeId ? edges.find((edge) => edge.id === selectedEdgeId) : undefined;
return ( return (
<div className="diagram-shell"> <div className="diagram-shell">
<div className="diagram-canvas"> <div className="diagram-canvas" ref={canvasWrapperRef}>
<NodesWithChildrenContext.Provider value={nodesWithChildren}> <NodesWithChildrenContext.Provider value={nodesWithChildren}>
<ModeContext.Provider value={mode}>
<ReactFlow <ReactFlow
nodes={visibleNodes} nodes={visibleNodes}
edges={visibleEdges} edges={visibleEdges}
@@ -216,16 +501,42 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
onEdgesChange={onEdgesChange} onEdgesChange={onEdgesChange}
onConnect={onConnect} onConnect={onConnect}
onNodeClick={onNodeClick} onNodeClick={onNodeClick}
onNodeDoubleClick={onNodeDoubleClick} 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}
className={mode === 'view' ? 'diagram-flow--view-mode' : undefined}
fitView fitView
> >
<Background /> <Background />
<Controls /> <ViewportPortal>
{alignmentGuides.map((guide, index) => (
<div
key={`${guide.axis}-${index}`}
className="diagram-alignment-guide"
style={
guide.axis === 'x'
? { left: guide.position, top: -10000, width: 1, height: 20000 }
: { top: guide.position, left: -10000, height: 1, width: 20000 }
}
/>
))}
</ViewportPortal>
<Controls>
{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>
<MiniMap /> <MiniMap />
<Panel position="top-right" className="diagram-mode-toggle"> <Panel position="top-right" className="diagram-mode-toggle">
<button type="button" disabled={mode === 'view'} onClick={() => setMode('view')}> <button type="button" disabled={mode === 'view'} onClick={() => setMode('view')}>
@@ -262,6 +573,7 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
))} ))}
</Panel> </Panel>
</ReactFlow> </ReactFlow>
</ModeContext.Provider>
</NodesWithChildrenContext.Provider> </NodesWithChildrenContext.Provider>
</div> </div>
@@ -275,11 +587,20 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
mode={mode} mode={mode}
onChange={updateSelectedNodeData} onChange={updateSelectedNodeData}
onEnterSubProcess={enterSubProcess} onEnterSubProcess={enterSubProcess}
onDuplicate={duplicateSelectedNode}
onDelete={deleteSelectedNode}
/>
) : selectedEdge ? (
<EdgeInspector
edgeId={selectedEdge.id}
label={typeof selectedEdge.label === 'string' ? selectedEdge.label : ''}
onChange={updateSelectedEdgeLabel}
onDelete={deleteSelectedEdge}
/> />
) : ( ) : (
<p className="diagram-aside__hint"> <p className="diagram-aside__hint">
{mode === 'edit' {mode === 'edit'
? 'Выберите узел на схеме, чтобы отредактировать его данные. Двойной клик по узлу — зайти внутрь как в под-процесс.' ? 'Выберите узел или связь на схеме — появятся кнопки «Дублировать»/«Удалить» и поля данных.'
: 'Кликните по узлу, чтобы увидеть описание. Если у узла есть вложенный процесс, появится кнопка перехода.'} : 'Кликните по узлу, чтобы увидеть описание. Если у узла есть вложенный процесс, появится кнопка перехода.'}
</p> </p>
)} )}
+28
View File
@@ -0,0 +1,28 @@
interface EdgeInspectorProps {
edgeId: string;
label: string;
onChange: (label: string) => void;
onDelete: () => void;
}
export function EdgeInspector({ edgeId, label, onChange, onDelete }: EdgeInspectorProps) {
return (
<div className="node-inspector">
<div className="node-inspector__header">
<span className="node-inspector__type">Связь</span>
<code className="node-inspector__id">{edgeId}</code>
</div>
<div className="node-inspector__toolbar">
<button type="button" onClick={onDelete}>
Удалить
</button>
</div>
<label className="node-inspector__field">
Подпись
<input type="text" value={label} onChange={(e) => onChange(e.target.value)} />
</label>
</div>
);
}
+32 -5
View File
@@ -1,9 +1,14 @@
import { DEFAULT_NODE_COLOR } from './nodes';
import type { DiagramNodeData, NodeKind } from './types'; import type { DiagramNodeData, NodeKind } from './types';
const TYPE_LABELS: Record<NodeKind, string> = { const TYPE_LABELS: Record<NodeKind, string> = {
task: 'Задача', task: 'Задача',
gateway: 'Шлюз', startEvent: 'Начальное событие',
event: 'Событие', intermediateEvent: 'Промежуточное событие',
endEvent: 'Конечное событие',
exclusiveGateway: 'Исключающий шлюз',
parallelGateway: 'Параллельный шлюз',
inclusiveGateway: 'Включающий шлюз',
}; };
interface NodeInspectorProps { interface NodeInspectorProps {
@@ -14,6 +19,8 @@ interface NodeInspectorProps {
mode: 'edit' | 'view'; mode: 'edit' | 'view';
onChange: (patch: Partial<DiagramNodeData>) => void; onChange: (patch: Partial<DiagramNodeData>) => void;
onEnterSubProcess: (nodeId: string) => void; onEnterSubProcess: (nodeId: string) => void;
onDuplicate: () => void;
onDelete: () => void;
} }
export function NodeInspector({ export function NodeInspector({
@@ -24,6 +31,8 @@ export function NodeInspector({
mode, mode,
onChange, onChange,
onEnterSubProcess, onEnterSubProcess,
onDuplicate,
onDelete,
}: NodeInspectorProps) { }: NodeInspectorProps) {
if (mode === 'view') { if (mode === 'view') {
const hasMeta = Boolean(data.responsible || data.duration); const hasMeta = Boolean(data.responsible || data.duration);
@@ -70,10 +79,28 @@ export function NodeInspector({
<code className="node-inspector__id">{nodeId}</code> <code className="node-inspector__id">{nodeId}</code>
</div> </div>
<div className="node-inspector__toolbar">
<button type="button" onClick={onDuplicate}>
Дублировать
</button>
<button type="button" onClick={onDelete}>
Удалить
</button>
</div>
<label className="node-inspector__field node-inspector__field--color">
Цвет фона
<input
type="color"
value={data.color ?? DEFAULT_NODE_COLOR[nodeType]}
onChange={(e) => onChange({ color: e.target.value })}
/>
</label>
<label className="node-inspector__field"> <label className="node-inspector__field">
Название Название
<input <textarea
type="text" rows={2}
value={data.label} value={data.label}
onChange={(e) => onChange({ label: e.target.value })} onChange={(e) => onChange({ label: e.target.value })}
/> />
@@ -111,7 +138,7 @@ export function NodeInspector({
</button> </button>
<p className="node-inspector__hint"> <p className="node-inspector__hint">
{childCount > 0 {childCount > 0
? `Внутри уже ${childCount} узл(ов). Двойной клик по узлу на схеме открывает то же самое.` ? `Внутри уже ${childCount} узл(ов).`
: 'Пока пусто — зайдя внутрь, можно добавить узлы детального разбора этого шага.'} : 'Пока пусто — зайдя внутрь, можно добавить узлы детального разбора этого шага.'}
</p> </p>
</div> </div>
+128 -24
View File
@@ -11,7 +11,9 @@
.diagram-toolbar { .diagram-toolbar {
display: flex; display: flex;
flex-wrap: wrap;
gap: 8px; gap: 8px;
max-width: 280px;
} }
.diagram-toolbar button { .diagram-toolbar button {
@@ -28,6 +30,26 @@
background: color-mix(in srgb, canvastext 8%, canvas); background: color-mix(in srgb, canvastext 8%, canvas);
} }
.diagram-flow--view-mode .react-flow__handle {
display: none;
}
/*
* React Flow's built-in disabled styling only dims <svg> children (for the
* zoom/fit/lock icon buttons) — our undo/redo controls use plain text glyphs,
* so they need their own dimming or they'd look clickable while inert.
*/
.react-flow__controls-button:disabled {
opacity: 0.4;
}
.diagram-alignment-guide {
position: absolute;
background: #ff0072;
pointer-events: none;
z-index: 1000;
}
.diagram-mode-toggle { .diagram-mode-toggle {
display: flex; display: flex;
border: 1px solid #999; border: 1px solid #999;
@@ -80,14 +102,24 @@
.diagram-node { .diagram-node {
position: relative; position: relative;
min-width: 120px; width: 100%;
padding: 10px 16px; height: 100%;
padding: 6px 10px;
border: 1px solid #555; border: 1px solid #555;
border-radius: 6px; border-radius: 6px;
background: canvas; background: canvas;
color: canvastext; color: canvastext;
font-size: 12px; font-size: 12px;
text-align: center; text-align: center;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
}
.diagram-node__label {
white-space: pre-wrap;
overflow-wrap: break-word;
} }
.diagram-node__badge { .diagram-node__badge {
@@ -104,35 +136,74 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 1;
} }
.diagram-node--gateway { .diagram-node__caption {
width: 64px; position: absolute;
height: 64px; top: 100%;
min-width: 0; left: 50%;
transform: translateX(-50%);
margin-top: 4px;
max-width: 120px;
font-size: 11px;
text-align: center;
color: canvastext;
white-space: pre-wrap;
}
.diagram-node--circle {
padding: 0; padding: 0;
display: flex; border-radius: 50%;
align-items: center;
justify-content: center;
border-radius: 4px;
transform: rotate(45deg);
} }
.diagram-node--gateway span { .diagram-node--startEvent {
border-width: 1px;
}
.diagram-node--endEvent {
border-width: 3px;
}
.diagram-node--intermediateEvent {
border-width: 1px;
}
.diagram-node--intermediateEvent::after {
content: '';
position: absolute;
inset: 3px;
border: 1px solid #555;
border-radius: 50%;
}
.diagram-node--gateway-outer {
position: relative;
width: 100%;
height: 100%;
}
/*
* Inset to 1/sqrt(2) of the outer box and rotated 45deg: the diamond's tips
* exactly reach the outer box's edges without the corners clipping outside it.
*/
.diagram-node--diamond {
position: absolute;
top: 50%;
left: 50%;
width: 70.71%;
height: 70.71%;
padding: 0;
border-radius: 4px;
transform: translate(-50%, -50%) rotate(45deg);
}
.diagram-node__icon {
display: inline-block; display: inline-block;
transform: rotate(-45deg); transform: rotate(-45deg);
font-size: 11px; font-size: 18px;
} font-weight: 700;
line-height: 1;
.diagram-node--event {
width: 64px;
height: 64px;
min-width: 0;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
} }
.diagram-aside { .diagram-aside {
@@ -177,6 +248,25 @@
justify-content: space-between; justify-content: space-between;
} }
.node-inspector__toolbar {
display: flex;
gap: 8px;
}
.node-inspector__toolbar button {
font: inherit;
padding: 6px 10px;
border: 1px solid #999;
border-radius: 4px;
background: canvas;
color: canvastext;
cursor: pointer;
}
.node-inspector__toolbar button:hover {
background: color-mix(in srgb, canvastext 8%, canvas);
}
.node-inspector__type { .node-inspector__type {
font-weight: 600; font-weight: 600;
} }
@@ -204,6 +294,19 @@
color: canvastext; color: canvastext;
} }
.node-inspector__field--color {
flex-direction: row;
align-items: center;
gap: 8px;
}
.node-inspector__field--color input[type='color'] {
width: 36px;
height: 28px;
padding: 2px;
cursor: pointer;
}
.node-inspector__action { .node-inspector__action {
font: inherit; font: inherit;
padding: 8px 10px; padding: 8px 10px;
@@ -227,6 +330,7 @@
.node-inspector__label { .node-inspector__label {
margin: 0; margin: 0;
font-size: 15px; font-size: 15px;
white-space: pre-wrap;
} }
.node-inspector__description { .node-inspector__description {
+127 -16
View File
@@ -1,5 +1,5 @@
import { createContext, useContext } from 'react'; import { createContext, useContext, type CSSProperties } from 'react';
import { Handle, Position, type Node, type NodeProps } from '@xyflow/react'; import { Handle, NodeResizer, Position, type Node, type NodeProps } from '@xyflow/react';
import type { DiagramNodeData, NodeKind } from './types'; import type { DiagramNodeData, NodeKind } from './types';
type ShapeNode = Node<DiagramNodeData, NodeKind>; type ShapeNode = Node<DiagramNodeData, NodeKind>;
@@ -7,11 +7,64 @@ type ShapeNode = Node<DiagramNodeData, NodeKind>;
/** Ids of nodes that currently have children (i.e. render as a drill-into-able sub-process). */ /** Ids of nodes that currently have children (i.e. render as a drill-into-able sub-process). */
export const NodesWithChildrenContext = createContext<Set<string>>(new Set()); export const NodesWithChildrenContext = createContext<Set<string>>(new Set());
/** Current editor mode — resize handles and connection points only show in 'edit'. */
export const ModeContext = createContext<'edit' | 'view'>('edit');
export const DEFAULT_NODE_COLOR: Record<NodeKind, string> = {
task: '#eef2ff',
startEvent: '#f0fdf4',
intermediateEvent: '#fefce8',
endEvent: '#fef2f2',
exclusiveGateway: '#fff7ed',
parallelGateway: '#faf5ff',
inclusiveGateway: '#f0fdfa',
};
const HANDLE_POSITIONS: { id: string; position: Position }[] = [
{ id: 'top', position: Position.Top },
{ id: 'right', position: Position.Right },
{ id: 'bottom', position: Position.Bottom },
{ id: 'left', position: Position.Left },
];
/**
* A single handle per side acts as both connection start and end
* (React Flow handles are connectable both ways by default) — paired with
* connectionMode="loose" on <ReactFlow> so any point can link to any point.
*/
function ShapeHandles() { function ShapeHandles() {
return ( return (
<> <>
<Handle type="target" position={Position.Left} /> {HANDLE_POSITIONS.map(({ id, position }) => (
<Handle type="source" position={Position.Right} /> <Handle key={id} id={id} type="source" position={position} />
))}
</>
);
}
/**
* The diamond div is a rotated square (`transform: rotate(45deg)`). Handles
* placed at the square's own corners land on the diamond's tips once rotated
* — corners, not `Position.Top/Right/Bottom/Left` (those are edge-midpoints,
* which rotate onto the diamond's flat sides instead).
*/
const DIAMOND_HANDLES: { id: string; position: Position; style: CSSProperties }[] = [
{ id: 'top', position: Position.Top, style: { top: 0, left: 0, transform: 'translate(-50%, -50%)' } },
{ id: 'right', position: Position.Right, style: { top: 0, left: '100%', transform: 'translate(-50%, -50%)' } },
{
id: 'bottom',
position: Position.Bottom,
style: { top: '100%', left: '100%', transform: 'translate(-50%, -50%)' },
},
{ id: 'left', position: Position.Left, style: { top: '100%', left: 0, transform: 'translate(-50%, -50%)' } },
];
function DiamondHandles() {
return (
<>
{DIAMOND_HANDLES.map(({ id, position, style }) => (
<Handle key={id} id={id} type="source" position={position} style={style} />
))}
</> </>
); );
} }
@@ -26,38 +79,96 @@ function SubProcessBadge({ id }: { id: string }) {
); );
} }
function TaskNode({ id, data }: NodeProps<ShapeNode>) { function ShapeResizer({
selected,
minWidth,
minHeight,
keepAspectRatio = true,
}: {
selected: boolean;
minWidth: number;
minHeight: number;
keepAspectRatio?: boolean;
}) {
const mode = useContext(ModeContext);
return ( return (
<div className="diagram-node diagram-node--task"> <NodeResizer
isVisible={selected && mode === 'edit'}
minWidth={minWidth}
minHeight={minHeight}
keepAspectRatio={keepAspectRatio}
/>
);
}
function TaskNode({ id, data, selected }: NodeProps<ShapeNode>) {
return (
<div className="diagram-node diagram-node--task" style={{ background: data.color ?? DEFAULT_NODE_COLOR.task }}>
<ShapeResizer selected={selected} minWidth={70} minHeight={32} keepAspectRatio={false} />
<ShapeHandles /> <ShapeHandles />
<SubProcessBadge id={id} /> <SubProcessBadge id={id} />
<span>{data.label}</span> <span className="diagram-node__label">{data.label}</span>
</div> </div>
); );
} }
function GatewayNode({ id, data }: NodeProps<ShapeNode>) { const EVENT_BORDER_CLASS: Record<'startEvent' | 'intermediateEvent' | 'endEvent', string> = {
startEvent: 'diagram-node--startEvent',
intermediateEvent: 'diagram-node--intermediateEvent',
endEvent: 'diagram-node--endEvent',
};
function EventNode({ id, data, type, selected }: NodeProps<ShapeNode>) {
const kind = (type as 'startEvent' | 'intermediateEvent' | 'endEvent') ?? 'startEvent';
return ( return (
<div className="diagram-node diagram-node--gateway"> <div
className={`diagram-node diagram-node--circle ${EVENT_BORDER_CLASS[kind]}`}
style={{ background: data.color ?? DEFAULT_NODE_COLOR[kind] }}
>
<ShapeResizer selected={selected} minWidth={24} minHeight={24} />
<ShapeHandles /> <ShapeHandles />
<SubProcessBadge id={id} /> <SubProcessBadge id={id} />
<span>{data.label}</span> <span className="diagram-node__caption">{data.label}</span>
</div> </div>
); );
} }
function EventNode({ id, data }: NodeProps<ShapeNode>) { const GATEWAY_ICON: Record<'exclusiveGateway' | 'parallelGateway' | 'inclusiveGateway', string> = {
exclusiveGateway: '✕',
parallelGateway: '+',
inclusiveGateway: '○',
};
function GatewayNode({ id, data, type, selected }: NodeProps<ShapeNode>) {
const kind = (type as 'exclusiveGateway' | 'parallelGateway' | 'inclusiveGateway') ?? 'exclusiveGateway';
return ( return (
<div className="diagram-node diagram-node--event"> <div className="diagram-node--gateway-outer">
<ShapeHandles /> <ShapeResizer selected={selected} minWidth={32} minHeight={32} />
{/*
The diamond is an inner square inset to 1/sqrt(2) of the outer box and
rotated 45deg, so its tips exactly reach the outer box's edges without
clipping. The caption sits on the (unrotated) outer box instead of the
diamond itself, so it renders upright below the shape.
*/}
<div
className="diagram-node diagram-node--diamond"
style={{ background: data.color ?? DEFAULT_NODE_COLOR[kind] }}
>
<DiamondHandles />
<span className="diagram-node__icon">{GATEWAY_ICON[kind]}</span>
</div>
<SubProcessBadge id={id} /> <SubProcessBadge id={id} />
<span>{data.label}</span> <span className="diagram-node__caption">{data.label}</span>
</div> </div>
); );
} }
export const nodeTypes = { export const nodeTypes = {
task: TaskNode, task: TaskNode,
gateway: GatewayNode, startEvent: EventNode,
event: EventNode, intermediateEvent: EventNode,
endEvent: EventNode,
exclusiveGateway: GatewayNode,
parallelGateway: GatewayNode,
inclusiveGateway: GatewayNode,
}; };
+5
View File
@@ -14,6 +14,8 @@ export function serializeDocument(nodes: Node[], edges: Edge[]): DiagramDocument
position: { x: node.position.x, y: node.position.y }, position: { x: node.position.x, y: node.position.y },
data: node.data as DiagramNode['data'], data: node.data as DiagramNode['data'],
...(node.parentId ? { parentId: node.parentId } : {}), ...(node.parentId ? { parentId: node.parentId } : {}),
...(typeof node.width === 'number' ? { width: node.width } : {}),
...(typeof node.height === 'number' ? { height: node.height } : {}),
}), }),
), ),
edges: edges.map( edges: edges.map(
@@ -21,7 +23,10 @@ export function serializeDocument(nodes: Node[], edges: Edge[]): DiagramDocument
id: edge.id, id: edge.id,
source: edge.source, source: edge.source,
target: edge.target, target: edge.target,
...(edge.sourceHandle ? { sourceHandle: edge.sourceHandle } : {}),
...(edge.targetHandle ? { targetHandle: edge.targetHandle } : {}),
type: edge.type, type: edge.type,
...(typeof edge.label === 'string' && edge.label ? { label: edge.label } : {}),
data: edge.data as DiagramEdge['data'], data: edge.data as DiagramEdge['data'],
}), }),
), ),
+16 -4
View File
@@ -1,10 +1,18 @@
export type NodeKind = 'task' | 'gateway' | 'event'; export type NodeKind =
| 'task'
| 'startEvent'
| 'intermediateEvent'
| 'endEvent'
| 'exclusiveGateway'
| 'parallelGateway'
| 'inclusiveGateway';
export interface DiagramNodeData extends Record<string, unknown> { export interface DiagramNodeData extends Record<string, unknown> {
label: string; label: string;
description?: string; description?: string;
responsible?: string; responsible?: string;
duration?: string; duration?: string;
color?: string;
} }
export interface DiagramNode { export interface DiagramNode {
@@ -14,17 +22,21 @@ export interface DiagramNode {
data: DiagramNodeData; data: DiagramNodeData;
/** Node this one is nested under (BPMN sub-process style drill-down), same document. */ /** Node this one is nested under (BPMN sub-process style drill-down), same document. */
parentId?: string; parentId?: string;
width?: number;
height?: number;
} }
export interface DiagramEdgeData extends Record<string, unknown> { export interface DiagramEdgeData extends Record<string, unknown> {}
label?: string;
}
export interface DiagramEdge { export interface DiagramEdge {
id: string; id: string;
source: string; source: string;
target: string; target: string;
/** Which of the 4 connection points on each shape this edge is anchored to. */
sourceHandle?: string;
targetHandle?: string;
type?: string; type?: string;
label?: string;
data?: DiagramEdgeData; data?: DiagramEdgeData;
} }