@flowgram-vue/free-lines-plugin 0.2.0

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.
Files changed (38) hide show
  1. package/LICENSE +22 -0
  2. package/dist/index.cjs +1026 -0
  3. package/dist/index.cjs.map +1 -0
  4. package/dist/index.css +120 -0
  5. package/dist/index.css.map +1 -0
  6. package/dist/index.d.ts +269 -0
  7. package/dist/index.js +1013 -0
  8. package/dist/index.js.map +1 -0
  9. package/package.json +63 -0
  10. package/src/__tests__/__snapshots__/bezier-controls.spec.ts.snap +20 -0
  11. package/src/__tests__/bezier-controls.spec.ts +25 -0
  12. package/src/components/index.ts +8 -0
  13. package/src/components/workflow-line-render/arrow.ts +60 -0
  14. package/src/components/workflow-line-render/index.css +22 -0
  15. package/src/components/workflow-line-render/index.ts +6 -0
  16. package/src/components/workflow-line-render/line-svg.ts +131 -0
  17. package/src/components/workflow-port-render/cross-hair.ts +16 -0
  18. package/src/components/workflow-port-render/index.css +121 -0
  19. package/src/components/workflow-port-render/index.ts +183 -0
  20. package/src/constants/lines.ts +9 -0
  21. package/src/constants/points.ts +12 -0
  22. package/src/contributions/bezier/bezier-controls.ts +105 -0
  23. package/src/contributions/bezier/index.ts +121 -0
  24. package/src/contributions/fold/fold-line.ts +293 -0
  25. package/src/contributions/fold/index.ts +97 -0
  26. package/src/contributions/index.ts +8 -0
  27. package/src/contributions/straight/index.ts +89 -0
  28. package/src/contributions/straight/point-on-line.ts +37 -0
  29. package/src/contributions/utils.ts +37 -0
  30. package/src/create-free-lines-plugin.ts +44 -0
  31. package/src/css.d.ts +6 -0
  32. package/src/env.d.ts +10 -0
  33. package/src/index.ts +12 -0
  34. package/src/layer/index.ts +8 -0
  35. package/src/layer/workflow-lines-layer.ts +201 -0
  36. package/src/type.ts +40 -0
  37. package/src/types/arrow-renderer.ts +32 -0
  38. package/src/wrap-layer-render.ts +38 -0
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
3
+ * SPDX-License-Identifier: MIT
4
+ */
5
+
6
+ .workflow-port-render {
7
+ width: 20px;
8
+ height: 20px;
9
+ border-radius: 50%;
10
+ margin-top: -10px;
11
+ margin-left: -10px;
12
+ left: 50%;
13
+ top: 50%;
14
+ position: absolute;
15
+ border: none;
16
+ }
17
+
18
+ .workflow-port-render > .symbol {
19
+ opacity: 0;
20
+ }
21
+
22
+ .workflow-port-render .bg-circle {
23
+ display: flex;
24
+ align-items: center;
25
+ justify-content: center;
26
+ position: absolute;
27
+ border-radius: 50%;
28
+ width: 20px;
29
+ height: 20px;
30
+ background-color: var(--g-workflow-port-color-background, #fff);
31
+ transform: scale(0.5);
32
+ transition: all 0.2s linear 0s;
33
+ }
34
+
35
+ .workflow-port-render .bg {
36
+ display: flex;
37
+ align-items: center;
38
+ justify-content: center;
39
+ position: relative;
40
+ width: 100%;
41
+ height: 100%;
42
+ border-radius: 50%;
43
+ background: var(--g-workflow-port-color-secondary, #9197f1);
44
+ transform: scale(0.4, 0.4);
45
+ transition: all 0.2s linear 0s;
46
+ }
47
+
48
+ .workflow-port-render .bg.hasError {
49
+ background: var(--g-workflow-port-color-error, red);
50
+ }
51
+
52
+ .workflow-port-render .bg .symbol {
53
+ position: absolute;
54
+ width: 14px;
55
+ height: 14px;
56
+ opacity: 0;
57
+ pointer-events: none;
58
+ color: var(--g-workflow-port-color-background, #fff);
59
+ transition: opacity 0.2s linear 0s;
60
+ }
61
+
62
+ .workflow-port-render .bg .symbol > svg {
63
+ width: 14px;
64
+ height: 14px;
65
+ }
66
+
67
+ .workflow-port-render .focus-circle {
68
+ position: absolute;
69
+ display: flex;
70
+ justify-content: center;
71
+ align-items: center;
72
+ width: 8px;
73
+ height: 8px;
74
+ opacity: 0;
75
+ background: var(--g-workflow-port-color-secondary, #9197f1);
76
+ border-radius: 50%;
77
+ transition: opacity 0.2s linear 0s;
78
+ }
79
+
80
+ .workflow-port-render.linked .bg:not(.hasError) {
81
+ background: var(--g-workflow-port-color-primary, #4d53e8);
82
+ }
83
+
84
+ .workflow-port-render.hovered .bg:not(.hasError) {
85
+ border: none;
86
+ cursor: crosshair;
87
+ transform: scale(1, 1);
88
+ background: var(--g-workflow-port-color-primary, #4d53e8);
89
+ }
90
+
91
+ .workflow-port-render.hovered .bg:not(.hasError) > .symbol {
92
+ opacity: 1;
93
+ }
94
+
95
+ .workflow-port-render .cross-hair {
96
+ position: relative;
97
+ left: 2px;
98
+ top: 2px;
99
+ }
100
+
101
+ .workflow-port-render .cross-hair::after,
102
+ .workflow-port-render .cross-hair::before {
103
+ content: '';
104
+ background: var(--g-workflow-port-color-background, #fff);
105
+ border-radius: 2px;
106
+ position: absolute;
107
+ }
108
+
109
+ .workflow-port-render .cross-hair::after {
110
+ left: 4px;
111
+ width: 2px;
112
+ height: 6px;
113
+ box-shadow: 0 4px var(--g-workflow-port-color-background, #fff);
114
+ }
115
+
116
+ .workflow-port-render .cross-hair::before {
117
+ top: 4px;
118
+ width: 6px;
119
+ height: 2px;
120
+ box-shadow: 4px 0 var(--g-workflow-port-color-background, #fff);
121
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
3
+ * SPDX-License-Identifier: MIT
4
+ */
5
+
6
+ import {
7
+ defineComponent,
8
+ h,
9
+ onBeforeUnmount,
10
+ shallowRef,
11
+ Teleport,
12
+ watch,
13
+ type CSSProperties,
14
+ type PropType,
15
+ } from 'vue';
16
+ import classNames from 'clsx';
17
+ import {
18
+ WorkflowHoverService,
19
+ type WorkflowPortEntity,
20
+ usePlaygroundReadonlyState,
21
+ WorkflowLinesManager,
22
+ } from '@flowgram-vue/free-layout-core';
23
+ import { MouseTouchEvent, useService } from '@flowgram-vue/core';
24
+ import type { Disposable } from '@flowgram-vue/utils';
25
+
26
+ import { PORT_BG_CLASS_NAME } from '../../constants/points';
27
+ import CrossHair from './cross-hair';
28
+ import './index.css';
29
+
30
+ export interface WorkflowPortRenderProps {
31
+ entity: WorkflowPortEntity;
32
+ className?: string;
33
+ style?: CSSProperties;
34
+ onClick?: (e: MouseEvent, port: WorkflowPortEntity) => void;
35
+ /** 激活状态颜色 (linked/hovered) */
36
+ primaryColor?: string;
37
+ /** 默认状态颜色 */
38
+ secondaryColor?: string;
39
+ /** 错误状态颜色 */
40
+ errorColor?: string;
41
+ /** 背景颜色 */
42
+ backgroundColor?: string;
43
+ }
44
+
45
+ export const WorkflowPortRender = defineComponent({
46
+ name: 'WorkflowPortRender',
47
+ props: {
48
+ entity: { type: Object as PropType<WorkflowPortEntity>, required: true },
49
+ className: { type: String, default: '' },
50
+ style: { type: Object as PropType<CSSProperties> },
51
+ onClick: {
52
+ type: Function as PropType<(e: MouseEvent, port: WorkflowPortEntity) => void>,
53
+ },
54
+ primaryColor: { type: String },
55
+ secondaryColor: { type: String },
56
+ errorColor: { type: String },
57
+ backgroundColor: { type: String },
58
+ },
59
+ setup(props) {
60
+ const hoverService = useService<WorkflowHoverService>(WorkflowHoverService);
61
+ const linesManager = useService<WorkflowLinesManager>(WorkflowLinesManager);
62
+ const targetElement = shallowRef(props.entity.targetElement);
63
+ const posX = shallowRef(props.entity.relativePosition.x);
64
+ const posY = shallowRef(props.entity.relativePosition.y);
65
+ const hovered = shallowRef(false);
66
+ const linked = shallowRef(Boolean(props.entity?.lines?.length));
67
+ const hasError = shallowRef(props.entity.hasError);
68
+ const readonly = usePlaygroundReadonlyState();
69
+
70
+ let disposers: Disposable[] = [];
71
+
72
+ const bindEntity = (): void => {
73
+ disposers.forEach((dispose) => dispose.dispose());
74
+ disposers = [];
75
+ const { entity } = props;
76
+ entity.validate();
77
+ hasError.value = entity.hasError;
78
+ disposers.push(
79
+ entity.onEntityChange(() => {
80
+ if (entity.targetElement) {
81
+ if (entity.targetElement !== targetElement.value) {
82
+ targetElement.value = entity.targetElement;
83
+ }
84
+ return;
85
+ }
86
+ const newPos = entity.relativePosition;
87
+ posX.value = Math.round(newPos.x);
88
+ posY.value = Math.round(newPos.y);
89
+ }),
90
+ hoverService.onHoveredChange(() => {
91
+ hovered.value = hoverService.isHovered(entity.id);
92
+ }),
93
+ entity.onErrorChanged(() => {
94
+ hasError.value = entity.hasError;
95
+ }),
96
+ linesManager.onAvailableLinesChange(() => {
97
+ setTimeout(() => {
98
+ if (linesManager.disposed || entity.disposed) return;
99
+ linked.value = Boolean(entity.lines.length);
100
+ }, 0);
101
+ })
102
+ );
103
+ };
104
+
105
+ watch(
106
+ () => [props.entity, targetElement.value],
107
+ () => bindEntity(),
108
+ { immediate: true }
109
+ );
110
+
111
+ onBeforeUnmount(() => {
112
+ disposers.forEach((dispose) => dispose.dispose());
113
+ });
114
+
115
+ return () => {
116
+ const { entity, onClick } = props;
117
+ const { disabled } = entity;
118
+ const className = classNames('workflow-port-render', props.className || '', {
119
+ hovered: !readonly.value && hovered.value && !disabled,
120
+ linked: linked.value,
121
+ });
122
+
123
+ const colorStyles: Record<string, string> = {};
124
+ if (props.primaryColor) {
125
+ colorStyles['--g-workflow-port-color-primary'] = props.primaryColor;
126
+ }
127
+ if (props.secondaryColor) {
128
+ colorStyles['--g-workflow-port-color-secondary'] = props.secondaryColor;
129
+ }
130
+ if (props.errorColor) {
131
+ colorStyles['--g-workflow-port-color-error'] = props.errorColor;
132
+ }
133
+ if (props.backgroundColor) {
134
+ colorStyles['--g-workflow-port-color-background'] = props.backgroundColor;
135
+ }
136
+
137
+ const combinedStyle = targetElement.value
138
+ ? { ...props.style, ...colorStyles }
139
+ : { ...props.style, ...colorStyles, left: `${posX.value}px`, top: `${posY.value}px` };
140
+
141
+ const content = h(
142
+ 'div',
143
+ {
144
+ class: className,
145
+ style: combinedStyle,
146
+ onClick: (e: MouseEvent) => onClick?.(e, entity),
147
+ onTouchstart: (e: TouchEvent) => {
148
+ if (!onClick) {
149
+ return;
150
+ }
151
+ MouseTouchEvent.onTouched(e, (mouseEvent) => {
152
+ onClick(mouseEvent as unknown as MouseEvent, entity);
153
+ });
154
+ },
155
+ 'data-port-entity-id': entity.id,
156
+ 'data-port-entity-type': entity.portType,
157
+ 'data-testid': 'sdk.workflow.canvas.node.port',
158
+ },
159
+ [
160
+ h('div', { class: classNames('bg-circle', 'workflow-bg-circle') }),
161
+ h(
162
+ 'div',
163
+ {
164
+ class: classNames({
165
+ bg: true,
166
+ [PORT_BG_CLASS_NAME]: true,
167
+ 'workflow-point-bg': true,
168
+ hasError: hasError.value,
169
+ }),
170
+ },
171
+ [h(CrossHair)]
172
+ ),
173
+ h('div', { class: 'focus-circle' }),
174
+ ]
175
+ );
176
+
177
+ if (targetElement.value) {
178
+ return h(Teleport, { to: targetElement.value }, [content]);
179
+ }
180
+ return content;
181
+ };
182
+ },
183
+ });
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
3
+ * SPDX-License-Identifier: MIT
4
+ */
5
+
6
+ // 箭头宽度
7
+ export const LINE_OFFSET = 6;
8
+
9
+ export const LINE_PADDING = 12;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
3
+ * SPDX-License-Identifier: MIT
4
+ */
5
+
6
+ // 连接点半径
7
+
8
+ export const STROKE_WIDTH_SLECTED = 3;
9
+
10
+ export const STROKE_WIDTH = 2;
11
+
12
+ export const PORT_BG_CLASS_NAME = 'workflow-port-bg';
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
3
+ * SPDX-License-Identifier: MIT
4
+ */
5
+
6
+ import { type IPoint } from '@flowgram-vue/utils';
7
+ import { LinePoint, LinePointLocation } from '@flowgram-vue/free-layout-core';
8
+
9
+ /**
10
+ * Fork from: https://github.com/xyflow/xyflow/blob/main/packages/system/src/utils/edges/bezier-edge.ts
11
+ * MIT License
12
+ * Copyright (c) 2019-2024 webkid GmbH
13
+ */
14
+ export function getBezierEdgeCenter(
15
+ fromPos: IPoint,
16
+ toPos: IPoint,
17
+ fromControl: IPoint,
18
+ toControl: IPoint
19
+ ): IPoint {
20
+ /*
21
+ * cubic bezier t=0.5 mid point, not the actual mid point, but easy to calculate
22
+ * https://stackoverflow.com/questions/67516101/how-to-find-distance-mid-point-of-bezier-curve
23
+ */
24
+ const x = fromPos.x * 0.125 + fromControl.x * 0.375 + toControl.x * 0.375 + toPos.x * 0.125;
25
+ const y = fromPos.y * 0.125 + fromControl.y * 0.375 + toControl.y * 0.375 + toPos.y * 0.125;
26
+ return {
27
+ x,
28
+ y,
29
+ };
30
+ }
31
+
32
+ function getControlOffset(distance: number, curvature: number): number {
33
+ if (distance >= 0) {
34
+ return 0.5 * distance;
35
+ }
36
+
37
+ return curvature * 25 * Math.sqrt(-distance);
38
+ }
39
+
40
+ function getControlWithCurvature({
41
+ location,
42
+ x1,
43
+ y1,
44
+ x2,
45
+ y2,
46
+ curvature,
47
+ }: {
48
+ location: LinePointLocation;
49
+ curvature: number;
50
+ x1: number;
51
+ x2: number;
52
+ y1: number;
53
+ y2: number;
54
+ }): IPoint {
55
+ switch (location) {
56
+ case 'left':
57
+ return {
58
+ x: x1 - getControlOffset(x1 - x2, curvature),
59
+ y: y1,
60
+ };
61
+ case 'right':
62
+ return {
63
+ x: x1 + getControlOffset(x2 - x1, curvature),
64
+ y: y1,
65
+ };
66
+ case 'top':
67
+ return {
68
+ x: x1,
69
+ y: y1 - getControlOffset(y1 - y2, curvature),
70
+ };
71
+ case 'bottom':
72
+ return {
73
+ x: x1,
74
+ y: y1 + getControlOffset(y2 - y1, curvature),
75
+ };
76
+ }
77
+ }
78
+
79
+ export function getBezierControlPoints(
80
+ fromPos: LinePoint,
81
+ toPos: LinePoint,
82
+ curvature = 0.25
83
+ ): { controls: [IPoint, IPoint]; center: IPoint } {
84
+ const fromControl = getControlWithCurvature({
85
+ location: fromPos.location,
86
+ x1: fromPos.x,
87
+ y1: fromPos.y,
88
+ x2: toPos.x,
89
+ y2: toPos.y,
90
+ curvature,
91
+ });
92
+ const toControl = getControlWithCurvature({
93
+ location: toPos.location,
94
+ x1: toPos.x,
95
+ y1: toPos.y,
96
+ x2: fromPos.x,
97
+ y2: fromPos.y,
98
+ curvature,
99
+ });
100
+ const center = getBezierEdgeCenter(fromPos, toPos, fromControl, toControl);
101
+ return {
102
+ controls: [fromControl, toControl],
103
+ center,
104
+ };
105
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
3
+ * SPDX-License-Identifier: MIT
4
+ */
5
+
6
+ import { Bezier } from 'bezier-js';
7
+ import { IPoint, Point, Rectangle } from '@flowgram-vue/utils';
8
+ import {
9
+ WorkflowLineEntity,
10
+ WorkflowLineRenderContribution,
11
+ LinePoint,
12
+ LineCenterPoint,
13
+ } from '@flowgram-vue/free-layout-core';
14
+ import { LineType } from '@flowgram-vue/free-layout-core';
15
+
16
+ import { posWithShrink, toRelative } from '../utils';
17
+ import { getBezierControlPoints } from './bezier-controls';
18
+
19
+ export interface BezierData {
20
+ fromPos: IPoint;
21
+ toPos: IPoint;
22
+ bbox: Rectangle; // 外围矩形
23
+ controls: IPoint[]; // 控制点
24
+ bezier: Bezier;
25
+ path: string;
26
+ center: LineCenterPoint;
27
+ }
28
+
29
+ export class WorkflowBezierLineContribution implements WorkflowLineRenderContribution {
30
+ public static type = LineType.BEZIER;
31
+
32
+ public entity: WorkflowLineEntity;
33
+
34
+ constructor(entity: WorkflowLineEntity) {
35
+ this.entity = entity;
36
+ }
37
+
38
+ private data?: BezierData;
39
+
40
+ public get path(): string {
41
+ return this.data?.path ?? '';
42
+ }
43
+
44
+ public calcDistance(pos: IPoint): number {
45
+ if (!this.data) {
46
+ return Number.MAX_SAFE_INTEGER;
47
+ }
48
+ return Point.getDistance(pos, this.data.bezier.project(pos));
49
+ }
50
+
51
+ public get bounds(): Rectangle {
52
+ if (!this.data) {
53
+ return Rectangle.EMPTY;
54
+ }
55
+ return this.data.bbox;
56
+ }
57
+
58
+ get center() {
59
+ return this.data?.center;
60
+ }
61
+
62
+ public update(params: { fromPos: LinePoint; toPos: LinePoint }): void {
63
+ this.data = this.calcBezier(params.fromPos, params.toPos);
64
+ }
65
+
66
+ private calcBezier(fromPos: LinePoint, toPos: LinePoint): BezierData {
67
+ const { controls, center } = getBezierControlPoints(
68
+ fromPos,
69
+ toPos,
70
+ this.entity.uiState.curvature
71
+ );
72
+ const bezier = new Bezier([fromPos, ...controls, toPos]);
73
+ const bbox = bezier.bbox();
74
+ const bboxBounds = new Rectangle(
75
+ bbox.x.min,
76
+ bbox.y.min,
77
+ bbox.x.max - bbox.x.min,
78
+ bbox.y.max - bbox.y.min
79
+ );
80
+ const centerPoint = toRelative(center, bboxBounds);
81
+
82
+ const path = this.getPath({ bbox: bboxBounds, fromPos, toPos, controls });
83
+
84
+ this.data = {
85
+ fromPos,
86
+ toPos,
87
+ bezier,
88
+ bbox: bboxBounds,
89
+ controls,
90
+ path,
91
+ center: {
92
+ ...center,
93
+ labelX: centerPoint.x,
94
+ labelY: centerPoint.y,
95
+ },
96
+ };
97
+ return this.data;
98
+ }
99
+
100
+ private getPath(params: {
101
+ bbox: Rectangle;
102
+ fromPos: LinePoint;
103
+ toPos: LinePoint;
104
+ controls: [IPoint, IPoint];
105
+ }): string {
106
+ const { bbox } = params;
107
+ // 相对位置转换函数
108
+ const fromPos = toRelative(params.fromPos, bbox);
109
+ const toPos = toRelative(params.toPos, bbox);
110
+
111
+ const controls = params.controls.map((c) => toRelative(c, bbox));
112
+ const shrink = this.entity.uiState.shrink;
113
+
114
+ const renderFromPos: IPoint = posWithShrink(fromPos, params.fromPos.location, shrink);
115
+
116
+ const renderToPos: IPoint = posWithShrink(toPos, params.toPos.location, shrink);
117
+
118
+ const controlPoints = controls.map((s) => `${s.x} ${s.y}`).join(',');
119
+ return `M${renderFromPos.x} ${renderFromPos.y} C ${controlPoints}, ${renderToPos.x} ${renderToPos.y}`;
120
+ }
121
+ }