yyd-flow-designer 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yyd-flow-designer",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "private": false,
5
5
  "description": "YYD Flow designer (process orchestration UI)",
6
6
  "keywords": [
@@ -33,7 +33,7 @@
33
33
  "peerDependencies": {
34
34
  "@ant-design/icons": ">=5",
35
35
  "@heroicons/react": ">=2",
36
- "@xyflow/react": ">=12",
36
+ "@xyflow/react": ">=12.9",
37
37
  "ahooks": ">=3",
38
38
  "antd": ">=5",
39
39
  "lodash": ">=4",
@@ -1,5 +1,37 @@
1
- import type { ConnectionLineComponentProps } from "@xyflow/react";
2
- import { getSmoothStepPath, Position } from "@xyflow/react";
1
+ import type { ConnectionLineComponentProps } from '@xyflow/react';
2
+ import { getSmoothStepPath, Position } from '@xyflow/react';
3
+
4
+ const BACK_RAIL_GAP = 64;
5
+ const STUB = 28;
6
+ const CORNER = 10;
7
+
8
+ const round = (n: number) => Math.round(n * 10) / 10;
9
+
10
+ const orthoPath = (points: Array<[number, number]>, radius = CORNER): string => {
11
+ if (points.length < 2) return '';
12
+ const pts = points.map(([x, y]) => [round(x), round(y)] as [number, number]);
13
+ let d = `M ${pts[0][0]} ${pts[0][1]}`;
14
+ for (let i = 1; i < pts.length - 1; i += 1) {
15
+ const [x0, y0] = pts[i - 1];
16
+ const [x1, y1] = pts[i];
17
+ const [x2, y2] = pts[i + 1];
18
+ const dx1 = x1 - x0;
19
+ const dy1 = y1 - y0;
20
+ const dx2 = x2 - x1;
21
+ const dy2 = y2 - y1;
22
+ const len1 = Math.hypot(dx1, dy1) || 1;
23
+ const len2 = Math.hypot(dx2, dy2) || 1;
24
+ const r = Math.min(radius, len1 / 2, len2 / 2);
25
+ const sx = x1 - (dx1 / len1) * r;
26
+ const sy = y1 - (dy1 / len1) * r;
27
+ const ex = x1 + (dx2 / len2) * r;
28
+ const ey = y1 + (dy2 / len2) * r;
29
+ d += ` L ${round(sx)} ${round(sy)} Q ${x1} ${y1} ${round(ex)} ${round(ey)}`;
30
+ }
31
+ const last = pts[pts.length - 1];
32
+ d += ` L ${last[0]} ${last[1]}`;
33
+ return d;
34
+ };
3
35
 
4
36
  const ConnectionLine = ({
5
37
  fromX,
@@ -9,34 +41,41 @@ const ConnectionLine = ({
9
41
  fromPosition = Position.Right,
10
42
  toPosition = Position.Left,
11
43
  }: ConnectionLineComponentProps) => {
12
- const [edgePath] = getSmoothStepPath({
13
- sourceX: fromX,
14
- sourceY: fromY,
15
- sourcePosition: fromPosition,
16
- targetX: toX,
17
- targetY: toY,
18
- targetPosition: toPosition,
19
- borderRadius: 14,
20
- offset: 18,
21
- });
44
+ const back = fromX > toX + 20;
45
+ let edgePath: string;
46
+ if (back) {
47
+ const railY = Math.max(fromY, toY) + BACK_RAIL_GAP;
48
+ edgePath = orthoPath([
49
+ [fromX, fromY],
50
+ [fromX + STUB, fromY],
51
+ [fromX + STUB, railY],
52
+ [toX - STUB, railY],
53
+ [toX - STUB, toY],
54
+ [toX, toY],
55
+ ]);
56
+ } else {
57
+ [edgePath] = getSmoothStepPath({
58
+ sourceX: fromX,
59
+ sourceY: fromY,
60
+ sourcePosition: fromPosition,
61
+ targetX: toX,
62
+ targetY: toY,
63
+ targetPosition: toPosition,
64
+ borderRadius: 12,
65
+ offset: 18,
66
+ });
67
+ }
22
68
 
23
69
  return (
24
70
  <g>
25
71
  <path
26
72
  fill="none"
27
- stroke={"#009688"}
73
+ stroke="#64748b"
28
74
  strokeWidth={2}
29
- className="animated"
75
+ strokeDasharray="6 4"
30
76
  d={edgePath}
31
77
  />
32
- <circle
33
- cx={toX}
34
- cy={toY}
35
- fill="#fff"
36
- r={2}
37
- stroke={"#009688"}
38
- strokeWidth={10}
39
- />
78
+ <circle cx={toX} cy={toY} fill="#fff" r={3} stroke="#64748b" strokeWidth={2} />
40
79
  </g>
41
80
  );
42
81
  };
@@ -2,7 +2,7 @@
2
2
 
3
3
  import {
4
4
  BaseEdge,
5
- getBezierPath,
5
+ getSmoothStepPath,
6
6
  type EdgeProps,
7
7
  } from '@xyflow/react';
8
8
  import { useMemo } from 'react';
@@ -19,13 +19,15 @@ export default function CustomEdge({
19
19
  selected,
20
20
  markerEnd,
21
21
  }: EdgeProps) {
22
- const [edgePath] = getBezierPath({
22
+ const [edgePath] = getSmoothStepPath({
23
23
  sourceX,
24
24
  sourceY,
25
25
  sourcePosition,
26
26
  targetX,
27
27
  targetY,
28
28
  targetPosition,
29
+ borderRadius: 10,
30
+ offset: 16,
29
31
  });
30
32
 
31
33
  // 计算路径长度用于动画
@@ -1,62 +1,246 @@
1
1
  'use client';
2
2
 
3
- import { BaseEdge, getBezierPath, type EdgeProps } from '@xyflow/react';
4
- import { useMemo } from 'react';
3
+ import {
4
+ BaseEdge,
5
+ EdgeLabelRenderer,
6
+ EdgeToolbar,
7
+ useReactFlow,
8
+ useStore,
9
+ type EdgeProps,
10
+ } from '@xyflow/react';
11
+ import { useCallback, useMemo, useRef } from 'react';
12
+ import { useFlowStore } from '../../../shared/flow/stores/useFlowStore';
13
+ import {
14
+ computeAutoWaypoints,
15
+ isBackEdgeXY,
16
+ waypointsToPath,
17
+ type EdgeRouteData,
18
+ type Waypoint,
19
+ } from './edgeRoute';
5
20
 
6
21
  const EDGE_STROKE = '#94a3b8';
7
22
  const EDGE_STROKE_SELECTED = '#6366f1';
8
- const BACK_RAIL_GAP = 28;
9
- const BACK_RAIL_STEP = 16;
10
-
11
- const round = (n: number) => Math.round(n * 10) / 10;
12
-
13
- const isBackEdge = (sourceX: number, targetX: number) => sourceX > targetX + 16;
14
-
15
- const getBackArcPath = (sx: number, sy: number, tx: number, ty: number, railY: number) => {
16
- const x1 = round(sx);
17
- const y1 = round(sy);
18
- const x2 = round(tx);
19
- const y2 = round(ty);
20
- const yRail = round(railY);
21
- const span = Math.max(80, x1 - x2);
22
- const c1x = round(x1 - span * 0.08);
23
- const c2x = round(x2 + span * 0.22);
24
- return `M ${x1} ${y1} C ${c1x} ${yRail}, ${c2x} ${yRail}, ${x2} ${y2}`;
23
+
24
+ const COLOR_PRESETS = [
25
+ '#94a3b8',
26
+ '#6366f1',
27
+ '#0ea5e9',
28
+ '#22c55e',
29
+ '#f59e0b',
30
+ '#ef4444',
31
+ '#a855f7',
32
+ '#0f172a',
33
+ ] as const;
34
+
35
+ /** 路径中点(按折线累计长度),label 贴在线中央 */
36
+ const pathMidpoint = (points: Waypoint[]): Waypoint => {
37
+ if (points.length < 2) {
38
+ return points[0] ?? { x: 0, y: 0 };
39
+ }
40
+ let total = 0;
41
+ const segs: number[] = [];
42
+ for (let i = 1; i < points.length; i += 1) {
43
+ const d = Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y);
44
+ segs.push(d);
45
+ total += d;
46
+ }
47
+ if (total <= 0) {
48
+ return points[0];
49
+ }
50
+ let remain = total / 2;
51
+ for (let i = 0; i < segs.length; i += 1) {
52
+ if (remain > segs[i]) {
53
+ remain -= segs[i];
54
+ continue;
55
+ }
56
+ const a = points[i];
57
+ const b = points[i + 1];
58
+ const t = segs[i] === 0 ? 0 : remain / segs[i];
59
+ return {
60
+ x: a.x + (b.x - a.x) * t,
61
+ y: a.y + (b.y - a.y) * t,
62
+ };
63
+ }
64
+ return points[points.length - 1];
25
65
  };
26
66
 
27
67
  export default function DefaultEdge({
28
68
  id,
69
+ target,
29
70
  sourceX,
30
71
  sourceY,
31
72
  targetX,
32
73
  targetY,
33
- sourcePosition,
34
- targetPosition,
35
74
  style = {},
36
75
  selected,
37
76
  data,
77
+ label,
38
78
  }: EdgeProps) {
39
- const back = isBackEdge(sourceX, targetX);
40
- const lane = back ? (id.charCodeAt(Math.max(0, id.length - 1)) % 4) : 0;
41
- const railY = Math.min(sourceY, targetY) - BACK_RAIL_GAP - lane * BACK_RAIL_STEP;
79
+ const { setEdges, screenToFlowPosition } = useReactFlow();
80
+ const lock = useFlowStore((s) => s.lock);
81
+ const dragging = useRef<number | null>(null);
82
+ const route = (data ?? {}) as EdgeRouteData;
83
+
84
+ const lane = useStore((state) => {
85
+ const ids = state.edges
86
+ .filter((e) => e.target === target)
87
+ .map((e) => e.id)
88
+ .sort((a, b) => a.localeCompare(b));
89
+ const idx = ids.indexOf(id);
90
+ return idx < 0 ? 0 : idx;
91
+ });
42
92
 
43
- const edgePath = useMemo(() => {
44
- if (back) {
45
- return getBackArcPath(sourceX, sourceY, targetX, targetY, railY);
93
+ const displayMids: Waypoint[] = useMemo(() => {
94
+ if (route.manual && Array.isArray(route.waypoints) && route.waypoints.length) {
95
+ return route.waypoints;
46
96
  }
47
- const [path] = getBezierPath({
48
- sourceX,
49
- sourceY,
50
- sourcePosition,
51
- targetX,
52
- targetY,
53
- targetPosition,
54
- });
55
- return path;
56
- }, [back, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, railY]);
97
+ return computeAutoWaypoints(sourceX, sourceY, targetX, targetY, lane);
98
+ }, [route.manual, route.waypoints, sourceX, sourceY, targetX, targetY, lane]);
99
+
100
+ const polyPoints = useMemo(
101
+ () => [{ x: sourceX, y: sourceY }, ...displayMids, { x: targetX, y: targetY }],
102
+ [sourceX, sourceY, targetX, targetY, displayMids],
103
+ );
104
+
105
+ const edgePath = useMemo(() => waypointsToPath(polyPoints), [polyPoints]);
106
+ const labelPos = useMemo(() => pathMidpoint(polyPoints), [polyPoints]);
107
+ const labelText = typeof label === 'string' ? label : '';
108
+ const customStroke = typeof style?.stroke === 'string' ? style.stroke : undefined;
109
+
110
+ const setEdgeLabel = useCallback(
111
+ (next: string) => {
112
+ setEdges((eds) =>
113
+ eds.map((e) => (e.id === id ? { ...e, label: next || undefined } : e)),
114
+ );
115
+ },
116
+ [id, setEdges],
117
+ );
118
+
119
+ const setEdgeStroke = useCallback(
120
+ (next: string | undefined) => {
121
+ setEdges((eds) =>
122
+ eds.map((e) => {
123
+ if (e.id !== id) return e;
124
+ const prevStyle = (e.style ?? {}) as Record<string, unknown>;
125
+ if (!next) {
126
+ const { stroke: _omit, ...rest } = prevStyle;
127
+ return { ...e, style: Object.keys(rest).length ? rest : undefined };
128
+ }
129
+ return { ...e, style: { ...prevStyle, stroke: next } };
130
+ }),
131
+ );
132
+ },
133
+ [id, setEdges],
134
+ );
135
+
136
+ const patchEdgeData = useCallback(
137
+ (patch: Partial<EdgeRouteData>) => {
138
+ setEdges((eds) =>
139
+ eds.map((e) =>
140
+ e.id === id
141
+ ? { ...e, data: { ...(e.data as object), ...patch } }
142
+ : e,
143
+ ),
144
+ );
145
+ },
146
+ [id, setEdges],
147
+ );
148
+
149
+ const commitWaypoints = useCallback(
150
+ (next: Waypoint[], manual = true) => {
151
+ patchEdgeData({ waypoints: next, manual });
152
+ },
153
+ [patchEdgeData],
154
+ );
155
+
156
+ const onHandlePointerDown = useCallback(
157
+ (index: number, e: React.PointerEvent) => {
158
+ if (lock) return;
159
+ e.preventDefault();
160
+ e.stopPropagation();
161
+ dragging.current = index;
162
+
163
+ const onMove = (ev: PointerEvent) => {
164
+ if (dragging.current !== index) return;
165
+ const p = screenToFlowPosition({ x: ev.clientX, y: ev.clientY });
166
+ const snapOrtho = ev.shiftKey;
167
+ setEdges((eds) =>
168
+ eds.map((edge) => {
169
+ if (edge.id !== id) return edge;
170
+ const prev = ((edge.data as EdgeRouteData)?.waypoints
171
+ ?? displayMids).map((w) => ({ ...w }));
172
+ if (!prev[index]) return edge;
173
+ let x = p.x;
174
+ let y = p.y;
175
+ if (snapOrtho) {
176
+ const left = index === 0
177
+ ? { x: sourceX, y: sourceY }
178
+ : prev[index - 1];
179
+ if (Math.abs(p.x - left.x) < Math.abs(p.y - left.y)) {
180
+ x = left.x;
181
+ } else {
182
+ y = left.y;
183
+ }
184
+ }
185
+ prev[index] = { x, y };
186
+ return {
187
+ ...edge,
188
+ data: { ...(edge.data as object), waypoints: prev, manual: true },
189
+ };
190
+ }),
191
+ );
192
+ };
193
+ const onUp = () => {
194
+ dragging.current = null;
195
+ window.removeEventListener('pointermove', onMove);
196
+ window.removeEventListener('pointerup', onUp);
197
+ };
198
+ window.addEventListener('pointermove', onMove);
199
+ window.addEventListener('pointerup', onUp);
200
+ },
201
+ [displayMids, id, lock, screenToFlowPosition, setEdges, sourceX, sourceY],
202
+ );
203
+
204
+ const onEdgeDoubleClick = useCallback(
205
+ (e: React.MouseEvent) => {
206
+ if (lock) return;
207
+ e.stopPropagation();
208
+ const p = screenToFlowPosition({ x: e.clientX, y: e.clientY });
209
+ const full = polyPoints;
210
+ let best = 0;
211
+ let bestDist = Infinity;
212
+ for (let i = 0; i < full.length - 1; i += 1) {
213
+ const a = full[i];
214
+ const b = full[i + 1];
215
+ const mx = (a.x + b.x) / 2;
216
+ const my = (a.y + b.y) / 2;
217
+ const d = Math.hypot(p.x - mx, p.y - my);
218
+ if (d < bestDist) {
219
+ bestDist = d;
220
+ best = i;
221
+ }
222
+ }
223
+ const next = [...displayMids];
224
+ next.splice(best, 0, { x: p.x, y: p.y });
225
+ commitWaypoints(next, true);
226
+ },
227
+ [commitWaypoints, displayMids, lock, polyPoints, screenToFlowPosition],
228
+ );
229
+
230
+ const onHandleContextMenu = useCallback(
231
+ (index: number, e: React.MouseEvent) => {
232
+ if (lock) return;
233
+ e.preventDefault();
234
+ e.stopPropagation();
235
+ commitWaypoints(displayMids.filter((_, i) => i !== index), true);
236
+ },
237
+ [commitWaypoints, displayMids, lock],
238
+ );
57
239
 
58
240
  const trailOn = Boolean((data as any)?.trailOn);
59
- const stroke = trailOn ? '#22c55e' : selected ? EDGE_STROKE_SELECTED : EDGE_STROKE;
241
+ const stroke = trailOn
242
+ ? '#22c55e'
243
+ : (customStroke ?? (selected ? EDGE_STROKE_SELECTED : EDGE_STROKE));
60
244
  const markerId = `bpmn-edge-arrow-${id}-${trailOn ? 'trail' : selected ? 'selected' : 'default'}`;
61
245
 
62
246
  return (
@@ -82,6 +266,14 @@ export default function DefaultEdge({
82
266
  />
83
267
  </marker>
84
268
  </defs>
269
+ <path
270
+ d={edgePath}
271
+ fill="none"
272
+ stroke="transparent"
273
+ strokeWidth={18}
274
+ style={{ cursor: 'pointer' }}
275
+ onDoubleClick={onEdgeDoubleClick}
276
+ />
85
277
  <BaseEdge
86
278
  id={id}
87
279
  path={edgePath}
@@ -91,7 +283,141 @@ export default function DefaultEdge({
91
283
  strokeWidth: trailOn || selected ? 2.5 : 2,
92
284
  }}
93
285
  markerEnd={`url(#${markerId})`}
286
+ {...(labelText
287
+ ? {
288
+ label: labelText,
289
+ labelX: labelPos.x,
290
+ labelY: labelPos.y,
291
+ }
292
+ : {})}
94
293
  />
294
+
295
+ {selected && !lock && (
296
+ <EdgeToolbar
297
+ edgeId={id}
298
+ x={labelPos.x}
299
+ y={labelPos.y}
300
+ alignX="center"
301
+ alignY="top"
302
+ className="nodrag nopan"
303
+ >
304
+ <div
305
+ style={{
306
+ display: 'flex',
307
+ alignItems: 'center',
308
+ gap: 8,
309
+ padding: '6px 8px',
310
+ borderRadius: 8,
311
+ background: '#fff',
312
+ border: '1px solid #e2e8f0',
313
+ boxShadow: '0 4px 14px rgba(15,23,42,0.12)',
314
+ pointerEvents: 'all',
315
+ }}
316
+ onClick={(e) => e.stopPropagation()}
317
+ onMouseDown={(e) => e.stopPropagation()}
318
+ >
319
+ <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
320
+ {COLOR_PRESETS.map((c) => {
321
+ const active = (customStroke ?? EDGE_STROKE) === c;
322
+ return (
323
+ <button
324
+ key={c}
325
+ type="button"
326
+ title={c}
327
+ onClick={() => setEdgeStroke(c === EDGE_STROKE ? undefined : c)}
328
+ style={{
329
+ width: 16,
330
+ height: 16,
331
+ borderRadius: 999,
332
+ border: active ? '2px solid #0f172a' : '1px solid #cbd5e1',
333
+ background: c,
334
+ padding: 0,
335
+ cursor: 'pointer',
336
+ }}
337
+ />
338
+ );
339
+ })}
340
+ <label
341
+ title="自定义颜色"
342
+ style={{
343
+ width: 16,
344
+ height: 16,
345
+ borderRadius: 999,
346
+ border: '1px solid #cbd5e1',
347
+ overflow: 'hidden',
348
+ cursor: 'pointer',
349
+ position: 'relative',
350
+ display: 'inline-block',
351
+ }}
352
+ >
353
+ <input
354
+ type="color"
355
+ value={customStroke ?? EDGE_STROKE}
356
+ onChange={(e) => setEdgeStroke(e.target.value)}
357
+ style={{
358
+ position: 'absolute',
359
+ inset: 0,
360
+ opacity: 0,
361
+ cursor: 'pointer',
362
+ border: 0,
363
+ padding: 0,
364
+ }}
365
+ />
366
+ <span
367
+ style={{
368
+ display: 'block',
369
+ width: '100%',
370
+ height: '100%',
371
+ background:
372
+ 'conic-gradient(#ef4444, #f59e0b, #22c55e, #0ea5e9, #6366f1, #a855f7, #ef4444)',
373
+ }}
374
+ />
375
+ </label>
376
+ </div>
377
+ <input
378
+ value={labelText}
379
+ maxLength={64}
380
+ placeholder="label"
381
+ onChange={(e) => setEdgeLabel(e.target.value)}
382
+ onKeyDown={(e) => e.stopPropagation()}
383
+ className="xy-edge__label"
384
+ style={{
385
+ width: 96,
386
+ padding: '2px 6px',
387
+ fontSize: 12,
388
+ border: '1px solid #e2e8f0',
389
+ borderRadius: 4,
390
+ outline: 'none',
391
+ }}
392
+ />
393
+ </div>
394
+ </EdgeToolbar>
395
+ )}
396
+
397
+ <EdgeLabelRenderer>
398
+ {selected && !lock && displayMids.map((wp, index) => (
399
+ <div
400
+ key={`${id}-wp-${index}`}
401
+ className="nodrag nopan"
402
+ style={{
403
+ position: 'absolute',
404
+ transform: `translate(-50%, -50%) translate(${wp.x}px, ${wp.y}px)`,
405
+ width: 10,
406
+ height: 10,
407
+ borderRadius: 999,
408
+ background: '#fff',
409
+ border: `2px solid ${EDGE_STROKE_SELECTED}`,
410
+ boxShadow: '0 0 0 1px rgba(99,102,241,0.25)',
411
+ cursor: 'move',
412
+ pointerEvents: 'all',
413
+ zIndex: 5,
414
+ }}
415
+ onPointerDown={(e) => onHandlePointerDown(index, e)}
416
+ onContextMenu={(e) => onHandleContextMenu(index, e)}
417
+ title="拖动调整折点(Shift 正交吸附);右键删除"
418
+ />
419
+ ))}
420
+ </EdgeLabelRenderer>
95
421
  </>
96
422
  );
97
423
  }