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

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
-31
View File
@@ -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
View File
@@ -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
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-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>
);
}
+119
View File
@@ -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
View File
@@ -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
View File
@@ -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>
);
+1
View File
@@ -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
View File
@@ -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> {