Добавил перекючатель редактирования и просмотра
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 { 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<DiagramDocument>(() => adapter.getInitialValue() ?? SAMPLE_DOCUMENT);
|
||||
const [doc, setDoc] = useState<DiagramDocument>(initialDoc);
|
||||
|
||||
const handleChange = useCallback((next: DiagramDocument) => {
|
||||
adapter.onChange(next);
|
||||
setDoc(next);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
return <DiagramEditor initialValue={initialDoc} onChange={handleChange} />;
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
+196
-6
@@ -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-shell">
|
||||
<div className="diagram-canvas">
|
||||
<NodesWithChildrenContext.Provider value={nodesWithChildren}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
width: 100%;
|
||||
.diagram-shell {
|
||||
display: flex;
|
||||
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 {
|
||||
position: relative;
|
||||
min-width: 120px;
|
||||
padding: 10px 16px;
|
||||
border: 1px solid #555;
|
||||
@@ -14,6 +90,22 @@
|
||||
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 {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
@@ -42,3 +134,119 @@
|
||||
justify-content: center;
|
||||
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 type { DiagramNodeData, NodeKind } from './types';
|
||||
|
||||
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() {
|
||||
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 (
|
||||
<div className="diagram-node diagram-node--task">
|
||||
<ShapeHandles />
|
||||
<SubProcessBadge id={id} />
|
||||
<span>{data.label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GatewayNode({ data }: NodeProps<ShapeNode>) {
|
||||
function GatewayNode({ id, data }: NodeProps<ShapeNode>) {
|
||||
return (
|
||||
<div className="diagram-node diagram-node--gateway">
|
||||
<ShapeHandles />
|
||||
<SubProcessBadge id={id} />
|
||||
<span>{data.label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EventNode({ data }: NodeProps<ShapeNode>) {
|
||||
function EventNode({ id, data }: NodeProps<ShapeNode>) {
|
||||
return (
|
||||
<div className="diagram-node diagram-node--event">
|
||||
<ShapeHandles />
|
||||
<SubProcessBadge id={id} />
|
||||
<span>{data.label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ export function serializeDocument(nodes: Node[], edges: Edge[]): DiagramDocument
|
||||
type: (node.type ?? 'task') as NodeKind,
|
||||
position: { x: node.position.x, y: node.position.y },
|
||||
data: node.data as DiagramNode['data'],
|
||||
...(node.parentId ? { parentId: node.parentId } : {}),
|
||||
}),
|
||||
),
|
||||
edges: edges.map(
|
||||
|
||||
+2
-4
@@ -1,14 +1,10 @@
|
||||
export type NodeKind = 'task' | 'gateway' | 'event';
|
||||
|
||||
export type NodeLinkType = 'description' | 'subDiagram';
|
||||
|
||||
export interface DiagramNodeData extends Record<string, unknown> {
|
||||
label: string;
|
||||
description?: string;
|
||||
responsible?: string;
|
||||
duration?: string;
|
||||
linkType?: NodeLinkType;
|
||||
subDiagramId?: string;
|
||||
}
|
||||
|
||||
export interface DiagramNode {
|
||||
@@ -16,6 +12,8 @@ export interface DiagramNode {
|
||||
type: NodeKind;
|
||||
position: { x: number; y: number };
|
||||
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> {
|
||||
|
||||
Reference in New Issue
Block a user