Добавление нового функционала
This commit is contained in:
+27
-5
@@ -5,7 +5,7 @@ import type { DiagramDocument } from './types';
|
|||||||
|
|
||||||
const SAMPLE_DOCUMENT: DiagramDocument = {
|
const SAMPLE_DOCUMENT: DiagramDocument = {
|
||||||
nodes: [
|
nodes: [
|
||||||
{ id: 'start', type: 'event', position: { x: 0, y: 80 }, data: { label: 'Заявка получена' } },
|
{ id: 'start', type: 'startEvent', position: { x: 0, y: 80 }, data: { label: 'Заявка получена' } },
|
||||||
{
|
{
|
||||||
id: 'review',
|
id: 'review',
|
||||||
type: 'task',
|
type: 'task',
|
||||||
@@ -17,7 +17,9 @@ const SAMPLE_DOCUMENT: DiagramDocument = {
|
|||||||
duration: '1-2 дня',
|
duration: '1-2 дня',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ id: 'decision', type: 'gateway', position: { x: 480, y: 68 }, data: { label: 'Одобрено?' } },
|
{ id: 'decision', type: 'exclusiveGateway', position: { x: 480, y: 68 }, data: { label: 'Одобрено?' } },
|
||||||
|
{ id: 'end-approved', type: 'endEvent', position: { x: 640, y: 40 }, data: { label: 'Заявка одобрена' } },
|
||||||
|
{ id: 'end-rejected', type: 'endEvent', position: { x: 640, y: 140 }, data: { label: 'Заявка отклонена' } },
|
||||||
{
|
{
|
||||||
id: 'review-check-docs',
|
id: 'review-check-docs',
|
||||||
type: 'task',
|
type: 'task',
|
||||||
@@ -34,9 +36,29 @@ const SAMPLE_DOCUMENT: DiagramDocument = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
edges: [
|
edges: [
|
||||||
{ id: 'start-review', source: 'start', target: 'review' },
|
{ id: 'start-review', source: 'start', target: 'review', sourceHandle: 'right', targetHandle: 'left' },
|
||||||
{ id: 'review-decision', source: 'review', target: 'decision' },
|
{ id: 'review-decision', source: 'review', target: 'decision', sourceHandle: 'right', targetHandle: 'left' },
|
||||||
{ id: 'review-check-docs-assign', source: 'review-check-docs', target: 'review-assign' },
|
{
|
||||||
|
id: 'decision-approved',
|
||||||
|
source: 'decision',
|
||||||
|
target: 'end-approved',
|
||||||
|
sourceHandle: 'right',
|
||||||
|
targetHandle: 'left',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'decision-rejected',
|
||||||
|
source: 'decision',
|
||||||
|
target: 'end-rejected',
|
||||||
|
sourceHandle: 'right',
|
||||||
|
targetHandle: 'left',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'review-check-docs-assign',
|
||||||
|
source: 'review-check-docs',
|
||||||
|
target: 'review-assign',
|
||||||
|
sourceHandle: 'right',
|
||||||
|
targetHandle: 'left',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+381
-60
@@ -1,11 +1,16 @@
|
|||||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
ReactFlowProvider,
|
ReactFlowProvider,
|
||||||
Background,
|
Background,
|
||||||
Controls,
|
Controls,
|
||||||
|
ControlButton,
|
||||||
MiniMap,
|
MiniMap,
|
||||||
Panel,
|
Panel,
|
||||||
|
ConnectionMode,
|
||||||
|
MarkerType,
|
||||||
|
ViewportPortal,
|
||||||
|
useReactFlow,
|
||||||
addEdge,
|
addEdge,
|
||||||
applyNodeChanges,
|
applyNodeChanges,
|
||||||
applyEdgeChanges,
|
applyEdgeChanges,
|
||||||
@@ -15,10 +20,12 @@ import {
|
|||||||
type NodeChange,
|
type NodeChange,
|
||||||
type EdgeChange,
|
type EdgeChange,
|
||||||
type NodeMouseHandler,
|
type NodeMouseHandler,
|
||||||
|
type EdgeMouseHandler,
|
||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
import '@xyflow/react/dist/style.css';
|
import '@xyflow/react/dist/style.css';
|
||||||
import { nodeTypes, NodesWithChildrenContext } from './nodes';
|
import { nodeTypes, NodesWithChildrenContext, ModeContext } from './nodes';
|
||||||
import { NodeInspector } from './NodeInspector';
|
import { NodeInspector } from './NodeInspector';
|
||||||
|
import { EdgeInspector } from './EdgeInspector';
|
||||||
import { debounce, serializeDocument } from './serialize';
|
import { debounce, serializeDocument } from './serialize';
|
||||||
import type { DiagramDocument, DiagramNodeData, NodeKind } from './types';
|
import type { DiagramDocument, DiagramNodeData, NodeKind } from './types';
|
||||||
import './diagram.css';
|
import './diagram.css';
|
||||||
@@ -28,20 +35,106 @@ interface DiagramEditorProps {
|
|||||||
onChange: (doc: DiagramDocument) => void;
|
onChange: (doc: DiagramDocument) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_EDGE_TYPE = 'smoothstep';
|
||||||
|
const DEFAULT_MARKER_END = { type: MarkerType.ArrowClosed };
|
||||||
|
const MAX_HISTORY = 50;
|
||||||
|
const TYPING_HISTORY_THROTTLE_MS = 1000;
|
||||||
|
|
||||||
const NODE_KINDS: { kind: NodeKind; label: string }[] = [
|
const NODE_KINDS: { kind: NodeKind; label: string }[] = [
|
||||||
{ kind: 'task', label: '+ Задача' },
|
{ kind: 'task', label: '+ Задача' },
|
||||||
{ kind: 'gateway', label: '+ Шлюз' },
|
{ kind: 'startEvent', label: '+ Начало' },
|
||||||
{ kind: 'event', label: '+ Событие' },
|
{ kind: 'intermediateEvent', label: '+ Промеж. событие' },
|
||||||
|
{ kind: 'endEvent', label: '+ Конец' },
|
||||||
|
{ kind: 'exclusiveGateway', label: '+ Искл. шлюз' },
|
||||||
|
{ kind: 'parallelGateway', label: '+ Паралл. шлюз' },
|
||||||
|
{ kind: 'inclusiveGateway', label: '+ Вкл. шлюз' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const DEFAULT_NODE_SIZE: Record<NodeKind, { width: number; height: number }> = {
|
||||||
|
task: { width: 140, height: 50 },
|
||||||
|
startEvent: { width: 48, height: 48 },
|
||||||
|
intermediateEvent: { width: 48, height: 48 },
|
||||||
|
endEvent: { width: 48, height: 48 },
|
||||||
|
exclusiveGateway: { width: 64, height: 64 },
|
||||||
|
parallelGateway: { width: 64, height: 64 },
|
||||||
|
inclusiveGateway: { width: 64, height: 64 },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AlignmentGuide {
|
||||||
|
axis: 'x' | 'y';
|
||||||
|
position: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Box {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SNAP_THRESHOLD = 6;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares the dragged box's edges/center against every other box on the same
|
||||||
|
* level and, for the first close-enough match on each axis, snaps to it and
|
||||||
|
* records a guide line to draw across the canvas.
|
||||||
|
*/
|
||||||
|
function computeSnapAndGuides(dragged: Box, others: Box[]): { x: number; y: number; guides: AlignmentGuide[] } {
|
||||||
|
const guides: AlignmentGuide[] = [];
|
||||||
|
let snappedX = dragged.x;
|
||||||
|
let snappedY = dragged.y;
|
||||||
|
let matchedX = false;
|
||||||
|
let matchedY = false;
|
||||||
|
|
||||||
|
for (const other of others) {
|
||||||
|
if (!matchedX) {
|
||||||
|
const xCandidates: [number, number][] = [
|
||||||
|
[dragged.x, other.x],
|
||||||
|
[dragged.x + dragged.width, other.x + other.width],
|
||||||
|
[dragged.x + dragged.width / 2, other.x + other.width / 2],
|
||||||
|
];
|
||||||
|
for (const [dValue, oValue] of xCandidates) {
|
||||||
|
if (Math.abs(dValue - oValue) <= SNAP_THRESHOLD) {
|
||||||
|
snappedX = dragged.x + (oValue - dValue);
|
||||||
|
guides.push({ axis: 'x', position: oValue });
|
||||||
|
matchedX = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!matchedY) {
|
||||||
|
const yCandidates: [number, number][] = [
|
||||||
|
[dragged.y, other.y],
|
||||||
|
[dragged.y + dragged.height, other.y + other.height],
|
||||||
|
[dragged.y + dragged.height / 2, other.y + other.height / 2],
|
||||||
|
];
|
||||||
|
for (const [dValue, oValue] of yCandidates) {
|
||||||
|
if (Math.abs(dValue - oValue) <= SNAP_THRESHOLD) {
|
||||||
|
snappedY = dragged.y + (oValue - dValue);
|
||||||
|
guides.push({ axis: 'y', position: oValue });
|
||||||
|
matchedY = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { x: snappedX, y: snappedY, guides };
|
||||||
|
}
|
||||||
|
|
||||||
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,
|
const defaults = DEFAULT_NODE_SIZE[node.type];
|
||||||
type: node.type,
|
return {
|
||||||
position: node.position,
|
id: node.id,
|
||||||
data: node.data,
|
type: node.type,
|
||||||
...(node.parentId ? { parentId: node.parentId } : {}),
|
position: node.position,
|
||||||
}));
|
data: node.data,
|
||||||
|
width: node.width ?? defaults.width,
|
||||||
|
height: node.height ?? defaults.height,
|
||||||
|
...(node.parentId ? { parentId: node.parentId } : {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function toRfEdges(doc: DiagramDocument | null): Edge[] {
|
function toRfEdges(doc: DiagramDocument | null): Edge[] {
|
||||||
@@ -49,24 +142,80 @@ function toRfEdges(doc: DiagramDocument | null): Edge[] {
|
|||||||
id: edge.id,
|
id: edge.id,
|
||||||
source: edge.source,
|
source: edge.source,
|
||||||
target: edge.target,
|
target: edge.target,
|
||||||
type: edge.type,
|
sourceHandle: edge.sourceHandle,
|
||||||
|
targetHandle: edge.targetHandle,
|
||||||
|
type: edge.type ?? DEFAULT_EDGE_TYPE,
|
||||||
|
markerEnd: DEFAULT_MARKER_END,
|
||||||
|
label: edge.label,
|
||||||
data: edge.data,
|
data: edge.data,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isEditableTarget(target: EventTarget | null): boolean {
|
||||||
|
if (!(target instanceof HTMLElement)) return false;
|
||||||
|
return target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
|
||||||
|
}
|
||||||
|
|
||||||
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 [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||||
|
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
||||||
const [focusNodeId, setFocusNodeId] = useState<string | null>(null);
|
const [focusNodeId, setFocusNodeId] = useState<string | null>(null);
|
||||||
const [mode, setMode] = useState<'edit' | 'view'>('edit');
|
const [mode, setMode] = useState<'edit' | 'view'>('edit');
|
||||||
|
const [alignmentGuides, setAlignmentGuides] = useState<AlignmentGuide[]>([]);
|
||||||
const nodeCounter = useRef(0);
|
const nodeCounter = useRef(0);
|
||||||
|
const historyRef = useRef<{ nodes: Node[]; edges: Edge[] }[]>([]);
|
||||||
|
const redoRef = useRef<{ nodes: Node[]; edges: Edge[] }[]>([]);
|
||||||
|
const lastTypingHistoryPushRef = useRef(0);
|
||||||
|
const clipboardRef = useRef<Node | null>(null);
|
||||||
|
const canvasWrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { screenToFlowPosition } = useReactFlow();
|
||||||
|
|
||||||
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 pushHistory = useCallback((prevNodes: Node[], prevEdges: Edge[]) => {
|
||||||
|
historyRef.current.push({ nodes: prevNodes, edges: prevEdges });
|
||||||
|
if (historyRef.current.length > MAX_HISTORY) historyRef.current.shift();
|
||||||
|
redoRef.current = [];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pushHistoryThrottled = useCallback(
|
||||||
|
(prevNodes: Node[], prevEdges: Edge[]) => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastTypingHistoryPushRef.current > TYPING_HISTORY_THROTTLE_MS) {
|
||||||
|
pushHistory(prevNodes, prevEdges);
|
||||||
|
}
|
||||||
|
lastTypingHistoryPushRef.current = now;
|
||||||
|
},
|
||||||
|
[pushHistory],
|
||||||
|
);
|
||||||
|
|
||||||
|
const undo = useCallback(() => {
|
||||||
|
const previous = historyRef.current.pop();
|
||||||
|
if (!previous) return;
|
||||||
|
redoRef.current.push({ nodes, edges });
|
||||||
|
setNodes(previous.nodes);
|
||||||
|
setEdges(previous.edges);
|
||||||
|
emitChange(previous.nodes, previous.edges);
|
||||||
|
setSelectedNodeId(null);
|
||||||
|
setSelectedEdgeId(null);
|
||||||
|
}, [nodes, edges, emitChange]);
|
||||||
|
|
||||||
|
const redo = useCallback(() => {
|
||||||
|
const next = redoRef.current.pop();
|
||||||
|
if (!next) return;
|
||||||
|
historyRef.current.push({ nodes, edges });
|
||||||
|
setNodes(next.nodes);
|
||||||
|
setEdges(next.edges);
|
||||||
|
emitChange(next.nodes, next.edges);
|
||||||
|
setSelectedNodeId(null);
|
||||||
|
setSelectedEdgeId(null);
|
||||||
|
}, [nodes, edges, emitChange]);
|
||||||
|
|
||||||
const nodesWithChildren = useMemo(() => {
|
const nodesWithChildren = useMemo(() => {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
@@ -102,112 +251,248 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
|||||||
const enterSubProcess = useCallback((nodeId: string) => {
|
const enterSubProcess = useCallback((nodeId: string) => {
|
||||||
setFocusNodeId(nodeId);
|
setFocusNodeId(nodeId);
|
||||||
setSelectedNodeId(null);
|
setSelectedNodeId(null);
|
||||||
|
setSelectedEdgeId(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const goToLevel = useCallback((nodeId: string | null) => {
|
const goToLevel = useCallback((nodeId: string | null) => {
|
||||||
setFocusNodeId(nodeId);
|
setFocusNodeId(nodeId);
|
||||||
setSelectedNodeId(null);
|
setSelectedNodeId(null);
|
||||||
|
setSelectedEdgeId(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onNodesChange = useCallback(
|
const onNodesChange = useCallback(
|
||||||
(changes: NodeChange[]) => {
|
(changes: NodeChange[]) => {
|
||||||
setNodes((current) => {
|
const withChildren = new Set(nodes.filter((n) => n.parentId).map((n) => n.parentId!));
|
||||||
const withChildren = new Set(current.filter((n) => n.parentId).map((n) => n.parentId!));
|
const filteredChanges = changes.filter((change) => {
|
||||||
const filteredChanges = changes.filter((change) => {
|
if (change.type !== 'remove' || !withChildren.has(change.id)) return true;
|
||||||
if (change.type !== 'remove' || !withChildren.has(change.id)) return true;
|
window.alert('Сначала удалите вложенные узлы — у этого узла есть под-процесс.');
|
||||||
window.alert('Сначала удалите вложенные узлы — у этого узла есть под-процесс.');
|
return false;
|
||||||
return false;
|
|
||||||
});
|
|
||||||
const next = applyNodeChanges(filteredChanges, current);
|
|
||||||
emitChange(next, edges);
|
|
||||||
return next;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const positionChanges = filteredChanges.filter((c) => c.type === 'position' && c.position);
|
||||||
|
let adjustedChanges = filteredChanges;
|
||||||
|
if (positionChanges.length === 1) {
|
||||||
|
const change = positionChanges[0];
|
||||||
|
const draggedNode = change.type === 'position' ? nodes.find((n) => n.id === change.id) : undefined;
|
||||||
|
if (change.type === 'position' && change.position && draggedNode) {
|
||||||
|
const level = draggedNode.parentId ?? null;
|
||||||
|
const others: Box[] = nodes
|
||||||
|
.filter((n) => n.id !== draggedNode.id && (n.parentId ?? null) === level)
|
||||||
|
.map((n) => ({ x: n.position.x, y: n.position.y, width: n.width ?? 0, height: n.height ?? 0 }));
|
||||||
|
const dragged: Box = {
|
||||||
|
x: change.position.x,
|
||||||
|
y: change.position.y,
|
||||||
|
width: draggedNode.width ?? 0,
|
||||||
|
height: draggedNode.height ?? 0,
|
||||||
|
};
|
||||||
|
const snapped = computeSnapAndGuides(dragged, others);
|
||||||
|
setAlignmentGuides(change.dragging === false ? [] : snapped.guides);
|
||||||
|
adjustedChanges = filteredChanges.map((c) =>
|
||||||
|
c === change ? { ...c, position: { x: snapped.x, y: snapped.y } } : c,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (alignmentGuides.length > 0) {
|
||||||
|
setAlignmentGuides([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isHistoryWorthy = filteredChanges.some(
|
||||||
|
(change) =>
|
||||||
|
change.type === 'remove' ||
|
||||||
|
(change.type === 'position' && change.dragging === false) ||
|
||||||
|
(change.type === 'dimensions' && change.resizing === false),
|
||||||
|
);
|
||||||
|
if (isHistoryWorthy) pushHistory(nodes, edges);
|
||||||
|
const next = applyNodeChanges(adjustedChanges, nodes);
|
||||||
|
setNodes(next);
|
||||||
|
emitChange(next, edges);
|
||||||
},
|
},
|
||||||
[edges, emitChange],
|
[nodes, edges, emitChange, pushHistory, alignmentGuides],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onEdgesChange = useCallback(
|
const onEdgesChange = useCallback(
|
||||||
(changes: EdgeChange[]) => {
|
(changes: EdgeChange[]) => {
|
||||||
setEdges((current) => {
|
const isHistoryWorthy = changes.some((change) => change.type === 'remove');
|
||||||
const next = applyEdgeChanges(changes, current);
|
if (isHistoryWorthy) pushHistory(nodes, edges);
|
||||||
emitChange(nodes, next);
|
const next = applyEdgeChanges(changes, edges);
|
||||||
return next;
|
setEdges(next);
|
||||||
});
|
emitChange(nodes, next);
|
||||||
},
|
},
|
||||||
[nodes, emitChange],
|
[nodes, edges, emitChange, pushHistory],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onConnect: OnConnect = useCallback(
|
const onConnect: OnConnect = useCallback(
|
||||||
(connection) => {
|
(connection) => {
|
||||||
setEdges((current) => {
|
pushHistory(nodes, edges);
|
||||||
const next = addEdge(connection, current);
|
const next = addEdge({ ...connection, type: DEFAULT_EDGE_TYPE, markerEnd: DEFAULT_MARKER_END }, edges);
|
||||||
emitChange(nodes, next);
|
setEdges(next);
|
||||||
return next;
|
emitChange(nodes, next);
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[nodes, emitChange],
|
[nodes, edges, emitChange, pushHistory],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onNodeClick: NodeMouseHandler = useCallback((_event, node) => {
|
const onNodeClick: NodeMouseHandler = useCallback((_event, node) => {
|
||||||
setSelectedNodeId(node.id);
|
setSelectedNodeId(node.id);
|
||||||
|
setSelectedEdgeId(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onNodeDoubleClick: NodeMouseHandler = useCallback(
|
const onEdgeClick: EdgeMouseHandler = useCallback(
|
||||||
(_event, node) => {
|
(_event, edge) => {
|
||||||
if (mode === 'view' && !nodesWithChildren.has(node.id)) return;
|
if (mode !== 'edit') return;
|
||||||
enterSubProcess(node.id);
|
setSelectedEdgeId(edge.id);
|
||||||
|
setSelectedNodeId(null);
|
||||||
},
|
},
|
||||||
[enterSubProcess, mode, nodesWithChildren],
|
[mode],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onPaneClick = useCallback(() => {
|
const onPaneClick = useCallback(() => {
|
||||||
setSelectedNodeId(null);
|
setSelectedNodeId(null);
|
||||||
|
setSelectedEdgeId(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateSelectedNodeData = useCallback(
|
const updateSelectedNodeData = useCallback(
|
||||||
(patch: Partial<DiagramNodeData>) => {
|
(patch: Partial<DiagramNodeData>) => {
|
||||||
if (!selectedNodeId) return;
|
if (!selectedNodeId) return;
|
||||||
setNodes((current) => {
|
pushHistoryThrottled(nodes, edges);
|
||||||
const next = current.map((node) =>
|
const next = nodes.map((node) =>
|
||||||
node.id === selectedNodeId ? { ...node, data: { ...node.data, ...patch } } : node,
|
node.id === selectedNodeId ? { ...node, data: { ...node.data, ...patch } } : node,
|
||||||
);
|
);
|
||||||
emitChange(next, edges);
|
setNodes(next);
|
||||||
return next;
|
emitChange(next, edges);
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[selectedNodeId, edges, emitChange],
|
[selectedNodeId, nodes, edges, emitChange, pushHistoryThrottled],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const updateSelectedEdgeLabel = useCallback(
|
||||||
|
(label: string) => {
|
||||||
|
if (!selectedEdgeId) return;
|
||||||
|
pushHistoryThrottled(nodes, edges);
|
||||||
|
const next = edges.map((edge) => (edge.id === selectedEdgeId ? { ...edge, label } : edge));
|
||||||
|
setEdges(next);
|
||||||
|
emitChange(nodes, next);
|
||||||
|
},
|
||||||
|
[selectedEdgeId, nodes, edges, emitChange, pushHistoryThrottled],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getViewportCenter = useCallback(() => {
|
||||||
|
const rect = canvasWrapperRef.current?.getBoundingClientRect();
|
||||||
|
if (!rect) return { x: 0, y: 0 };
|
||||||
|
return screenToFlowPosition({ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 });
|
||||||
|
}, [screenToFlowPosition]);
|
||||||
|
|
||||||
const addNode = useCallback(
|
const addNode = useCallback(
|
||||||
(kind: NodeKind) => {
|
(kind: NodeKind) => {
|
||||||
nodeCounter.current += 1;
|
nodeCounter.current += 1;
|
||||||
const id = `${kind}-${nodeCounter.current}`;
|
const id = `${kind}-${nodeCounter.current}`;
|
||||||
const offset = nodeCounter.current;
|
const center = getViewportCenter();
|
||||||
|
const fanOut = nodeCounter.current;
|
||||||
|
const size = DEFAULT_NODE_SIZE[kind];
|
||||||
const newNode: Node = {
|
const newNode: Node = {
|
||||||
id,
|
id,
|
||||||
type: kind,
|
type: kind,
|
||||||
position: { x: 40 + ((offset * 40) % 400), y: 320 + ((offset * 70) % 200) },
|
position: {
|
||||||
|
x: center.x + ((fanOut * 24) % 120) - 60,
|
||||||
|
y: center.y + ((fanOut * 18) % 90) - 45,
|
||||||
|
},
|
||||||
|
width: size.width,
|
||||||
|
height: size.height,
|
||||||
data: { label: NODE_KINDS.find((n) => n.kind === kind)!.label.replace('+ ', '') } as DiagramNodeData,
|
data: { label: NODE_KINDS.find((n) => n.kind === kind)!.label.replace('+ ', '') } as DiagramNodeData,
|
||||||
...(focusNodeId ? { parentId: focusNodeId } : {}),
|
...(focusNodeId ? { parentId: focusNodeId } : {}),
|
||||||
};
|
};
|
||||||
setNodes((current) => {
|
pushHistory(nodes, edges);
|
||||||
const next = [...current, newNode];
|
const next = [...nodes, newNode];
|
||||||
emitChange(next, edges);
|
setNodes(next);
|
||||||
return next;
|
emitChange(next, edges);
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[edges, emitChange, focusNodeId],
|
[nodes, edges, emitChange, focusNodeId, pushHistory, getViewportCenter],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const copySelectedNode = useCallback(() => {
|
||||||
|
const node = selectedNodeId ? nodes.find((n) => n.id === selectedNodeId) : undefined;
|
||||||
|
if (!node) return;
|
||||||
|
clipboardRef.current = node;
|
||||||
|
}, [selectedNodeId, nodes]);
|
||||||
|
|
||||||
|
const pasteClipboardNode = useCallback(() => {
|
||||||
|
const source = clipboardRef.current;
|
||||||
|
if (!source) return;
|
||||||
|
nodeCounter.current += 1;
|
||||||
|
const newId = `${source.type}-${nodeCounter.current}`;
|
||||||
|
const { parentId: _sourceParentId, selected: _sourceSelected, ...restSource } = source;
|
||||||
|
const newNode: Node = {
|
||||||
|
...restSource,
|
||||||
|
id: newId,
|
||||||
|
position: { x: source.position.x + 40, y: source.position.y + 40 },
|
||||||
|
data: { ...(source.data as DiagramNodeData) },
|
||||||
|
...(focusNodeId ? { parentId: focusNodeId } : {}),
|
||||||
|
};
|
||||||
|
pushHistory(nodes, edges);
|
||||||
|
const next = [...nodes, newNode];
|
||||||
|
setNodes(next);
|
||||||
|
emitChange(next, edges);
|
||||||
|
setSelectedNodeId(newId);
|
||||||
|
setSelectedEdgeId(null);
|
||||||
|
}, [nodes, edges, emitChange, focusNodeId, pushHistory]);
|
||||||
|
|
||||||
|
const duplicateSelectedNode = useCallback(() => {
|
||||||
|
copySelectedNode();
|
||||||
|
pasteClipboardNode();
|
||||||
|
}, [copySelectedNode, pasteClipboardNode]);
|
||||||
|
|
||||||
|
const deleteSelectedNode = useCallback(() => {
|
||||||
|
if (!selectedNodeId) return;
|
||||||
|
if (nodes.some((n) => n.parentId === selectedNodeId)) {
|
||||||
|
window.alert('Сначала удалите вложенные узлы — у этого узла есть под-процесс.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pushHistory(nodes, edges);
|
||||||
|
const nextNodes = nodes.filter((n) => n.id !== selectedNodeId);
|
||||||
|
const nextEdges = edges.filter((e) => e.source !== selectedNodeId && e.target !== selectedNodeId);
|
||||||
|
setNodes(nextNodes);
|
||||||
|
setEdges(nextEdges);
|
||||||
|
emitChange(nextNodes, nextEdges);
|
||||||
|
setSelectedNodeId(null);
|
||||||
|
}, [selectedNodeId, nodes, edges, emitChange, pushHistory]);
|
||||||
|
|
||||||
|
const deleteSelectedEdge = useCallback(() => {
|
||||||
|
if (!selectedEdgeId) return;
|
||||||
|
pushHistory(nodes, edges);
|
||||||
|
const nextEdges = edges.filter((e) => e.id !== selectedEdgeId);
|
||||||
|
setEdges(nextEdges);
|
||||||
|
emitChange(nodes, nextEdges);
|
||||||
|
setSelectedEdgeId(null);
|
||||||
|
}, [selectedEdgeId, nodes, edges, emitChange, pushHistory]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
|
if (mode !== 'edit' || isEditableTarget(event.target)) return;
|
||||||
|
if (!(event.ctrlKey || event.metaKey)) return;
|
||||||
|
const key = event.key.toLowerCase();
|
||||||
|
if (key === 'z') {
|
||||||
|
event.preventDefault();
|
||||||
|
undo();
|
||||||
|
} else if (key === 'c') {
|
||||||
|
event.preventDefault();
|
||||||
|
copySelectedNode();
|
||||||
|
} else if (key === 'v') {
|
||||||
|
event.preventDefault();
|
||||||
|
pasteClipboardNode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
|
}, [mode, undo, copySelectedNode, pasteClipboardNode]);
|
||||||
|
|
||||||
const selectedNode = selectedNodeId ? nodes.find((node) => node.id === selectedNodeId) : undefined;
|
const selectedNode = selectedNodeId ? nodes.find((node) => node.id === selectedNodeId) : undefined;
|
||||||
const selectedNodeChildCount = selectedNode
|
const selectedNodeChildCount = selectedNode
|
||||||
? nodes.filter((node) => node.parentId === selectedNode.id).length
|
? nodes.filter((node) => node.parentId === selectedNode.id).length
|
||||||
: 0;
|
: 0;
|
||||||
|
const selectedEdge = selectedEdgeId ? edges.find((edge) => edge.id === selectedEdgeId) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="diagram-shell">
|
<div className="diagram-shell">
|
||||||
<div className="diagram-canvas">
|
<div className="diagram-canvas" ref={canvasWrapperRef}>
|
||||||
<NodesWithChildrenContext.Provider value={nodesWithChildren}>
|
<NodesWithChildrenContext.Provider value={nodesWithChildren}>
|
||||||
|
<ModeContext.Provider value={mode}>
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={visibleNodes}
|
nodes={visibleNodes}
|
||||||
edges={visibleEdges}
|
edges={visibleEdges}
|
||||||
@@ -216,16 +501,42 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
|||||||
onEdgesChange={onEdgesChange}
|
onEdgesChange={onEdgesChange}
|
||||||
onConnect={onConnect}
|
onConnect={onConnect}
|
||||||
onNodeClick={onNodeClick}
|
onNodeClick={onNodeClick}
|
||||||
onNodeDoubleClick={onNodeDoubleClick}
|
onEdgeClick={onEdgeClick}
|
||||||
onPaneClick={onPaneClick}
|
onPaneClick={onPaneClick}
|
||||||
nodesDraggable={mode === 'edit'}
|
nodesDraggable={mode === 'edit'}
|
||||||
nodesConnectable={mode === 'edit'}
|
nodesConnectable={mode === 'edit'}
|
||||||
deleteKeyCode={mode === 'edit' ? undefined : null}
|
deleteKeyCode={mode === 'edit' ? undefined : null}
|
||||||
zoomOnDoubleClick={false}
|
zoomOnDoubleClick={false}
|
||||||
|
connectionMode={ConnectionMode.Loose}
|
||||||
|
className={mode === 'view' ? 'diagram-flow--view-mode' : undefined}
|
||||||
fitView
|
fitView
|
||||||
>
|
>
|
||||||
<Background />
|
<Background />
|
||||||
<Controls />
|
<ViewportPortal>
|
||||||
|
{alignmentGuides.map((guide, index) => (
|
||||||
|
<div
|
||||||
|
key={`${guide.axis}-${index}`}
|
||||||
|
className="diagram-alignment-guide"
|
||||||
|
style={
|
||||||
|
guide.axis === 'x'
|
||||||
|
? { left: guide.position, top: -10000, width: 1, height: 20000 }
|
||||||
|
: { top: guide.position, left: -10000, height: 1, width: 20000 }
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ViewportPortal>
|
||||||
|
<Controls>
|
||||||
|
{mode === 'edit' && (
|
||||||
|
<>
|
||||||
|
<ControlButton onClick={undo} disabled={historyRef.current.length === 0} title="Отменить (Ctrl+Z)">
|
||||||
|
↶
|
||||||
|
</ControlButton>
|
||||||
|
<ControlButton onClick={redo} disabled={redoRef.current.length === 0} title="Повторить">
|
||||||
|
↷
|
||||||
|
</ControlButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Controls>
|
||||||
<MiniMap />
|
<MiniMap />
|
||||||
<Panel position="top-right" className="diagram-mode-toggle">
|
<Panel position="top-right" className="diagram-mode-toggle">
|
||||||
<button type="button" disabled={mode === 'view'} onClick={() => setMode('view')}>
|
<button type="button" disabled={mode === 'view'} onClick={() => setMode('view')}>
|
||||||
@@ -262,6 +573,7 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
|||||||
))}
|
))}
|
||||||
</Panel>
|
</Panel>
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
|
</ModeContext.Provider>
|
||||||
</NodesWithChildrenContext.Provider>
|
</NodesWithChildrenContext.Provider>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -275,11 +587,20 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
|||||||
mode={mode}
|
mode={mode}
|
||||||
onChange={updateSelectedNodeData}
|
onChange={updateSelectedNodeData}
|
||||||
onEnterSubProcess={enterSubProcess}
|
onEnterSubProcess={enterSubProcess}
|
||||||
|
onDuplicate={duplicateSelectedNode}
|
||||||
|
onDelete={deleteSelectedNode}
|
||||||
|
/>
|
||||||
|
) : selectedEdge ? (
|
||||||
|
<EdgeInspector
|
||||||
|
edgeId={selectedEdge.id}
|
||||||
|
label={typeof selectedEdge.label === 'string' ? selectedEdge.label : ''}
|
||||||
|
onChange={updateSelectedEdgeLabel}
|
||||||
|
onDelete={deleteSelectedEdge}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="diagram-aside__hint">
|
<p className="diagram-aside__hint">
|
||||||
{mode === 'edit'
|
{mode === 'edit'
|
||||||
? 'Выберите узел на схеме, чтобы отредактировать его данные. Двойной клик по узлу — зайти внутрь как в под-процесс.'
|
? 'Выберите узел или связь на схеме — появятся кнопки «Дублировать»/«Удалить» и поля данных.'
|
||||||
: 'Кликните по узлу, чтобы увидеть описание. Если у узла есть вложенный процесс, появится кнопка перехода.'}
|
: 'Кликните по узлу, чтобы увидеть описание. Если у узла есть вложенный процесс, появится кнопка перехода.'}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
interface EdgeInspectorProps {
|
||||||
|
edgeId: string;
|
||||||
|
label: string;
|
||||||
|
onChange: (label: string) => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EdgeInspector({ edgeId, label, onChange, onDelete }: EdgeInspectorProps) {
|
||||||
|
return (
|
||||||
|
<div className="node-inspector">
|
||||||
|
<div className="node-inspector__header">
|
||||||
|
<span className="node-inspector__type">Связь</span>
|
||||||
|
<code className="node-inspector__id">{edgeId}</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="node-inspector__toolbar">
|
||||||
|
<button type="button" onClick={onDelete}>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="node-inspector__field">
|
||||||
|
Подпись
|
||||||
|
<input type="text" value={label} onChange={(e) => onChange(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+32
-5
@@ -1,9 +1,14 @@
|
|||||||
|
import { DEFAULT_NODE_COLOR } from './nodes';
|
||||||
import type { DiagramNodeData, NodeKind } from './types';
|
import type { DiagramNodeData, NodeKind } from './types';
|
||||||
|
|
||||||
const TYPE_LABELS: Record<NodeKind, string> = {
|
const TYPE_LABELS: Record<NodeKind, string> = {
|
||||||
task: 'Задача',
|
task: 'Задача',
|
||||||
gateway: 'Шлюз',
|
startEvent: 'Начальное событие',
|
||||||
event: 'Событие',
|
intermediateEvent: 'Промежуточное событие',
|
||||||
|
endEvent: 'Конечное событие',
|
||||||
|
exclusiveGateway: 'Исключающий шлюз',
|
||||||
|
parallelGateway: 'Параллельный шлюз',
|
||||||
|
inclusiveGateway: 'Включающий шлюз',
|
||||||
};
|
};
|
||||||
|
|
||||||
interface NodeInspectorProps {
|
interface NodeInspectorProps {
|
||||||
@@ -14,6 +19,8 @@ interface NodeInspectorProps {
|
|||||||
mode: 'edit' | 'view';
|
mode: 'edit' | 'view';
|
||||||
onChange: (patch: Partial<DiagramNodeData>) => void;
|
onChange: (patch: Partial<DiagramNodeData>) => void;
|
||||||
onEnterSubProcess: (nodeId: string) => void;
|
onEnterSubProcess: (nodeId: string) => void;
|
||||||
|
onDuplicate: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NodeInspector({
|
export function NodeInspector({
|
||||||
@@ -24,6 +31,8 @@ export function NodeInspector({
|
|||||||
mode,
|
mode,
|
||||||
onChange,
|
onChange,
|
||||||
onEnterSubProcess,
|
onEnterSubProcess,
|
||||||
|
onDuplicate,
|
||||||
|
onDelete,
|
||||||
}: NodeInspectorProps) {
|
}: NodeInspectorProps) {
|
||||||
if (mode === 'view') {
|
if (mode === 'view') {
|
||||||
const hasMeta = Boolean(data.responsible || data.duration);
|
const hasMeta = Boolean(data.responsible || data.duration);
|
||||||
@@ -70,10 +79,28 @@ export function NodeInspector({
|
|||||||
<code className="node-inspector__id">{nodeId}</code>
|
<code className="node-inspector__id">{nodeId}</code>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="node-inspector__toolbar">
|
||||||
|
<button type="button" onClick={onDuplicate}>
|
||||||
|
Дублировать
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onDelete}>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="node-inspector__field node-inspector__field--color">
|
||||||
|
Цвет фона
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={data.color ?? DEFAULT_NODE_COLOR[nodeType]}
|
||||||
|
onChange={(e) => onChange({ color: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
<label className="node-inspector__field">
|
<label className="node-inspector__field">
|
||||||
Название
|
Название
|
||||||
<input
|
<textarea
|
||||||
type="text"
|
rows={2}
|
||||||
value={data.label}
|
value={data.label}
|
||||||
onChange={(e) => onChange({ label: e.target.value })}
|
onChange={(e) => onChange({ label: e.target.value })}
|
||||||
/>
|
/>
|
||||||
@@ -111,7 +138,7 @@ export function NodeInspector({
|
|||||||
</button>
|
</button>
|
||||||
<p className="node-inspector__hint">
|
<p className="node-inspector__hint">
|
||||||
{childCount > 0
|
{childCount > 0
|
||||||
? `Внутри уже ${childCount} узл(ов). Двойной клик по узлу на схеме открывает то же самое.`
|
? `Внутри уже ${childCount} узл(ов).`
|
||||||
: 'Пока пусто — зайдя внутрь, можно добавить узлы детального разбора этого шага.'}
|
: 'Пока пусто — зайдя внутрь, можно добавить узлы детального разбора этого шага.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+128
-24
@@ -11,7 +11,9 @@
|
|||||||
|
|
||||||
.diagram-toolbar {
|
.diagram-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
max-width: 280px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.diagram-toolbar button {
|
.diagram-toolbar button {
|
||||||
@@ -28,6 +30,26 @@
|
|||||||
background: color-mix(in srgb, canvastext 8%, canvas);
|
background: color-mix(in srgb, canvastext 8%, canvas);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.diagram-flow--view-mode .react-flow__handle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* React Flow's built-in disabled styling only dims <svg> children (for the
|
||||||
|
* zoom/fit/lock icon buttons) — our undo/redo controls use plain text glyphs,
|
||||||
|
* so they need their own dimming or they'd look clickable while inert.
|
||||||
|
*/
|
||||||
|
.react-flow__controls-button:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagram-alignment-guide {
|
||||||
|
position: absolute;
|
||||||
|
background: #ff0072;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
.diagram-mode-toggle {
|
.diagram-mode-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
border: 1px solid #999;
|
border: 1px solid #999;
|
||||||
@@ -80,14 +102,24 @@
|
|||||||
|
|
||||||
.diagram-node {
|
.diagram-node {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-width: 120px;
|
width: 100%;
|
||||||
padding: 10px 16px;
|
height: 100%;
|
||||||
|
padding: 6px 10px;
|
||||||
border: 1px solid #555;
|
border: 1px solid #555;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: canvas;
|
background: canvas;
|
||||||
color: canvastext;
|
color: canvastext;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagram-node__label {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.diagram-node__badge {
|
.diagram-node__badge {
|
||||||
@@ -104,35 +136,74 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.diagram-node--gateway {
|
.diagram-node__caption {
|
||||||
width: 64px;
|
position: absolute;
|
||||||
height: 64px;
|
top: 100%;
|
||||||
min-width: 0;
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
margin-top: 4px;
|
||||||
|
max-width: 120px;
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: center;
|
||||||
|
color: canvastext;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagram-node--circle {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
display: flex;
|
border-radius: 50%;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 4px;
|
|
||||||
transform: rotate(45deg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.diagram-node--gateway span {
|
.diagram-node--startEvent {
|
||||||
|
border-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagram-node--endEvent {
|
||||||
|
border-width: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagram-node--intermediateEvent {
|
||||||
|
border-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagram-node--intermediateEvent::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 3px;
|
||||||
|
border: 1px solid #555;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagram-node--gateway-outer {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Inset to 1/sqrt(2) of the outer box and rotated 45deg: the diamond's tips
|
||||||
|
* exactly reach the outer box's edges without the corners clipping outside it.
|
||||||
|
*/
|
||||||
|
.diagram-node--diamond {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 70.71%;
|
||||||
|
height: 70.71%;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
transform: translate(-50%, -50%) rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagram-node__icon {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
transform: rotate(-45deg);
|
transform: rotate(-45deg);
|
||||||
font-size: 11px;
|
font-size: 18px;
|
||||||
}
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
.diagram-node--event {
|
|
||||||
width: 64px;
|
|
||||||
height: 64px;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.diagram-aside {
|
.diagram-aside {
|
||||||
@@ -177,6 +248,25 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.node-inspector__toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-inspector__toolbar button {
|
||||||
|
font: inherit;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid #999;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: canvas;
|
||||||
|
color: canvastext;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-inspector__toolbar button:hover {
|
||||||
|
background: color-mix(in srgb, canvastext 8%, canvas);
|
||||||
|
}
|
||||||
|
|
||||||
.node-inspector__type {
|
.node-inspector__type {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
@@ -204,6 +294,19 @@
|
|||||||
color: canvastext;
|
color: canvastext;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.node-inspector__field--color {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-inspector__field--color input[type='color'] {
|
||||||
|
width: 36px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.node-inspector__action {
|
.node-inspector__action {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
@@ -227,6 +330,7 @@
|
|||||||
.node-inspector__label {
|
.node-inspector__label {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.node-inspector__description {
|
.node-inspector__description {
|
||||||
|
|||||||
+127
-16
@@ -1,5 +1,5 @@
|
|||||||
import { createContext, useContext } from 'react';
|
import { createContext, useContext, type CSSProperties } from 'react';
|
||||||
import { Handle, Position, type Node, type NodeProps } from '@xyflow/react';
|
import { Handle, NodeResizer, 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>;
|
||||||
@@ -7,11 +7,64 @@ type ShapeNode = Node<DiagramNodeData, NodeKind>;
|
|||||||
/** Ids of nodes that currently have children (i.e. render as a drill-into-able sub-process). */
|
/** 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());
|
export const NodesWithChildrenContext = createContext<Set<string>>(new Set());
|
||||||
|
|
||||||
|
/** Current editor mode — resize handles and connection points only show in 'edit'. */
|
||||||
|
export const ModeContext = createContext<'edit' | 'view'>('edit');
|
||||||
|
|
||||||
|
export const DEFAULT_NODE_COLOR: Record<NodeKind, string> = {
|
||||||
|
task: '#eef2ff',
|
||||||
|
startEvent: '#f0fdf4',
|
||||||
|
intermediateEvent: '#fefce8',
|
||||||
|
endEvent: '#fef2f2',
|
||||||
|
exclusiveGateway: '#fff7ed',
|
||||||
|
parallelGateway: '#faf5ff',
|
||||||
|
inclusiveGateway: '#f0fdfa',
|
||||||
|
};
|
||||||
|
|
||||||
|
const HANDLE_POSITIONS: { id: string; position: Position }[] = [
|
||||||
|
{ id: 'top', position: Position.Top },
|
||||||
|
{ id: 'right', position: Position.Right },
|
||||||
|
{ id: 'bottom', position: Position.Bottom },
|
||||||
|
{ id: 'left', position: Position.Left },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single handle per side acts as both connection start and end
|
||||||
|
* (React Flow handles are connectable both ways by default) — paired with
|
||||||
|
* connectionMode="loose" on <ReactFlow> so any point can link to any point.
|
||||||
|
*/
|
||||||
function ShapeHandles() {
|
function ShapeHandles() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Handle type="target" position={Position.Left} />
|
{HANDLE_POSITIONS.map(({ id, position }) => (
|
||||||
<Handle type="source" position={Position.Right} />
|
<Handle key={id} id={id} type="source" position={position} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The diamond div is a rotated square (`transform: rotate(45deg)`). Handles
|
||||||
|
* placed at the square's own corners land on the diamond's tips once rotated
|
||||||
|
* — corners, not `Position.Top/Right/Bottom/Left` (those are edge-midpoints,
|
||||||
|
* which rotate onto the diamond's flat sides instead).
|
||||||
|
*/
|
||||||
|
const DIAMOND_HANDLES: { id: string; position: Position; style: CSSProperties }[] = [
|
||||||
|
{ id: 'top', position: Position.Top, style: { top: 0, left: 0, transform: 'translate(-50%, -50%)' } },
|
||||||
|
{ id: 'right', position: Position.Right, style: { top: 0, left: '100%', transform: 'translate(-50%, -50%)' } },
|
||||||
|
{
|
||||||
|
id: 'bottom',
|
||||||
|
position: Position.Bottom,
|
||||||
|
style: { top: '100%', left: '100%', transform: 'translate(-50%, -50%)' },
|
||||||
|
},
|
||||||
|
{ id: 'left', position: Position.Left, style: { top: '100%', left: 0, transform: 'translate(-50%, -50%)' } },
|
||||||
|
];
|
||||||
|
|
||||||
|
function DiamondHandles() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{DIAMOND_HANDLES.map(({ id, position, style }) => (
|
||||||
|
<Handle key={id} id={id} type="source" position={position} style={style} />
|
||||||
|
))}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -26,38 +79,96 @@ function SubProcessBadge({ id }: { id: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TaskNode({ id, data }: NodeProps<ShapeNode>) {
|
function ShapeResizer({
|
||||||
|
selected,
|
||||||
|
minWidth,
|
||||||
|
minHeight,
|
||||||
|
keepAspectRatio = true,
|
||||||
|
}: {
|
||||||
|
selected: boolean;
|
||||||
|
minWidth: number;
|
||||||
|
minHeight: number;
|
||||||
|
keepAspectRatio?: boolean;
|
||||||
|
}) {
|
||||||
|
const mode = useContext(ModeContext);
|
||||||
return (
|
return (
|
||||||
<div className="diagram-node diagram-node--task">
|
<NodeResizer
|
||||||
|
isVisible={selected && mode === 'edit'}
|
||||||
|
minWidth={minWidth}
|
||||||
|
minHeight={minHeight}
|
||||||
|
keepAspectRatio={keepAspectRatio}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskNode({ id, data, selected }: NodeProps<ShapeNode>) {
|
||||||
|
return (
|
||||||
|
<div className="diagram-node diagram-node--task" style={{ background: data.color ?? DEFAULT_NODE_COLOR.task }}>
|
||||||
|
<ShapeResizer selected={selected} minWidth={70} minHeight={32} keepAspectRatio={false} />
|
||||||
<ShapeHandles />
|
<ShapeHandles />
|
||||||
<SubProcessBadge id={id} />
|
<SubProcessBadge id={id} />
|
||||||
<span>{data.label}</span>
|
<span className="diagram-node__label">{data.label}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function GatewayNode({ id, data }: NodeProps<ShapeNode>) {
|
const EVENT_BORDER_CLASS: Record<'startEvent' | 'intermediateEvent' | 'endEvent', string> = {
|
||||||
|
startEvent: 'diagram-node--startEvent',
|
||||||
|
intermediateEvent: 'diagram-node--intermediateEvent',
|
||||||
|
endEvent: 'diagram-node--endEvent',
|
||||||
|
};
|
||||||
|
|
||||||
|
function EventNode({ id, data, type, selected }: NodeProps<ShapeNode>) {
|
||||||
|
const kind = (type as 'startEvent' | 'intermediateEvent' | 'endEvent') ?? 'startEvent';
|
||||||
return (
|
return (
|
||||||
<div className="diagram-node diagram-node--gateway">
|
<div
|
||||||
|
className={`diagram-node diagram-node--circle ${EVENT_BORDER_CLASS[kind]}`}
|
||||||
|
style={{ background: data.color ?? DEFAULT_NODE_COLOR[kind] }}
|
||||||
|
>
|
||||||
|
<ShapeResizer selected={selected} minWidth={24} minHeight={24} />
|
||||||
<ShapeHandles />
|
<ShapeHandles />
|
||||||
<SubProcessBadge id={id} />
|
<SubProcessBadge id={id} />
|
||||||
<span>{data.label}</span>
|
<span className="diagram-node__caption">{data.label}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EventNode({ id, data }: NodeProps<ShapeNode>) {
|
const GATEWAY_ICON: Record<'exclusiveGateway' | 'parallelGateway' | 'inclusiveGateway', string> = {
|
||||||
|
exclusiveGateway: '✕',
|
||||||
|
parallelGateway: '+',
|
||||||
|
inclusiveGateway: '○',
|
||||||
|
};
|
||||||
|
|
||||||
|
function GatewayNode({ id, data, type, selected }: NodeProps<ShapeNode>) {
|
||||||
|
const kind = (type as 'exclusiveGateway' | 'parallelGateway' | 'inclusiveGateway') ?? 'exclusiveGateway';
|
||||||
return (
|
return (
|
||||||
<div className="diagram-node diagram-node--event">
|
<div className="diagram-node--gateway-outer">
|
||||||
<ShapeHandles />
|
<ShapeResizer selected={selected} minWidth={32} minHeight={32} />
|
||||||
|
{/*
|
||||||
|
The diamond is an inner square inset to 1/sqrt(2) of the outer box and
|
||||||
|
rotated 45deg, so its tips exactly reach the outer box's edges without
|
||||||
|
clipping. The caption sits on the (unrotated) outer box instead of the
|
||||||
|
diamond itself, so it renders upright below the shape.
|
||||||
|
*/}
|
||||||
|
<div
|
||||||
|
className="diagram-node diagram-node--diamond"
|
||||||
|
style={{ background: data.color ?? DEFAULT_NODE_COLOR[kind] }}
|
||||||
|
>
|
||||||
|
<DiamondHandles />
|
||||||
|
<span className="diagram-node__icon">{GATEWAY_ICON[kind]}</span>
|
||||||
|
</div>
|
||||||
<SubProcessBadge id={id} />
|
<SubProcessBadge id={id} />
|
||||||
<span>{data.label}</span>
|
<span className="diagram-node__caption">{data.label}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const nodeTypes = {
|
export const nodeTypes = {
|
||||||
task: TaskNode,
|
task: TaskNode,
|
||||||
gateway: GatewayNode,
|
startEvent: EventNode,
|
||||||
event: EventNode,
|
intermediateEvent: EventNode,
|
||||||
|
endEvent: EventNode,
|
||||||
|
exclusiveGateway: GatewayNode,
|
||||||
|
parallelGateway: GatewayNode,
|
||||||
|
inclusiveGateway: GatewayNode,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export function serializeDocument(nodes: Node[], edges: Edge[]): DiagramDocument
|
|||||||
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 } : {}),
|
...(node.parentId ? { parentId: node.parentId } : {}),
|
||||||
|
...(typeof node.width === 'number' ? { width: node.width } : {}),
|
||||||
|
...(typeof node.height === 'number' ? { height: node.height } : {}),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
edges: edges.map(
|
edges: edges.map(
|
||||||
@@ -21,7 +23,10 @@ export function serializeDocument(nodes: Node[], edges: Edge[]): DiagramDocument
|
|||||||
id: edge.id,
|
id: edge.id,
|
||||||
source: edge.source,
|
source: edge.source,
|
||||||
target: edge.target,
|
target: edge.target,
|
||||||
|
...(edge.sourceHandle ? { sourceHandle: edge.sourceHandle } : {}),
|
||||||
|
...(edge.targetHandle ? { targetHandle: edge.targetHandle } : {}),
|
||||||
type: edge.type,
|
type: edge.type,
|
||||||
|
...(typeof edge.label === 'string' && edge.label ? { label: edge.label } : {}),
|
||||||
data: edge.data as DiagramEdge['data'],
|
data: edge.data as DiagramEdge['data'],
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
+16
-4
@@ -1,10 +1,18 @@
|
|||||||
export type NodeKind = 'task' | 'gateway' | 'event';
|
export type NodeKind =
|
||||||
|
| 'task'
|
||||||
|
| 'startEvent'
|
||||||
|
| 'intermediateEvent'
|
||||||
|
| 'endEvent'
|
||||||
|
| 'exclusiveGateway'
|
||||||
|
| 'parallelGateway'
|
||||||
|
| 'inclusiveGateway';
|
||||||
|
|
||||||
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;
|
||||||
|
color?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiagramNode {
|
export interface DiagramNode {
|
||||||
@@ -14,17 +22,21 @@ export interface DiagramNode {
|
|||||||
data: DiagramNodeData;
|
data: DiagramNodeData;
|
||||||
/** Node this one is nested under (BPMN sub-process style drill-down), same document. */
|
/** Node this one is nested under (BPMN sub-process style drill-down), same document. */
|
||||||
parentId?: string;
|
parentId?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiagramEdgeData extends Record<string, unknown> {
|
export interface DiagramEdgeData extends Record<string, unknown> {}
|
||||||
label?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DiagramEdge {
|
export interface DiagramEdge {
|
||||||
id: string;
|
id: string;
|
||||||
source: string;
|
source: string;
|
||||||
target: string;
|
target: string;
|
||||||
|
/** Which of the 4 connection points on each shape this edge is anchored to. */
|
||||||
|
sourceHandle?: string;
|
||||||
|
targetHandle?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
|
label?: string;
|
||||||
data?: DiagramEdgeData;
|
data?: DiagramEdgeData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user