Доработки
This commit is contained in:
@@ -12,10 +12,12 @@ import {
|
|||||||
ViewportPortal,
|
ViewportPortal,
|
||||||
useReactFlow,
|
useReactFlow,
|
||||||
addEdge,
|
addEdge,
|
||||||
|
reconnectEdge,
|
||||||
applyNodeChanges,
|
applyNodeChanges,
|
||||||
applyEdgeChanges,
|
applyEdgeChanges,
|
||||||
type Node,
|
type Node,
|
||||||
type Edge,
|
type Edge,
|
||||||
|
type Connection,
|
||||||
type OnConnect,
|
type OnConnect,
|
||||||
type NodeChange,
|
type NodeChange,
|
||||||
type EdgeChange,
|
type EdgeChange,
|
||||||
@@ -132,6 +134,7 @@ function toRfNodes(doc: DiagramDocument | null): Node[] {
|
|||||||
data: node.data,
|
data: node.data,
|
||||||
width: node.width ?? defaults.width,
|
width: node.width ?? defaults.width,
|
||||||
height: node.height ?? defaults.height,
|
height: node.height ?? defaults.height,
|
||||||
|
zIndex: node.zIndex,
|
||||||
...(node.parentId ? { parentId: node.parentId } : {}),
|
...(node.parentId ? { parentId: node.parentId } : {}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -147,6 +150,7 @@ function toRfEdges(doc: DiagramDocument | null): Edge[] {
|
|||||||
type: edge.type ?? DEFAULT_EDGE_TYPE,
|
type: edge.type ?? DEFAULT_EDGE_TYPE,
|
||||||
markerEnd: DEFAULT_MARKER_END,
|
markerEnd: DEFAULT_MARKER_END,
|
||||||
label: edge.label,
|
label: edge.label,
|
||||||
|
zIndex: edge.zIndex,
|
||||||
data: edge.data,
|
data: edge.data,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -330,6 +334,17 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
|||||||
[nodes, edges, emitChange, pushHistory],
|
[nodes, edges, emitChange, pushHistory],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const onReconnect = useCallback(
|
||||||
|
(oldEdge: Edge, connection: Connection) => {
|
||||||
|
pushHistory(nodes, edges);
|
||||||
|
// Keep the original edge id stable so selection/history don't go stale mid-drag.
|
||||||
|
const next = reconnectEdge(oldEdge, connection, edges, { shouldReplaceId: false });
|
||||||
|
setEdges(next);
|
||||||
|
emitChange(nodes, next);
|
||||||
|
},
|
||||||
|
[nodes, edges, emitChange, pushHistory],
|
||||||
|
);
|
||||||
|
|
||||||
const onNodeClick: NodeMouseHandler = useCallback((_event, node) => {
|
const onNodeClick: NodeMouseHandler = useCallback((_event, node) => {
|
||||||
setSelectedNodeId(node.id);
|
setSelectedNodeId(node.id);
|
||||||
setSelectedEdgeId(null);
|
setSelectedEdgeId(null);
|
||||||
@@ -462,6 +477,53 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
|||||||
setSelectedEdgeId(null);
|
setSelectedEdgeId(null);
|
||||||
}, [selectedEdgeId, nodes, edges, emitChange, pushHistory]);
|
}, [selectedEdgeId, nodes, edges, emitChange, pushHistory]);
|
||||||
|
|
||||||
|
// Nodes and edges share one z-index space (zIndexMode="manual"), so front/back
|
||||||
|
// always means "above/below everything currently on the canvas", not just siblings.
|
||||||
|
const maxZIndex = useCallback(
|
||||||
|
() => Math.max(0, ...nodes.map((n) => n.zIndex ?? 0), ...edges.map((e) => e.zIndex ?? 0)),
|
||||||
|
[nodes, edges],
|
||||||
|
);
|
||||||
|
const minZIndex = useCallback(
|
||||||
|
() => Math.min(0, ...nodes.map((n) => n.zIndex ?? 0), ...edges.map((e) => e.zIndex ?? 0)),
|
||||||
|
[nodes, edges],
|
||||||
|
);
|
||||||
|
|
||||||
|
const bringSelectedNodeToFront = useCallback(() => {
|
||||||
|
if (!selectedNodeId) return;
|
||||||
|
const z = maxZIndex() + 1;
|
||||||
|
pushHistory(nodes, edges);
|
||||||
|
const next = nodes.map((n) => (n.id === selectedNodeId ? { ...n, zIndex: z } : n));
|
||||||
|
setNodes(next);
|
||||||
|
emitChange(next, edges);
|
||||||
|
}, [selectedNodeId, nodes, edges, emitChange, pushHistory, maxZIndex]);
|
||||||
|
|
||||||
|
const sendSelectedNodeToBack = useCallback(() => {
|
||||||
|
if (!selectedNodeId) return;
|
||||||
|
const z = minZIndex() - 1;
|
||||||
|
pushHistory(nodes, edges);
|
||||||
|
const next = nodes.map((n) => (n.id === selectedNodeId ? { ...n, zIndex: z } : n));
|
||||||
|
setNodes(next);
|
||||||
|
emitChange(next, edges);
|
||||||
|
}, [selectedNodeId, nodes, edges, emitChange, pushHistory, minZIndex]);
|
||||||
|
|
||||||
|
const bringSelectedEdgeToFront = useCallback(() => {
|
||||||
|
if (!selectedEdgeId) return;
|
||||||
|
const z = maxZIndex() + 1;
|
||||||
|
pushHistory(nodes, edges);
|
||||||
|
const next = edges.map((e) => (e.id === selectedEdgeId ? { ...e, zIndex: z } : e));
|
||||||
|
setEdges(next);
|
||||||
|
emitChange(nodes, next);
|
||||||
|
}, [selectedEdgeId, nodes, edges, emitChange, pushHistory, maxZIndex]);
|
||||||
|
|
||||||
|
const sendSelectedEdgeToBack = useCallback(() => {
|
||||||
|
if (!selectedEdgeId) return;
|
||||||
|
const z = minZIndex() - 1;
|
||||||
|
pushHistory(nodes, edges);
|
||||||
|
const next = edges.map((e) => (e.id === selectedEdgeId ? { ...e, zIndex: z } : e));
|
||||||
|
setEdges(next);
|
||||||
|
emitChange(nodes, next);
|
||||||
|
}, [selectedEdgeId, nodes, edges, emitChange, pushHistory, minZIndex]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onKeyDown(event: KeyboardEvent) {
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
if (mode !== 'edit' || isEditableTarget(event.target)) return;
|
if (mode !== 'edit' || isEditableTarget(event.target)) return;
|
||||||
@@ -500,6 +562,8 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
|||||||
onNodesChange={onNodesChange}
|
onNodesChange={onNodesChange}
|
||||||
onEdgesChange={onEdgesChange}
|
onEdgesChange={onEdgesChange}
|
||||||
onConnect={onConnect}
|
onConnect={onConnect}
|
||||||
|
onReconnect={onReconnect}
|
||||||
|
edgesReconnectable={mode === 'edit'}
|
||||||
onNodeClick={onNodeClick}
|
onNodeClick={onNodeClick}
|
||||||
onEdgeClick={onEdgeClick}
|
onEdgeClick={onEdgeClick}
|
||||||
onPaneClick={onPaneClick}
|
onPaneClick={onPaneClick}
|
||||||
@@ -508,6 +572,7 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
|||||||
deleteKeyCode={mode === 'edit' ? undefined : null}
|
deleteKeyCode={mode === 'edit' ? undefined : null}
|
||||||
zoomOnDoubleClick={false}
|
zoomOnDoubleClick={false}
|
||||||
connectionMode={ConnectionMode.Loose}
|
connectionMode={ConnectionMode.Loose}
|
||||||
|
zIndexMode="manual"
|
||||||
className={mode === 'view' ? 'diagram-flow--view-mode' : undefined}
|
className={mode === 'view' ? 'diagram-flow--view-mode' : undefined}
|
||||||
fitView
|
fitView
|
||||||
>
|
>
|
||||||
@@ -589,6 +654,8 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
|||||||
onEnterSubProcess={enterSubProcess}
|
onEnterSubProcess={enterSubProcess}
|
||||||
onDuplicate={duplicateSelectedNode}
|
onDuplicate={duplicateSelectedNode}
|
||||||
onDelete={deleteSelectedNode}
|
onDelete={deleteSelectedNode}
|
||||||
|
onBringToFront={bringSelectedNodeToFront}
|
||||||
|
onSendToBack={sendSelectedNodeToBack}
|
||||||
/>
|
/>
|
||||||
) : selectedEdge ? (
|
) : selectedEdge ? (
|
||||||
<EdgeInspector
|
<EdgeInspector
|
||||||
@@ -596,6 +663,8 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
|
|||||||
label={typeof selectedEdge.label === 'string' ? selectedEdge.label : ''}
|
label={typeof selectedEdge.label === 'string' ? selectedEdge.label : ''}
|
||||||
onChange={updateSelectedEdgeLabel}
|
onChange={updateSelectedEdgeLabel}
|
||||||
onDelete={deleteSelectedEdge}
|
onDelete={deleteSelectedEdge}
|
||||||
|
onBringToFront={bringSelectedEdgeToFront}
|
||||||
|
onSendToBack={sendSelectedEdgeToBack}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="diagram-aside__hint">
|
<p className="diagram-aside__hint">
|
||||||
|
|||||||
+19
-1
@@ -3,9 +3,18 @@ interface EdgeInspectorProps {
|
|||||||
label: string;
|
label: string;
|
||||||
onChange: (label: string) => void;
|
onChange: (label: string) => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
onBringToFront: () => void;
|
||||||
|
onSendToBack: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EdgeInspector({ edgeId, label, onChange, onDelete }: EdgeInspectorProps) {
|
export function EdgeInspector({
|
||||||
|
edgeId,
|
||||||
|
label,
|
||||||
|
onChange,
|
||||||
|
onDelete,
|
||||||
|
onBringToFront,
|
||||||
|
onSendToBack,
|
||||||
|
}: EdgeInspectorProps) {
|
||||||
return (
|
return (
|
||||||
<div className="node-inspector">
|
<div className="node-inspector">
|
||||||
<div className="node-inspector__header">
|
<div className="node-inspector__header">
|
||||||
@@ -19,6 +28,15 @@ export function EdgeInspector({ edgeId, label, onChange, onDelete }: EdgeInspect
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="node-inspector__toolbar">
|
||||||
|
<button type="button" onClick={onBringToFront}>
|
||||||
|
На передний план
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onSendToBack}>
|
||||||
|
На задний план
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label className="node-inspector__field">
|
<label className="node-inspector__field">
|
||||||
Подпись
|
Подпись
|
||||||
<input type="text" value={label} onChange={(e) => onChange(e.target.value)} />
|
<input type="text" value={label} onChange={(e) => onChange(e.target.value)} />
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ interface NodeInspectorProps {
|
|||||||
onEnterSubProcess: (nodeId: string) => void;
|
onEnterSubProcess: (nodeId: string) => void;
|
||||||
onDuplicate: () => void;
|
onDuplicate: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
onBringToFront: () => void;
|
||||||
|
onSendToBack: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NodeInspector({
|
export function NodeInspector({
|
||||||
@@ -33,6 +35,8 @@ export function NodeInspector({
|
|||||||
onEnterSubProcess,
|
onEnterSubProcess,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onBringToFront,
|
||||||
|
onSendToBack,
|
||||||
}: NodeInspectorProps) {
|
}: NodeInspectorProps) {
|
||||||
if (mode === 'view') {
|
if (mode === 'view') {
|
||||||
const hasMeta = Boolean(data.responsible || data.duration);
|
const hasMeta = Boolean(data.responsible || data.duration);
|
||||||
@@ -88,6 +92,15 @@ export function NodeInspector({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="node-inspector__toolbar">
|
||||||
|
<button type="button" onClick={onBringToFront}>
|
||||||
|
На передний план
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onSendToBack}>
|
||||||
|
На задний план
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label className="node-inspector__field node-inspector__field--color">
|
<label className="node-inspector__field node-inspector__field--color">
|
||||||
Цвет фона
|
Цвет фона
|
||||||
<input
|
<input
|
||||||
|
|||||||
+10
-13
@@ -181,26 +181,23 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/* Diamond via clip-path on an unrotated box — see the comment in nodes.tsx for why. */
|
||||||
* Inset to 1/sqrt(2) of the outer box and rotated 45deg: the diamond's tips
|
.diagram-node--diamond-bg {
|
||||||
* exactly reach the outer box's edges without the corners clipping outside it.
|
|
||||||
*/
|
|
||||||
.diagram-node--diamond {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
inset: 0;
|
||||||
left: 50%;
|
border: 1px solid #555;
|
||||||
width: 70.71%;
|
box-sizing: border-box;
|
||||||
height: 70.71%;
|
clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%);
|
||||||
padding: 0;
|
|
||||||
border-radius: 4px;
|
|
||||||
transform: translate(-50%, -50%) rotate(45deg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.diagram-node__icon {
|
.diagram-node__icon {
|
||||||
|
position: relative;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
transform: rotate(-45deg);
|
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
|||||||
+15
-39
@@ -1,4 +1,4 @@
|
|||||||
import { createContext, useContext, type CSSProperties } from 'react';
|
import { createContext, useContext } from 'react';
|
||||||
import { Handle, NodeResizer, 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';
|
||||||
|
|
||||||
@@ -42,33 +42,6 @@ function ShapeHandles() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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} />
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SubProcessBadge({ id }: { id: string }) {
|
function SubProcessBadge({ id }: { id: string }) {
|
||||||
const withChildren = useContext(NodesWithChildrenContext);
|
const withChildren = useContext(NodesWithChildrenContext);
|
||||||
if (!withChildren.has(id)) return null;
|
if (!withChildren.has(id)) return null;
|
||||||
@@ -145,19 +118,22 @@ function GatewayNode({ id, data, type, selected }: NodeProps<ShapeNode>) {
|
|||||||
<div className="diagram-node--gateway-outer">
|
<div className="diagram-node--gateway-outer">
|
||||||
<ShapeResizer selected={selected} minWidth={32} minHeight={32} />
|
<ShapeResizer selected={selected} minWidth={32} minHeight={32} />
|
||||||
{/*
|
{/*
|
||||||
The diamond is an inner square inset to 1/sqrt(2) of the outer box and
|
The diamond is drawn with clip-path on a plain (unrotated) box, not
|
||||||
rotated 45deg, so its tips exactly reach the outer box's edges without
|
`transform: rotate(45deg)` — a rotated element makes React Flow's
|
||||||
clipping. The caption sits on the (unrotated) outer box instead of the
|
getBoundingClientRect-based handle measurement pick up sub-pixel
|
||||||
diamond itself, so it renders upright below the shape.
|
rounding from the rotation, which showed up as a visible kink in
|
||||||
|
otherwise-straight horizontal/vertical edges. With clip-path, the
|
||||||
|
standard Position.Top/Right/Bottom/Left handles already land exactly
|
||||||
|
on the diamond's tips (they're at the box's edge midpoints either
|
||||||
|
way), so this reuses the same ShapeHandles as every other shape.
|
||||||
|
Handles/badge/caption are siblings of the clipped div, not children
|
||||||
|
of it, since clip-path would otherwise cut off anything of theirs
|
||||||
|
that renders outside the diamond outline.
|
||||||
*/}
|
*/}
|
||||||
<div
|
<div className="diagram-node--diamond-bg" style={{ background: data.color ?? DEFAULT_NODE_COLOR[kind] }} />
|
||||||
className="diagram-node diagram-node--diamond"
|
<ShapeHandles />
|
||||||
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 className="diagram-node__icon">{GATEWAY_ICON[kind]}</span>
|
||||||
<span className="diagram-node__caption">{data.label}</span>
|
<span className="diagram-node__caption">{data.label}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+3
-1
@@ -3,7 +3,7 @@ import type { DiagramDocument, DiagramEdge, DiagramNode, NodeKind } from './type
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* React Flow's runtime Node/Edge carry UI-only fields (measured, selected,
|
* React Flow's runtime Node/Edge carry UI-only fields (measured, selected,
|
||||||
* dragging, zIndex, ...). Only this subset gets persisted.
|
* dragging, ...). Only this subset gets persisted.
|
||||||
*/
|
*/
|
||||||
export function serializeDocument(nodes: Node[], edges: Edge[]): DiagramDocument {
|
export function serializeDocument(nodes: Node[], edges: Edge[]): DiagramDocument {
|
||||||
return {
|
return {
|
||||||
@@ -16,6 +16,7 @@ export function serializeDocument(nodes: Node[], edges: Edge[]): DiagramDocument
|
|||||||
...(node.parentId ? { parentId: node.parentId } : {}),
|
...(node.parentId ? { parentId: node.parentId } : {}),
|
||||||
...(typeof node.width === 'number' ? { width: node.width } : {}),
|
...(typeof node.width === 'number' ? { width: node.width } : {}),
|
||||||
...(typeof node.height === 'number' ? { height: node.height } : {}),
|
...(typeof node.height === 'number' ? { height: node.height } : {}),
|
||||||
|
...(typeof node.zIndex === 'number' ? { zIndex: node.zIndex } : {}),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
edges: edges.map(
|
edges: edges.map(
|
||||||
@@ -27,6 +28,7 @@ export function serializeDocument(nodes: Node[], edges: Edge[]): DiagramDocument
|
|||||||
...(edge.targetHandle ? { targetHandle: edge.targetHandle } : {}),
|
...(edge.targetHandle ? { targetHandle: edge.targetHandle } : {}),
|
||||||
type: edge.type,
|
type: edge.type,
|
||||||
...(typeof edge.label === 'string' && edge.label ? { label: edge.label } : {}),
|
...(typeof edge.label === 'string' && edge.label ? { label: edge.label } : {}),
|
||||||
|
...(typeof edge.zIndex === 'number' ? { zIndex: edge.zIndex } : {}),
|
||||||
data: edge.data as DiagramEdge['data'],
|
data: edge.data as DiagramEdge['data'],
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export interface DiagramNode {
|
|||||||
parentId?: string;
|
parentId?: string;
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
|
/** Manual stacking order — shared z-space with edges, higher draws on top. */
|
||||||
|
zIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiagramEdgeData extends Record<string, unknown> {}
|
export interface DiagramEdgeData extends Record<string, unknown> {}
|
||||||
@@ -38,6 +40,8 @@ export interface DiagramEdge {
|
|||||||
type?: string;
|
type?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
data?: DiagramEdgeData;
|
data?: DiagramEdgeData;
|
||||||
|
/** Manual stacking order — shared z-space with nodes, higher draws on top. */
|
||||||
|
zIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiagramDocument {
|
export interface DiagramDocument {
|
||||||
|
|||||||
Reference in New Issue
Block a user