Добавил перекючатель редактирования и просмотра
This commit is contained in:
+208
-18
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user