Доработки

This commit is contained in:
NikitaKalitov
2026-07-15 09:16:04 +03:00
parent 8bbee2dc7e
commit be2504d60d
7 changed files with 133 additions and 54 deletions
+69
View File
@@ -12,10 +12,12 @@ import {
ViewportPortal,
useReactFlow,
addEdge,
reconnectEdge,
applyNodeChanges,
applyEdgeChanges,
type Node,
type Edge,
type Connection,
type OnConnect,
type NodeChange,
type EdgeChange,
@@ -132,6 +134,7 @@ function toRfNodes(doc: DiagramDocument | null): Node[] {
data: node.data,
width: node.width ?? defaults.width,
height: node.height ?? defaults.height,
zIndex: node.zIndex,
...(node.parentId ? { parentId: node.parentId } : {}),
};
});
@@ -147,6 +150,7 @@ function toRfEdges(doc: DiagramDocument | null): Edge[] {
type: edge.type ?? DEFAULT_EDGE_TYPE,
markerEnd: DEFAULT_MARKER_END,
label: edge.label,
zIndex: edge.zIndex,
data: edge.data,
}));
}
@@ -330,6 +334,17 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
[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) => {
setSelectedNodeId(node.id);
setSelectedEdgeId(null);
@@ -462,6 +477,53 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
setSelectedEdgeId(null);
}, [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(() => {
function onKeyDown(event: KeyboardEvent) {
if (mode !== 'edit' || isEditableTarget(event.target)) return;
@@ -500,6 +562,8 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onReconnect={onReconnect}
edgesReconnectable={mode === 'edit'}
onNodeClick={onNodeClick}
onEdgeClick={onEdgeClick}
onPaneClick={onPaneClick}
@@ -508,6 +572,7 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
deleteKeyCode={mode === 'edit' ? undefined : null}
zoomOnDoubleClick={false}
connectionMode={ConnectionMode.Loose}
zIndexMode="manual"
className={mode === 'view' ? 'diagram-flow--view-mode' : undefined}
fitView
>
@@ -589,6 +654,8 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
onEnterSubProcess={enterSubProcess}
onDuplicate={duplicateSelectedNode}
onDelete={deleteSelectedNode}
onBringToFront={bringSelectedNodeToFront}
onSendToBack={sendSelectedNodeToBack}
/>
) : selectedEdge ? (
<EdgeInspector
@@ -596,6 +663,8 @@ function DiagramCanvas({ initialValue, onChange }: DiagramEditorProps) {
label={typeof selectedEdge.label === 'string' ? selectedEdge.label : ''}
onChange={updateSelectedEdgeLabel}
onDelete={deleteSelectedEdge}
onBringToFront={bringSelectedEdgeToFront}
onSendToBack={sendSelectedEdgeToBack}
/>
) : (
<p className="diagram-aside__hint">
+19 -1
View File
@@ -3,9 +3,18 @@ interface EdgeInspectorProps {
label: string;
onChange: (label: string) => 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 (
<div className="node-inspector">
<div className="node-inspector__header">
@@ -19,6 +28,15 @@ export function EdgeInspector({ edgeId, label, onChange, onDelete }: EdgeInspect
</button>
</div>
<div className="node-inspector__toolbar">
<button type="button" onClick={onBringToFront}>
На передний план
</button>
<button type="button" onClick={onSendToBack}>
На задний план
</button>
</div>
<label className="node-inspector__field">
Подпись
<input type="text" value={label} onChange={(e) => onChange(e.target.value)} />
+13
View File
@@ -21,6 +21,8 @@ interface NodeInspectorProps {
onEnterSubProcess: (nodeId: string) => void;
onDuplicate: () => void;
onDelete: () => void;
onBringToFront: () => void;
onSendToBack: () => void;
}
export function NodeInspector({
@@ -33,6 +35,8 @@ export function NodeInspector({
onEnterSubProcess,
onDuplicate,
onDelete,
onBringToFront,
onSendToBack,
}: NodeInspectorProps) {
if (mode === 'view') {
const hasMeta = Boolean(data.responsible || data.duration);
@@ -88,6 +92,15 @@ export function NodeInspector({
</button>
</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">
Цвет фона
<input
+10 -13
View File
@@ -181,26 +181,23 @@
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
/*
* 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 {
/* Diamond via clip-path on an unrotated box — see the comment in nodes.tsx for why. */
.diagram-node--diamond-bg {
position: absolute;
top: 50%;
left: 50%;
width: 70.71%;
height: 70.71%;
padding: 0;
border-radius: 4px;
transform: translate(-50%, -50%) rotate(45deg);
inset: 0;
border: 1px solid #555;
box-sizing: border-box;
clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%);
}
.diagram-node__icon {
position: relative;
display: inline-block;
transform: rotate(-45deg);
font-size: 18px;
font-weight: 700;
line-height: 1;
+15 -39
View File
@@ -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 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 }) {
const withChildren = useContext(NodesWithChildrenContext);
if (!withChildren.has(id)) return null;
@@ -145,19 +118,22 @@ function GatewayNode({ id, data, type, selected }: NodeProps<ShapeNode>) {
<div className="diagram-node--gateway-outer">
<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.
The diamond is drawn with clip-path on a plain (unrotated) box, not
`transform: rotate(45deg)` — a rotated element makes React Flow's
getBoundingClientRect-based handle measurement pick up sub-pixel
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
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>
<div className="diagram-node--diamond-bg" style={{ background: data.color ?? DEFAULT_NODE_COLOR[kind] }} />
<ShapeHandles />
<SubProcessBadge id={id} />
<span className="diagram-node__icon">{GATEWAY_ICON[kind]}</span>
<span className="diagram-node__caption">{data.label}</span>
</div>
);
+3 -1
View File
@@ -3,7 +3,7 @@ import type { DiagramDocument, DiagramEdge, DiagramNode, NodeKind } from './type
/**
* 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 {
return {
@@ -16,6 +16,7 @@ export function serializeDocument(nodes: Node[], edges: Edge[]): DiagramDocument
...(node.parentId ? { parentId: node.parentId } : {}),
...(typeof node.width === 'number' ? { width: node.width } : {}),
...(typeof node.height === 'number' ? { height: node.height } : {}),
...(typeof node.zIndex === 'number' ? { zIndex: node.zIndex } : {}),
}),
),
edges: edges.map(
@@ -27,6 +28,7 @@ export function serializeDocument(nodes: Node[], edges: Edge[]): DiagramDocument
...(edge.targetHandle ? { targetHandle: edge.targetHandle } : {}),
type: edge.type,
...(typeof edge.label === 'string' && edge.label ? { label: edge.label } : {}),
...(typeof edge.zIndex === 'number' ? { zIndex: edge.zIndex } : {}),
data: edge.data as DiagramEdge['data'],
}),
),
+4
View File
@@ -24,6 +24,8 @@ export interface DiagramNode {
parentId?: string;
width?: number;
height?: number;
/** Manual stacking order — shared z-space with edges, higher draws on top. */
zIndex?: number;
}
export interface DiagramEdgeData extends Record<string, unknown> {}
@@ -38,6 +40,8 @@ export interface DiagramEdge {
type?: string;
label?: string;
data?: DiagramEdgeData;
/** Manual stacking order — shared z-space with nodes, higher draws on top. */
zIndex?: number;
}
export interface DiagramDocument {