Добавил перекючатель редактирования и просмотра

This commit is contained in:
NikitaKalitov
2026-07-14 10:45:39 +03:00
parent 5b6bdc0a1f
commit fed7df1bea
8 changed files with 576 additions and 73 deletions
+208 -18
View File
@@ -1,10 +1,11 @@
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import {
ReactFlow,
ReactFlowProvider,
Background,
Controls,
MiniMap,
Panel,
addEdge,
applyNodeChanges,
applyEdgeChanges,
@@ -13,11 +14,13 @@ import {
type OnConnect,
type NodeChange,
type EdgeChange,
type NodeMouseHandler,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { nodeTypes } from './nodes';
import { nodeTypes, NodesWithChildrenContext } from './nodes';
import { NodeInspector } from './NodeInspector';
import { debounce, serializeDocument } from './serialize';
import type { DiagramDocument } from './types';
import type { DiagramDocument, DiagramNodeData, NodeKind } from './types';
import './diagram.css';
interface DiagramEditorProps {
@@ -25,12 +28,19 @@ interface DiagramEditorProps {
onChange: (doc: DiagramDocument) => void;
}
const NODE_KINDS: { kind: NodeKind; label: string }[] = [
{ kind: 'task', label: '+ Задача' },
{ kind: 'gateway', label: '+ Шлюз' },
{ kind: 'event', label: '+ Событие' },
];
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 } : {}),
}));
}
@@ -47,16 +57,68 @@ function toRfEdges(doc: DiagramDocument | null): Edge[] {
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 [focusNodeId, setFocusNodeId] = useState<string | null>(null);
const [mode, setMode] = useState<'edit' | 'view'>('edit');
const nodeCounter = useRef(0);
const emitChange = useMemo(
() => debounce((n: Node[], e: Edge[]) => onChange(serializeDocument(n, e)), 400),
[onChange],
);
const nodesWithChildren = useMemo(() => {
const set = new Set<string>();
for (const node of nodes) {
if (node.parentId) set.add(node.parentId);
}
return set;
}, [nodes]);
const visibleNodes = useMemo(
() => nodes.filter((node) => (node.parentId ?? null) === focusNodeId),
[nodes, focusNodeId],
);
const visibleEdges = useMemo(() => {
const visibleIds = new Set(visibleNodes.map((node) => node.id));
return edges.filter((edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target));
}, [edges, visibleNodes]);
const breadcrumbPath = useMemo(() => {
const path: Node[] = [];
const visited = new Set<string>();
let currentId = focusNodeId;
while (currentId && !visited.has(currentId)) {
visited.add(currentId);
const node = nodes.find((n) => n.id === currentId);
if (!node) break;
path.unshift(node);
currentId = node.parentId ?? null;
}
return path;
}, [nodes, focusNodeId]);
const enterSubProcess = useCallback((nodeId: string) => {
setFocusNodeId(nodeId);
setSelectedNodeId(null);
}, []);
const goToLevel = useCallback((nodeId: string | null) => {
setFocusNodeId(nodeId);
setSelectedNodeId(null);
}, []);
const onNodesChange = useCallback(
(changes: NodeChange[]) => {
setNodes((current) => {
const next = applyNodeChanges(changes, 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;
});
@@ -86,21 +148,149 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
[nodes, emitChange],
);
const onNodeClick: NodeMouseHandler = useCallback((_event, node) => {
setSelectedNodeId(node.id);
}, []);
const onNodeDoubleClick: NodeMouseHandler = useCallback(
(_event, node) => {
if (mode === 'view' && !nodesWithChildren.has(node.id)) return;
enterSubProcess(node.id);
},
[enterSubProcess, mode, nodesWithChildren],
);
const onPaneClick = useCallback(() => {
setSelectedNodeId(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;
});
},
[selectedNodeId, edges, emitChange],
);
const addNode = useCallback(
(kind: NodeKind) => {
nodeCounter.current += 1;
const id = `${kind}-${nodeCounter.current}`;
const offset = nodeCounter.current;
const newNode: Node = {
id,
type: kind,
position: { x: 40 + ((offset * 40) % 400), y: 320 + ((offset * 70) % 200) },
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;
});
},
[edges, emitChange, focusNodeId],
);
const selectedNode = selectedNodeId ? nodes.find((node) => node.id === selectedNodeId) : undefined;
const selectedNodeChildCount = selectedNode
? nodes.filter((node) => node.parentId === selectedNode.id).length
: 0;
return (
<div className="diagram-canvas">
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
fitView
>
<Background />
<Controls />
<MiniMap />
</ReactFlow>
<div className="diagram-shell">
<div className="diagram-canvas">
<NodesWithChildrenContext.Provider value={nodesWithChildren}>
<ReactFlow
nodes={visibleNodes}
edges={visibleEdges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={onNodeClick}
onNodeDoubleClick={onNodeDoubleClick}
onPaneClick={onPaneClick}
nodesDraggable={mode === 'edit'}
nodesConnectable={mode === 'edit'}
deleteKeyCode={mode === 'edit' ? undefined : null}
zoomOnDoubleClick={false}
fitView
>
<Background />
<Controls />
<MiniMap />
<Panel position="top-right" className="diagram-mode-toggle">
<button type="button" disabled={mode === 'view'} onClick={() => setMode('view')}>
Просмотр
</button>
<button type="button" disabled={mode === 'edit'} onClick={() => setMode('edit')}>
Редактирование
</button>
</Panel>
{mode === 'edit' && (
<Panel position="top-left" className="diagram-toolbar">
{NODE_KINDS.map(({ kind, label }) => (
<button key={kind} type="button" onClick={() => addNode(kind)}>
{label}
</button>
))}
</Panel>
)}
<Panel position="top-center" className="diagram-breadcrumbs">
<button type="button" disabled={!focusNodeId} onClick={() => goToLevel(null)}>
Корень
</button>
{breadcrumbPath.map((node, index) => (
<span key={node.id}>
{' / '}
<button
type="button"
disabled={index === breadcrumbPath.length - 1}
onClick={() => goToLevel(node.id)}
>
{(node.data as DiagramNodeData).label}
</button>
</span>
))}
</Panel>
</ReactFlow>
</NodesWithChildrenContext.Provider>
</div>
<aside className="diagram-aside">
{selectedNode ? (
<NodeInspector
nodeId={selectedNode.id}
nodeType={(selectedNode.type ?? 'task') as NodeKind}
data={selectedNode.data as DiagramNodeData}
childCount={selectedNodeChildCount}
mode={mode}
onChange={updateSelectedNodeData}
onEnterSubProcess={enterSubProcess}
/>
) : (
<p className="diagram-aside__hint">
{mode === 'edit'
? 'Выберите узел на схеме, чтобы отредактировать его данные. Двойной клик по узлу — зайти внутрь как в под-процесс.'
: 'Кликните по узлу, чтобы увидеть описание. Если у узла есть вложенный процесс, появится кнопка перехода.'}
</p>
)}
{mode === 'edit' && (
<details className="diagram-aside__json">
<summary>Текущий doc (для демо)</summary>
<pre>{JSON.stringify(serializeDocument(nodes, edges), null, 2)}</pre>
</details>
)}
</aside>
</div>
);
}