Доработки

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, 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
View File
@@ -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)} />
+13
View File
@@ -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
View File
@@ -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
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 { 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
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, * 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'],
}), }),
), ),
+4
View File
@@ -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 {