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

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
+381 -60
View File
@@ -1,11 +1,16 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ReactFlow,
ReactFlowProvider,
Background,
Controls,
ControlButton,
MiniMap,
Panel,
ConnectionMode,
MarkerType,
ViewportPortal,
useReactFlow,
addEdge,
applyNodeChanges,
applyEdgeChanges,
@@ -15,10 +20,12 @@ import {
type NodeChange,
type EdgeChange,
type NodeMouseHandler,
type EdgeMouseHandler,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { nodeTypes, NodesWithChildrenContext } from './nodes';
import { nodeTypes, NodesWithChildrenContext, ModeContext } from './nodes';
import { NodeInspector } from './NodeInspector';
import { EdgeInspector } from './EdgeInspector';
import { debounce, serializeDocument } from './serialize';
import type { DiagramDocument, DiagramNodeData, NodeKind } from './types';
import './diagram.css';
@@ -28,20 +35,106 @@ interface DiagramEditorProps {
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 }[] = [
{ kind: 'task', label: '+ Задача' },
{ kind: 'gateway', label: '+ Шлюз' },
{ kind: 'event', label: '+ Событие' },
{ kind: 'startEvent', 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[] {
return (doc?.nodes ?? []).map((node) => ({
id: node.id,
type: node.type,
position: node.position,
data: node.data,
...(node.parentId ? { parentId: node.parentId } : {}),
}));
return (doc?.nodes ?? []).map((node) => {
const defaults = DEFAULT_NODE_SIZE[node.type];
return {
id: node.id,
type: node.type,
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[] {
@@ -49,24 +142,80 @@ function toRfEdges(doc: DiagramDocument | null): Edge[] {
id: edge.id,
source: edge.source,
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,
}));
}
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) {
const [nodes, setNodes] = useState<Node[]>(() => toRfNodes(initialValue));
const [edges, setEdges] = useState<Edge[]>(() => toRfEdges(initialValue));
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
const [focusNodeId, setFocusNodeId] = useState<string | null>(null);
const [mode, setMode] = useState<'edit' | 'view'>('edit');
const [alignmentGuides, setAlignmentGuides] = useState<AlignmentGuide[]>([]);
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(
() => debounce((n: Node[], e: Edge[]) => onChange(serializeDocument(n, e)), 400),
[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 set = new Set<string>();
for (const node of nodes) {
@@ -102,112 +251,248 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
const enterSubProcess = useCallback((nodeId: string) => {
setFocusNodeId(nodeId);
setSelectedNodeId(null);
setSelectedEdgeId(null);
}, []);
const goToLevel = useCallback((nodeId: string | null) => {
setFocusNodeId(nodeId);
setSelectedNodeId(null);
setSelectedEdgeId(null);
}, []);
const onNodesChange = useCallback(
(changes: NodeChange[]) => {
setNodes((current) => {
const withChildren = new Set(current.filter((n) => n.parentId).map((n) => n.parentId!));
const filteredChanges = changes.filter((change) => {
if (change.type !== 'remove' || !withChildren.has(change.id)) return true;
window.alert('Сначала удалите вложенные узлы — у этого узла есть под-процесс.');
return false;
});
const next = applyNodeChanges(filteredChanges, current);
emitChange(next, edges);
return next;
const withChildren = new Set(nodes.filter((n) => n.parentId).map((n) => n.parentId!));
const filteredChanges = changes.filter((change) => {
if (change.type !== 'remove' || !withChildren.has(change.id)) return true;
window.alert('Сначала удалите вложенные узлы — у этого узла есть под-процесс.');
return false;
});
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(
(changes: EdgeChange[]) => {
setEdges((current) => {
const next = applyEdgeChanges(changes, current);
emitChange(nodes, next);
return next;
});
const isHistoryWorthy = changes.some((change) => change.type === 'remove');
if (isHistoryWorthy) pushHistory(nodes, edges);
const next = applyEdgeChanges(changes, edges);
setEdges(next);
emitChange(nodes, next);
},
[nodes, emitChange],
[nodes, edges, emitChange, pushHistory],
);
const onConnect: OnConnect = useCallback(
(connection) => {
setEdges((current) => {
const next = addEdge(connection, current);
emitChange(nodes, next);
return next;
});
pushHistory(nodes, edges);
const next = addEdge({ ...connection, type: DEFAULT_EDGE_TYPE, markerEnd: DEFAULT_MARKER_END }, edges);
setEdges(next);
emitChange(nodes, next);
},
[nodes, emitChange],
[nodes, edges, emitChange, pushHistory],
);
const onNodeClick: NodeMouseHandler = useCallback((_event, node) => {
setSelectedNodeId(node.id);
setSelectedEdgeId(null);
}, []);
const onNodeDoubleClick: NodeMouseHandler = useCallback(
(_event, node) => {
if (mode === 'view' && !nodesWithChildren.has(node.id)) return;
enterSubProcess(node.id);
const onEdgeClick: EdgeMouseHandler = useCallback(
(_event, edge) => {
if (mode !== 'edit') return;
setSelectedEdgeId(edge.id);
setSelectedNodeId(null);
},
[enterSubProcess, mode, nodesWithChildren],
[mode],
);
const onPaneClick = useCallback(() => {
setSelectedNodeId(null);
setSelectedEdgeId(null);
}, []);
const updateSelectedNodeData = useCallback(
(patch: Partial<DiagramNodeData>) => {
if (!selectedNodeId) return;
setNodes((current) => {
const next = current.map((node) =>
node.id === selectedNodeId ? { ...node, data: { ...node.data, ...patch } } : node,
);
emitChange(next, edges);
return next;
});
pushHistoryThrottled(nodes, edges);
const next = nodes.map((node) =>
node.id === selectedNodeId ? { ...node, data: { ...node.data, ...patch } } : node,
);
setNodes(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(
(kind: NodeKind) => {
nodeCounter.current += 1;
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 = {
id,
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,
...(focusNodeId ? { parentId: focusNodeId } : {}),
};
setNodes((current) => {
const next = [...current, newNode];
emitChange(next, edges);
return next;
});
pushHistory(nodes, edges);
const next = [...nodes, newNode];
setNodes(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 selectedNodeChildCount = selectedNode
? nodes.filter((node) => node.parentId === selectedNode.id).length
: 0;
const selectedEdge = selectedEdgeId ? edges.find((edge) => edge.id === selectedEdgeId) : undefined;
return (
<div className="diagram-shell">
<div className="diagram-canvas">
<div className="diagram-canvas" ref={canvasWrapperRef}>
<NodesWithChildrenContext.Provider value={nodesWithChildren}>
<ModeContext.Provider value={mode}>
<ReactFlow
nodes={visibleNodes}
edges={visibleEdges}
@@ -216,16 +501,42 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={onNodeClick}
onNodeDoubleClick={onNodeDoubleClick}
onEdgeClick={onEdgeClick}
onPaneClick={onPaneClick}
nodesDraggable={mode === 'edit'}
nodesConnectable={mode === 'edit'}
deleteKeyCode={mode === 'edit' ? undefined : null}
zoomOnDoubleClick={false}
connectionMode={ConnectionMode.Loose}
className={mode === 'view' ? 'diagram-flow--view-mode' : undefined}
fitView
>
<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 />
<Panel position="top-right" className="diagram-mode-toggle">
<button type="button" disabled={mode === 'view'} onClick={() => setMode('view')}>
@@ -262,6 +573,7 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
))}
</Panel>
</ReactFlow>
</ModeContext.Provider>
</NodesWithChildrenContext.Provider>
</div>
@@ -275,11 +587,20 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
mode={mode}
onChange={updateSelectedNodeData}
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">
{mode === 'edit'
? 'Выберите узел на схеме, чтобы отредактировать его данные. Двойной клик по узлу — зайти внутрь как в под-процесс.'
? 'Выберите узел или связь на схеме — появятся кнопки «Дублировать»/«Удалить» и поля данных.'
: 'Кликните по узлу, чтобы увидеть описание. Если у узла есть вложенный процесс, появится кнопка перехода.'}
</p>
)}