diff --git a/src/App.css b/src/App.css deleted file mode 100644 index a38bc34..0000000 --- a/src/App.css +++ /dev/null @@ -1,31 +0,0 @@ -.app-shell { - display: flex; - height: 100%; -} - -.app-canvas { - flex: 1; - min-width: 0; -} - -.app-inspector { - width: 360px; - flex-shrink: 0; - border-left: 1px solid color-mix(in srgb, currentcolor 20%, transparent); - padding: 12px; - overflow: auto; -} - -.app-inspector h2 { - margin: 0 0 8px; - font-size: 13px; - text-transform: uppercase; - letter-spacing: 0.04em; - opacity: 0.7; -} - -.app-inspector pre { - font-size: 12px; - white-space: pre-wrap; - word-break: break-word; -} diff --git a/src/App.tsx b/src/App.tsx index f0e48b2..8847e5e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,7 +2,6 @@ import { useCallback, useState } from 'react'; import { DiagramEditor } from './DiagramEditor'; import { createDevAdapter } from './host/devAdapter'; import type { DiagramDocument } from './types'; -import './App.css'; const SAMPLE_DOCUMENT: DiagramDocument = { nodes: [ @@ -16,14 +15,28 @@ const SAMPLE_DOCUMENT: DiagramDocument = { description: 'Приём заявок проводится ответственным лицом, занимает 1-2 дня.', responsible: 'Иван Иванов', duration: '1-2 дня', - linkType: 'description', }, }, { id: 'decision', type: 'gateway', position: { x: 480, y: 68 }, data: { label: 'Одобрено?' } }, + { + id: 'review-check-docs', + type: 'task', + position: { x: 40, y: 60 }, + data: { label: 'Проверить документы' }, + parentId: 'review', + }, + { + id: 'review-assign', + type: 'task', + position: { x: 260, y: 60 }, + data: { label: 'Назначить ответственного' }, + parentId: 'review', + }, ], edges: [ { id: 'start-review', source: 'start', target: 'review' }, { id: 'review-decision', source: 'review', target: 'decision' }, + { id: 'review-check-docs-assign', source: 'review-check-docs', target: 'review-assign' }, ], }; @@ -31,24 +44,12 @@ const adapter = createDevAdapter(); function App() { const [initialDoc] = useState(() => adapter.getInitialValue() ?? SAMPLE_DOCUMENT); - const [doc, setDoc] = useState(initialDoc); const handleChange = useCallback((next: DiagramDocument) => { adapter.onChange(next); - setDoc(next); }, []); - return ( -
-
- -
- -
- ); + return ; } export default App; diff --git a/src/DiagramEditor.tsx b/src/DiagramEditor.tsx index 91c8912..689df0c 100644 --- a/src/DiagramEditor.tsx +++ b/src/DiagramEditor.tsx @@ -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(() => toRfNodes(initialValue)); const [edges, setEdges] = useState(() => toRfEdges(initialValue)); + const [selectedNodeId, setSelectedNodeId] = useState(null); + const [focusNodeId, setFocusNodeId] = useState(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(); + 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(); + 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) => { + 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 ( -
- - - - - +
+
+ + + + + + + + + + {mode === 'edit' && ( + + {NODE_KINDS.map(({ kind, label }) => ( + + ))} + + )} + + + {breadcrumbPath.map((node, index) => ( + + {' / '} + + + ))} + + + +
+ +
); } diff --git a/src/NodeInspector.tsx b/src/NodeInspector.tsx new file mode 100644 index 0000000..5c56187 --- /dev/null +++ b/src/NodeInspector.tsx @@ -0,0 +1,119 @@ +import type { DiagramNodeData, NodeKind } from './types'; + +const TYPE_LABELS: Record = { + task: 'Задача', + gateway: 'Шлюз', + event: 'Событие', +}; + +interface NodeInspectorProps { + nodeId: string; + nodeType: NodeKind; + data: DiagramNodeData; + childCount: number; + mode: 'edit' | 'view'; + onChange: (patch: Partial) => void; + onEnterSubProcess: (nodeId: string) => void; +} + +export function NodeInspector({ + nodeId, + nodeType, + data, + childCount, + mode, + onChange, + onEnterSubProcess, +}: NodeInspectorProps) { + if (mode === 'view') { + const hasMeta = Boolean(data.responsible || data.duration); + return ( +
+
+ {TYPE_LABELS[nodeType]} +
+ +

{data.label}

+ + {data.description &&

{data.description}

} + + {hasMeta && ( +
+ {data.responsible && ( + <> +
Ответственный
+
{data.responsible}
+ + )} + {data.duration && ( + <> +
Длительность
+
{data.duration}
+ + )} +
+ )} + + {childCount > 0 && ( + + )} +
+ ); + } + + return ( +
+
+ {TYPE_LABELS[nodeType]} + {nodeId} +
+ + + +