Добавил перекючатель редактирования и просмотра
This commit is contained in:
-31
@@ -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;
|
|
||||||
}
|
|
||||||
+16
-15
@@ -2,7 +2,6 @@ import { useCallback, useState } from 'react';
|
|||||||
import { DiagramEditor } from './DiagramEditor';
|
import { DiagramEditor } from './DiagramEditor';
|
||||||
import { createDevAdapter } from './host/devAdapter';
|
import { createDevAdapter } from './host/devAdapter';
|
||||||
import type { DiagramDocument } from './types';
|
import type { DiagramDocument } from './types';
|
||||||
import './App.css';
|
|
||||||
|
|
||||||
const SAMPLE_DOCUMENT: DiagramDocument = {
|
const SAMPLE_DOCUMENT: DiagramDocument = {
|
||||||
nodes: [
|
nodes: [
|
||||||
@@ -16,14 +15,28 @@ const SAMPLE_DOCUMENT: DiagramDocument = {
|
|||||||
description: 'Приём заявок проводится ответственным лицом, занимает 1-2 дня.',
|
description: 'Приём заявок проводится ответственным лицом, занимает 1-2 дня.',
|
||||||
responsible: 'Иван Иванов',
|
responsible: 'Иван Иванов',
|
||||||
duration: '1-2 дня',
|
duration: '1-2 дня',
|
||||||
linkType: 'description',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ id: 'decision', type: 'gateway', position: { x: 480, y: 68 }, data: { label: 'Одобрено?' } },
|
{ 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: [
|
edges: [
|
||||||
{ id: 'start-review', source: 'start', target: 'review' },
|
{ id: 'start-review', source: 'start', target: 'review' },
|
||||||
{ id: 'review-decision', source: 'review', target: 'decision' },
|
{ 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() {
|
function App() {
|
||||||
const [initialDoc] = useState<DiagramDocument>(() => adapter.getInitialValue() ?? SAMPLE_DOCUMENT);
|
const [initialDoc] = useState<DiagramDocument>(() => adapter.getInitialValue() ?? SAMPLE_DOCUMENT);
|
||||||
const [doc, setDoc] = useState<DiagramDocument>(initialDoc);
|
|
||||||
|
|
||||||
const handleChange = useCallback((next: DiagramDocument) => {
|
const handleChange = useCallback((next: DiagramDocument) => {
|
||||||
adapter.onChange(next);
|
adapter.onChange(next);
|
||||||
setDoc(next);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return <DiagramEditor initialValue={initialDoc} onChange={handleChange} />;
|
||||||
<div className="app-shell">
|
|
||||||
<div className="app-canvas">
|
|
||||||
<DiagramEditor initialValue={initialDoc} onChange={handleChange} />
|
|
||||||
</div>
|
|
||||||
<aside className="app-inspector">
|
|
||||||
<h2>Текущий doc (для демо)</h2>
|
|
||||||
<pre>{JSON.stringify(doc, null, 2)}</pre>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
+208
-18
@@ -1,10 +1,11 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
ReactFlowProvider,
|
ReactFlowProvider,
|
||||||
Background,
|
Background,
|
||||||
Controls,
|
Controls,
|
||||||
MiniMap,
|
MiniMap,
|
||||||
|
Panel,
|
||||||
addEdge,
|
addEdge,
|
||||||
applyNodeChanges,
|
applyNodeChanges,
|
||||||
applyEdgeChanges,
|
applyEdgeChanges,
|
||||||
@@ -13,11 +14,13 @@ import {
|
|||||||
type OnConnect,
|
type OnConnect,
|
||||||
type NodeChange,
|
type NodeChange,
|
||||||
type EdgeChange,
|
type EdgeChange,
|
||||||
|
type NodeMouseHandler,
|
||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
import '@xyflow/react/dist/style.css';
|
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 { debounce, serializeDocument } from './serialize';
|
||||||
import type { DiagramDocument } from './types';
|
import type { DiagramDocument, DiagramNodeData, NodeKind } from './types';
|
||||||
import './diagram.css';
|
import './diagram.css';
|
||||||
|
|
||||||
interface DiagramEditorProps {
|
interface DiagramEditorProps {
|
||||||
@@ -25,12 +28,19 @@ interface DiagramEditorProps {
|
|||||||
onChange: (doc: DiagramDocument) => void;
|
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[] {
|
function toRfNodes(doc: DiagramDocument | null): Node[] {
|
||||||
return (doc?.nodes ?? []).map((node) => ({
|
return (doc?.nodes ?? []).map((node) => ({
|
||||||
id: node.id,
|
id: node.id,
|
||||||
type: node.type,
|
type: node.type,
|
||||||
position: node.position,
|
position: node.position,
|
||||||
data: node.data,
|
data: node.data,
|
||||||
|
...(node.parentId ? { parentId: node.parentId } : {}),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,16 +57,68 @@ function toRfEdges(doc: DiagramDocument | null): Edge[] {
|
|||||||
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 [focusNodeId, setFocusNodeId] = useState<string | null>(null);
|
||||||
|
const [mode, setMode] = useState<'edit' | 'view'>('edit');
|
||||||
|
const nodeCounter = useRef(0);
|
||||||
|
|
||||||
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 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(
|
const onNodesChange = useCallback(
|
||||||
(changes: NodeChange[]) => {
|
(changes: NodeChange[]) => {
|
||||||
setNodes((current) => {
|
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);
|
emitChange(next, edges);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
@@ -86,21 +148,149 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
|||||||
[nodes, emitChange],
|
[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 (
|
return (
|
||||||
<div className="diagram-canvas">
|
<div className="diagram-shell">
|
||||||
<ReactFlow
|
<div className="diagram-canvas">
|
||||||
nodes={nodes}
|
<NodesWithChildrenContext.Provider value={nodesWithChildren}>
|
||||||
edges={edges}
|
<ReactFlow
|
||||||
nodeTypes={nodeTypes}
|
nodes={visibleNodes}
|
||||||
onNodesChange={onNodesChange}
|
edges={visibleEdges}
|
||||||
onEdgesChange={onEdgesChange}
|
nodeTypes={nodeTypes}
|
||||||
onConnect={onConnect}
|
onNodesChange={onNodesChange}
|
||||||
fitView
|
onEdgesChange={onEdgesChange}
|
||||||
>
|
onConnect={onConnect}
|
||||||
<Background />
|
onNodeClick={onNodeClick}
|
||||||
<Controls />
|
onNodeDoubleClick={onNodeDoubleClick}
|
||||||
<MiniMap />
|
onPaneClick={onPaneClick}
|
||||||
</ReactFlow>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import type { DiagramNodeData, NodeKind } from './types';
|
||||||
|
|
||||||
|
const TYPE_LABELS: Record<NodeKind, string> = {
|
||||||
|
task: 'Задача',
|
||||||
|
gateway: 'Шлюз',
|
||||||
|
event: 'Событие',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface NodeInspectorProps {
|
||||||
|
nodeId: string;
|
||||||
|
nodeType: NodeKind;
|
||||||
|
data: DiagramNodeData;
|
||||||
|
childCount: number;
|
||||||
|
mode: 'edit' | 'view';
|
||||||
|
onChange: (patch: Partial<DiagramNodeData>) => 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 (
|
||||||
|
<div className="node-inspector">
|
||||||
|
<div className="node-inspector__header">
|
||||||
|
<span className="node-inspector__type">{TYPE_LABELS[nodeType]}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="node-inspector__label">{data.label}</h3>
|
||||||
|
|
||||||
|
{data.description && <p className="node-inspector__description">{data.description}</p>}
|
||||||
|
|
||||||
|
{hasMeta && (
|
||||||
|
<dl className="node-inspector__meta">
|
||||||
|
{data.responsible && (
|
||||||
|
<>
|
||||||
|
<dt>Ответственный</dt>
|
||||||
|
<dd>{data.responsible}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{data.duration && (
|
||||||
|
<>
|
||||||
|
<dt>Длительность</dt>
|
||||||
|
<dd>{data.duration}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{childCount > 0 && (
|
||||||
|
<button type="button" className="node-inspector__action" onClick={() => onEnterSubProcess(nodeId)}>
|
||||||
|
Открыть вложенный процесс →
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="node-inspector">
|
||||||
|
<div className="node-inspector__header">
|
||||||
|
<span className="node-inspector__type">{TYPE_LABELS[nodeType]}</span>
|
||||||
|
<code className="node-inspector__id">{nodeId}</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="node-inspector__field">
|
||||||
|
Название
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={data.label}
|
||||||
|
onChange={(e) => onChange({ label: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="node-inspector__field">
|
||||||
|
Описание
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={data.description ?? ''}
|
||||||
|
onChange={(e) => onChange({ description: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="node-inspector__field">
|
||||||
|
Ответственный
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={data.responsible ?? ''}
|
||||||
|
onChange={(e) => onChange({ responsible: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="node-inspector__field">
|
||||||
|
Длительность
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={data.duration ?? ''}
|
||||||
|
onChange={(e) => onChange({ duration: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="button" className="node-inspector__action" onClick={() => onEnterSubProcess(nodeId)}>
|
||||||
|
Войти в под-процесс →
|
||||||
|
</button>
|
||||||
|
<p className="node-inspector__hint">
|
||||||
|
{childCount > 0
|
||||||
|
? `Внутри уже ${childCount} узл(ов). Двойной клик по узлу на схеме открывает то же самое.`
|
||||||
|
: 'Пока пусто — зайдя внутрь, можно добавить узлы детального разбора этого шага.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+210
-2
@@ -1,9 +1,85 @@
|
|||||||
.diagram-canvas {
|
.diagram-shell {
|
||||||
width: 100%;
|
display: flex;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.diagram-canvas {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagram-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagram-toolbar button {
|
||||||
|
font: inherit;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid #999;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: canvas;
|
||||||
|
color: canvastext;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagram-toolbar button:hover {
|
||||||
|
background: color-mix(in srgb, canvastext 8%, canvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagram-mode-toggle {
|
||||||
|
display: flex;
|
||||||
|
border: 1px solid #999;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagram-mode-toggle button {
|
||||||
|
font: inherit;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: none;
|
||||||
|
background: canvas;
|
||||||
|
color: canvastext;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagram-mode-toggle button:disabled {
|
||||||
|
background: color-mix(in srgb, canvastext 12%, canvas);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagram-mode-toggle button:first-child {
|
||||||
|
border-right: 1px solid #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagram-breadcrumbs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagram-breadcrumbs button {
|
||||||
|
font: inherit;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagram-breadcrumbs button:disabled {
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.9;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.diagram-node {
|
.diagram-node {
|
||||||
|
position: relative;
|
||||||
min-width: 120px;
|
min-width: 120px;
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
border: 1px solid #555;
|
border: 1px solid #555;
|
||||||
@@ -14,6 +90,22 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.diagram-node__badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
right: -8px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid #555;
|
||||||
|
background: canvas;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.diagram-node--gateway {
|
.diagram-node--gateway {
|
||||||
width: 64px;
|
width: 64px;
|
||||||
height: 64px;
|
height: 64px;
|
||||||
@@ -42,3 +134,119 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.diagram-aside {
|
||||||
|
width: 340px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-left: 1px solid color-mix(in srgb, currentcolor 20%, transparent);
|
||||||
|
padding: 12px;
|
||||||
|
overflow: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagram-aside__hint {
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagram-aside__json summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagram-aside__json pre {
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-inspector {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-inspector__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-inspector__type {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-inspector__id {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-inspector__field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-inspector__field input,
|
||||||
|
.node-inspector__field textarea,
|
||||||
|
.node-inspector__field select {
|
||||||
|
font: inherit;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid #999;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: canvas;
|
||||||
|
color: canvastext;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-inspector__action {
|
||||||
|
font: inherit;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #999;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: canvas;
|
||||||
|
color: canvastext;
|
||||||
|
cursor: pointer;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-inspector__action:hover {
|
||||||
|
background: color-mix(in srgb, canvastext 8%, canvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-inspector__hint {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-inspector__label {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-inspector__description {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-inspector__meta {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 2px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-inspector__meta dt {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-inspector__meta dd {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|||||||
+20
-3
@@ -1,8 +1,12 @@
|
|||||||
|
import { createContext, useContext } from 'react';
|
||||||
import { Handle, Position, type Node, type NodeProps } from '@xyflow/react';
|
import { Handle, 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>;
|
||||||
|
|
||||||
|
/** 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());
|
||||||
|
|
||||||
function ShapeHandles() {
|
function ShapeHandles() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -12,28 +16,41 @@ function ShapeHandles() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TaskNode({ data }: NodeProps<ShapeNode>) {
|
function SubProcessBadge({ id }: { id: string }) {
|
||||||
|
const withChildren = useContext(NodesWithChildrenContext);
|
||||||
|
if (!withChildren.has(id)) return null;
|
||||||
|
return (
|
||||||
|
<span className="diagram-node__badge" title="Есть вложенные узлы — двойной клик, чтобы открыть">
|
||||||
|
⊞
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskNode({ id, data }: NodeProps<ShapeNode>) {
|
||||||
return (
|
return (
|
||||||
<div className="diagram-node diagram-node--task">
|
<div className="diagram-node diagram-node--task">
|
||||||
<ShapeHandles />
|
<ShapeHandles />
|
||||||
|
<SubProcessBadge id={id} />
|
||||||
<span>{data.label}</span>
|
<span>{data.label}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function GatewayNode({ data }: NodeProps<ShapeNode>) {
|
function GatewayNode({ id, data }: NodeProps<ShapeNode>) {
|
||||||
return (
|
return (
|
||||||
<div className="diagram-node diagram-node--gateway">
|
<div className="diagram-node diagram-node--gateway">
|
||||||
<ShapeHandles />
|
<ShapeHandles />
|
||||||
|
<SubProcessBadge id={id} />
|
||||||
<span>{data.label}</span>
|
<span>{data.label}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EventNode({ data }: NodeProps<ShapeNode>) {
|
function EventNode({ id, data }: NodeProps<ShapeNode>) {
|
||||||
return (
|
return (
|
||||||
<div className="diagram-node diagram-node--event">
|
<div className="diagram-node diagram-node--event">
|
||||||
<ShapeHandles />
|
<ShapeHandles />
|
||||||
|
<SubProcessBadge id={id} />
|
||||||
<span>{data.label}</span>
|
<span>{data.label}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export function serializeDocument(nodes: Node[], edges: Edge[]): DiagramDocument
|
|||||||
type: (node.type ?? 'task') as NodeKind,
|
type: (node.type ?? 'task') as NodeKind,
|
||||||
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 } : {}),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
edges: edges.map(
|
edges: edges.map(
|
||||||
|
|||||||
+2
-4
@@ -1,14 +1,10 @@
|
|||||||
export type NodeKind = 'task' | 'gateway' | 'event';
|
export type NodeKind = 'task' | 'gateway' | 'event';
|
||||||
|
|
||||||
export type NodeLinkType = 'description' | 'subDiagram';
|
|
||||||
|
|
||||||
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;
|
||||||
linkType?: NodeLinkType;
|
|
||||||
subDiagramId?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiagramNode {
|
export interface DiagramNode {
|
||||||
@@ -16,6 +12,8 @@ export interface DiagramNode {
|
|||||||
type: NodeKind;
|
type: NodeKind;
|
||||||
position: { x: number; y: number };
|
position: { x: number; y: number };
|
||||||
data: DiagramNodeData;
|
data: DiagramNodeData;
|
||||||
|
/** Node this one is nested under (BPMN sub-process style drill-down), same document. */
|
||||||
|
parentId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiagramEdgeData extends Record<string, unknown> {
|
export interface DiagramEdgeData extends Record<string, unknown> {
|
||||||
|
|||||||
Reference in New Issue
Block a user