chore: import upstream snapshot with attribution
CI / build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:09:51 +08:00
commit 0232b4e2bb
3528 changed files with 291404 additions and 0 deletions
@@ -0,0 +1,22 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import type { WorkflowSnapLayerOptions, WorkflowSnapServiceOptions } from './type';
export const SnapDefaultOptions: WorkflowSnapServiceOptions & WorkflowSnapLayerOptions = {
enableEdgeSnapping: true,
edgeThreshold: 7,
enableGridSnapping: false,
gridSize: 20,
enableMultiSnapping: true,
enableOnlyViewportSnapping: true,
edgeColor: '#4E40E5',
alignColor: '#4E40E5',
edgeLineWidth: 2,
alignLineWidth: 2,
alignCrossWidth: 16,
};
export const Epsilon = 0.00001;
@@ -0,0 +1,34 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { definePluginCreator } from '@flowgram.ai/core';
import {
FreeSnapPluginOptions,
WorkflowSnapLayerOptions,
WorkflowSnapServiceOptions,
} from './type';
import { WorkflowSnapService } from './service';
import { WorkflowSnapLayer } from './layer';
import { SnapDefaultOptions } from './constant';
export const createFreeSnapPlugin = definePluginCreator<FreeSnapPluginOptions>({
onBind({ bind }) {
bind(WorkflowSnapService).toSelf().inSingletonScope();
},
onInit(ctx, opts) {
const options: WorkflowSnapServiceOptions & WorkflowSnapLayerOptions = {
...SnapDefaultOptions,
...opts,
};
ctx.playground.registerLayer(WorkflowSnapLayer, options);
const snapService = ctx.get<WorkflowSnapService>(WorkflowSnapService);
snapService.init(options);
},
onDispose(ctx) {
const snapService = ctx.get<WorkflowSnapService>(WorkflowSnapService);
snapService.dispose();
},
});
@@ -0,0 +1,7 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { createFreeSnapPlugin } from './create-plugin';
export { WorkflowSnapService } from './service';
@@ -0,0 +1,514 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import React from 'react';
import { inject, injectable } from 'inversify';
import { domUtils } from '@flowgram.ai/utils';
import { Rectangle } from '@flowgram.ai/utils';
import { WorkflowDocument } from '@flowgram.ai/free-layout-core';
import { FlowNodeTransformData } from '@flowgram.ai/document';
import { Layer } from '@flowgram.ai/core';
import { isEqual, isGreaterThan, isNumber } from './utils';
import { AlignRect, SnapEvent, WorkflowSnapLayerOptions } from './type';
import { WorkflowSnapService } from './service';
interface SnapRenderLine {
className: string;
sourceNode: string;
top: number;
left: number;
width: number;
height: number;
dashed?: boolean;
}
@injectable()
export class WorkflowSnapLayer extends Layer<WorkflowSnapLayerOptions> {
public static type = 'WorkflowSnapLayer';
@inject(WorkflowDocument) private readonly document: WorkflowDocument;
@inject(WorkflowSnapService) private readonly service: WorkflowSnapService;
public readonly node = domUtils.createDivWithClass(
'gedit-playground-layer gedit-flow-snap-layer'
);
private edgeLines: SnapRenderLine[] = [];
private alignLines: SnapRenderLine[] = [];
public onReady(): void {
this.node.style.zIndex = '9999';
this.toDispose.pushAll([
this.service.onSnap((event: SnapEvent) => {
this.edgeLines = this.calcEdgeLines(event);
this.alignLines = this.calcAlignLines(event);
this.render();
}),
]);
}
public render(): JSX.Element {
return (
<>
{this.alignLines.length > 0 && (
<div className="workflow-snap-align-lines">{this.renderAlignLines()}</div>
)}
{this.edgeLines.length > 0 && (
<div className="workflow-snap-edge-lines">{this.renderEdgeLines()}</div>
)}
</>
);
}
public onZoom(scale: number): void {
this.node.style.transform = `scale(${scale})`;
}
private renderEdgeLines(): JSX.Element[] {
return this.edgeLines.map((renderLine: SnapRenderLine) => {
const { className, sourceNode, top, left, width, height, dashed } = renderLine;
const id = `${className}-${sourceNode}-${top}-${left}-${width}-${height}`;
const isHorizontal = width < height;
const border = `${this.options.edgeLineWidth}px ${dashed ? 'dashed' : 'solid'} ${
this.options.edgeColor
}`;
return (
<div
className={`workflow-snap-edge-line ${className}`}
data-testid="sdk.workflow.canvas.snap.edgeLine"
data-snap-line-id={id}
data-snap-line-source-node={sourceNode}
key={id}
style={{
top,
left,
width,
height,
position: 'absolute',
borderLeft: isHorizontal ? border : 'none',
borderTop: !isHorizontal ? border : 'none',
}}
/>
);
});
}
private renderAlignLines(): JSX.Element[] {
return this.alignLines.map((renderLine: SnapRenderLine) => {
const id = `${renderLine.className}-${renderLine.sourceNode}-${renderLine.top}-${renderLine.left}-${renderLine.width}-${renderLine.height}`;
const isHorizontal = isGreaterThan(renderLine.width, renderLine.height);
const alignLineWidth = this.options.alignLineWidth; // 整体线条粗细
const alignCrossWidth = this.options.alignCrossWidth; // 工字形横线的长度
// 调整渲染位置以保持居中
const adjustedTop = isHorizontal ? renderLine.top - alignLineWidth / 2 : renderLine.top;
const adjustedLeft = isHorizontal ? renderLine.left : renderLine.left - alignLineWidth / 2;
return (
<div
className={`workflow-snap-align-line ${renderLine.className}`}
data-testid="sdk.workflow.canvas.snap.alignLine"
data-snap-line-id={id}
data-snap-line-source-node={renderLine.sourceNode}
key={id}
style={{
position: 'absolute',
}}
>
{/* 主线 */}
<div
style={{
position: 'absolute',
top: adjustedTop,
left: adjustedLeft,
width: isHorizontal ? renderLine.width : alignLineWidth,
height: isHorizontal ? alignLineWidth : renderLine.height,
backgroundColor: this.options.alignColor,
}}
/>
{/* 左端或上端横线 */}
<div
style={{
position: 'absolute',
top: isHorizontal
? adjustedTop - (alignCrossWidth - alignLineWidth) / 2
: adjustedTop,
left: isHorizontal
? adjustedLeft
: adjustedLeft - (alignCrossWidth - alignLineWidth) / 2,
width: isHorizontal ? alignLineWidth : alignCrossWidth,
height: isHorizontal ? alignCrossWidth : alignLineWidth,
backgroundColor: this.options.alignColor,
}}
/>
{/* 右端或下端横线 */}
<div
style={{
position: 'absolute',
top: isHorizontal
? adjustedTop - (alignCrossWidth - alignLineWidth) / 2
: adjustedTop + renderLine.height - alignLineWidth,
left: isHorizontal
? adjustedLeft + renderLine.width - alignLineWidth
: adjustedLeft - (alignCrossWidth - alignLineWidth) / 2,
width: isHorizontal ? alignLineWidth : alignCrossWidth,
height: isHorizontal ? alignCrossWidth : alignLineWidth,
backgroundColor: this.options.alignColor,
}}
/>
</div>
);
});
}
private calcEdgeLines(event: SnapEvent): SnapRenderLine[] {
const { alignRects, snapRect, snapEdgeLines } = event;
const edgeLines: SnapRenderLine[] = [];
const topFullAlign = this.directionFullAlign({
alignRects: alignRects.top,
targetRect: snapRect,
isVertical: true,
});
const bottomFullAlign = this.directionFullAlign({
alignRects: alignRects.bottom,
targetRect: snapRect,
isVertical: true,
});
const leftFullAlign = this.directionFullAlign({
alignRects: alignRects.left,
targetRect: snapRect,
isVertical: false,
});
const rightFullAlign = this.directionFullAlign({
alignRects: alignRects.right,
targetRect: snapRect,
isVertical: false,
});
// 处理顶部对齐
if (topFullAlign) {
const top = topFullAlign.rect.top;
const height = bottomFullAlign
? snapRect.bottom - snapRect.height / 2 - top
: snapRect.bottom - top;
const width = this.options.edgeLineWidth;
const lineData = {
top,
width,
height,
};
edgeLines.push({
className: 'edge-full-top-left',
sourceNode: topFullAlign.sourceNodeId,
left: snapRect.left,
...lineData,
});
edgeLines.push({
className: 'edge-full-top-right',
sourceNode: topFullAlign.sourceNodeId,
left: snapRect.right - 1,
...lineData,
});
edgeLines.push({
className: 'edge-full-top-mid',
sourceNode: topFullAlign.sourceNodeId,
left: snapRect.left + snapRect.width / 2,
dashed: true,
...lineData,
});
}
// 处理底部对齐
if (bottomFullAlign) {
const top = topFullAlign ? snapRect.top + snapRect.height / 2 : snapRect.top;
const height = bottomFullAlign.rect.bottom - top;
const width = this.options.edgeLineWidth;
const lineData = {
top,
width,
height,
};
edgeLines.push({
className: 'edge-full-bottom-left',
sourceNode: bottomFullAlign.sourceNodeId,
left: snapRect.left,
...lineData,
});
edgeLines.push({
className: 'edge-full-bottom-right',
sourceNode: bottomFullAlign.sourceNodeId,
left: snapRect.right - 1,
...lineData,
});
edgeLines.push({
className: 'edge-full-bottom-mid',
sourceNode: bottomFullAlign.sourceNodeId,
left: snapRect.left + snapRect.width / 2,
dashed: true,
...lineData,
});
}
// 处理左侧对齐
if (leftFullAlign) {
const left = leftFullAlign.rect.left;
const width = rightFullAlign
? snapRect.right - snapRect.width / 2 - left
: snapRect.right - left;
const height = this.options.edgeLineWidth;
const lineData = {
left,
width,
height,
};
edgeLines.push({
className: 'edge-full-left-top',
sourceNode: leftFullAlign.sourceNodeId,
top: snapRect.top,
...lineData,
});
edgeLines.push({
className: 'edge-full-left-bottom',
sourceNode: leftFullAlign.sourceNodeId,
top: snapRect.bottom - 1,
...lineData,
});
edgeLines.push({
className: 'edge-full-left-mid',
sourceNode: leftFullAlign.sourceNodeId,
top: snapRect.top + snapRect.height / 2,
dashed: true,
...lineData,
});
}
// 处理右侧对齐
if (rightFullAlign) {
const left = leftFullAlign ? snapRect.left + snapRect.width / 2 : snapRect.left;
const width = rightFullAlign.rect.right - left;
const height = this.options.edgeLineWidth;
const lineData = {
left,
width,
height,
};
edgeLines.push({
className: 'edge-full-right-top',
sourceNode: rightFullAlign.sourceNodeId,
top: snapRect.top,
...lineData,
});
edgeLines.push({
className: 'edge-full-right-bottom',
sourceNode: rightFullAlign.sourceNodeId,
top: snapRect.bottom - 1,
...lineData,
});
edgeLines.push({
className: 'edge-full-right-mid',
sourceNode: rightFullAlign.sourceNodeId,
top: snapRect.top + snapRect.height / 2,
dashed: true,
...lineData,
});
}
const snappedEdgeLines = Object.entries(snapEdgeLines)
.map(([direction, snapLine]) => {
if (!snapLine) {
return;
}
const sourceNode = this.document.getNode(snapLine.sourceNodeId);
if (!sourceNode) {
return;
}
const nodeRect = sourceNode.getData(FlowNodeTransformData).bounds;
if (isNumber(snapLine.x)) {
// 垂直
const top = Math.min(nodeRect.top, snapRect.top);
const bottom = Math.max(nodeRect.bottom, snapRect.bottom);
const height = bottom - top;
const left = direction === 'right' ? snapLine.x - 1 : snapLine.x;
const width = this.options.edgeLineWidth;
const isMidX = direction === 'midVertical';
const lineData: SnapRenderLine = {
className: `edge-snapped-${direction}`,
sourceNode: snapLine.sourceNodeId,
top,
left,
width,
height,
dashed: isMidX,
};
const onTop = top === nodeRect.top;
if (onTop && topFullAlign) {
return;
}
if (!onTop && bottomFullAlign) {
return;
}
return lineData;
} else if (isNumber(snapLine.y)) {
// 水平
const left = Math.min(nodeRect.left, snapRect.left);
const right = Math.max(nodeRect.right, snapRect.right);
const width = right - left;
const top = direction === 'bottom' ? snapLine.y - 1 : snapLine.y;
const height = this.options.edgeLineWidth;
const isMidY = direction === 'midHorizontal';
const lineData: SnapRenderLine = {
className: `edge-snapped-${direction}`,
sourceNode: snapLine.sourceNodeId,
top,
left,
width,
height,
dashed: isMidY,
};
const onLeft = left === nodeRect.left;
if (onLeft && leftFullAlign) {
return;
}
if (!onLeft && rightFullAlign) {
return;
}
return lineData;
}
})
.filter(Boolean) as SnapRenderLine[];
edgeLines.push(...snappedEdgeLines);
return edgeLines;
}
private directionFullAlign(params: {
alignRects: AlignRect[];
targetRect: Rectangle;
isVertical: boolean;
}): AlignRect | undefined {
const { alignRects, targetRect, isVertical } = params;
let fullAlignIndex = -1;
for (let i = 0; i < alignRects.length; i++) {
const alignRect = alignRects[i];
const prevRect = alignRects[i - 1]?.rect ?? targetRect;
const isFullAlign = this.rectFullAlign(alignRect.rect, prevRect, isVertical);
if (!isFullAlign) {
// 未对齐则中断
break; // 用 for 循环 + break 反而比 Array.findIndex 实现可读性更好
}
fullAlignIndex = i;
}
const fullAlignRect = alignRects[fullAlignIndex];
return fullAlignRect;
}
private rectFullAlign(rectA: Rectangle, rectB: Rectangle, isVertical: boolean): boolean {
if (isVertical) {
return isEqual(rectA.left, rectB.left) && isEqual(rectA.right, rectB.right);
} else {
return isEqual(rectA.top, rectB.top) && isEqual(rectA.bottom, rectB.bottom);
}
}
private calcAlignLines(event: SnapEvent): SnapRenderLine[] {
const { alignRects, alignSpacing, snapRect } = event;
const topAlignLines = this.calcDirectionAlignLines({
alignRects: alignRects.top,
targetRect: snapRect,
isVertical: true,
spacing: alignSpacing.midVertical ?? alignSpacing.top,
});
const bottomAlignLines = this.calcDirectionAlignLines({
alignRects: alignRects.bottom,
targetRect: snapRect,
isVertical: true,
spacing: alignSpacing.midVertical ?? alignSpacing.bottom,
});
const leftAlignLines = this.calcDirectionAlignLines({
alignRects: alignRects.left,
targetRect: snapRect,
isVertical: false,
spacing: alignSpacing.midHorizontal ?? alignSpacing.left,
});
const rightAlignLines = this.calcDirectionAlignLines({
alignRects: alignRects.right,
targetRect: snapRect,
isVertical: false,
spacing: alignSpacing.midHorizontal ?? alignSpacing.right,
});
return [...topAlignLines, ...bottomAlignLines, ...leftAlignLines, ...rightAlignLines];
}
private calcDirectionAlignLines(params: {
alignRects: AlignRect[];
targetRect: Rectangle;
isVertical: boolean;
spacing?: number;
}) {
const { alignRects, targetRect, isVertical, spacing } = params;
const alignLines: SnapRenderLine[] = [];
if (!spacing) {
return alignLines;
}
for (let i = 0; i < alignRects.length; i++) {
const alignRect = alignRects[i];
const rect = alignRect.rect;
const prevRect = alignRects[i - 1]?.rect ?? targetRect;
const betweenSpacing = isVertical
? Math.min(Math.abs(prevRect.top - rect.bottom), Math.abs(prevRect.bottom - rect.top))
: Math.min(Math.abs(prevRect.left - rect.right), Math.abs(prevRect.right - rect.left));
if (!isEqual(betweenSpacing, spacing)) {
// 不连续,需要中断
break; // 因为要用到 break,所以不能用 Array.map()
}
if (isVertical) {
const centerX = this.calcHorizontalIntersectionCenter(rect, targetRect);
alignLines.push({
className: 'align-vertical',
sourceNode: alignRect.sourceNodeId,
top: Math.min(rect.bottom, prevRect.bottom),
left: centerX,
width: 1,
height: spacing,
});
} else {
const centerY = this.calcVerticalIntersectionCenter(rect, targetRect);
alignLines.push({
className: 'align-horizontal',
sourceNode: alignRect.sourceNodeId,
top: centerY,
left: Math.min(rect.right, prevRect.right),
width: spacing,
height: 1,
});
}
}
return alignLines;
}
private calcVerticalIntersectionCenter(rectA: Rectangle, rectB: Rectangle): number {
const top = Math.max(rectA.top, rectB.top);
const bottom = Math.min(rectA.bottom, rectB.bottom);
return (top + bottom) / 2;
}
private calcHorizontalIntersectionCenter(rectA: Rectangle, rectB: Rectangle): number {
const left = Math.max(rectA.left, rectB.left);
const right = Math.min(rectA.right, rectB.right);
return (left + right) / 2;
}
}
@@ -0,0 +1,703 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { inject, injectable } from 'inversify';
import { Disposable, Emitter, Rectangle } from '@flowgram.ai/utils';
import { IPoint } from '@flowgram.ai/utils';
import { WorkflowNodeEntity, WorkflowDocument } from '@flowgram.ai/free-layout-core';
import { WorkflowDragService } from '@flowgram.ai/free-layout-core';
import { FlowNodeTransformData } from '@flowgram.ai/document';
import { FlowNodeBaseType } from '@flowgram.ai/document';
import { EntityManager, PlaygroundConfigEntity, TransformData } from '@flowgram.ai/core';
import { isEqual, isGreaterThan, isLessThan, isLessThanOrEqual, isNumber } from './utils';
import type {
SnapEvent,
SnapHorizontalLine,
SnapLines,
SnapMidHorizontalLine,
SnapMidVerticalLine,
SnapVerticalLine,
WorkflowSnapServiceOptions,
AlignRects,
AlignRect,
AlignSpacing,
SnapNodeRect,
SnapEdgeLines,
} from './type';
import { SnapDefaultOptions } from './constant';
@injectable()
export class WorkflowSnapService {
@inject(WorkflowDocument) private readonly document: WorkflowDocument;
@inject(EntityManager) private readonly entityManager: EntityManager;
@inject(WorkflowDragService)
private readonly dragService: WorkflowDragService;
@inject(PlaygroundConfigEntity)
private readonly playgroundConfig: PlaygroundConfigEntity;
private disposers: Disposable[] = [];
private options: WorkflowSnapServiceOptions;
private snapEmitter = new Emitter<SnapEvent>();
public readonly onSnap = this.snapEmitter.event;
private _disabled = false;
public init(params: Partial<WorkflowSnapServiceOptions> = {}): void {
this.options = {
...SnapDefaultOptions,
...params,
};
this.mountListener();
}
public dispose(): void {
this.disposers.forEach((disposer) => disposer.dispose());
}
public get disabled(): boolean {
return this._disabled;
}
public disable(): void {
if (this._disabled) {
return;
}
this._disabled = true;
this.clear();
}
public enable(): void {
if (!this._disabled) {
return;
}
this._disabled = false;
this.clear();
}
private mountListener(): void {
const dragAdjusterDisposer = this.dragService.registerPosAdjuster((params) => {
const { selectedNodes: targetNodes, position } = params;
const isMultiSnapping = this.options.enableMultiSnapping ? false : targetNodes.length !== 1;
if (this._disabled || !this.options.enableEdgeSnapping || isMultiSnapping) {
return {
x: 0,
y: 0,
};
}
return this.snapping({
targetNodes,
position,
});
});
const dragEndDisposer = this.dragService.onNodesDrag((event) => {
if (event.type !== 'onDragEnd' || this._disabled) {
return;
}
if (this.options.enableGridSnapping) {
this.gridSnapping({
targetNodes: event.nodes,
gridSize: this.options.gridSize,
});
}
if (this.options.enableEdgeSnapping) {
this.clear();
}
});
this.disposers.push(dragAdjusterDisposer, dragEndDisposer);
}
private snapping(params: { targetNodes: WorkflowNodeEntity[]; position: IPoint }): IPoint {
const { targetNodes, position } = params;
const targetBounds = this.getBounds(targetNodes);
const targetRect = new Rectangle(
position.x,
position.y,
targetBounds.width,
targetBounds.height
);
const snapNodeRects = this.getSnapNodeRects({
targetNodes,
targetRect,
});
const { alignOffset, alignRects, alignSpacing } = this.calcAlignOffset({
targetRect,
alignThreshold: this.options.edgeThreshold,
snapNodeRects,
});
const { snapOffset, snapEdgeLines } = this.calcSnapOffset({
targetRect,
edgeThreshold: this.options.edgeThreshold,
snapNodeRects,
});
const offset: IPoint = {
x: snapOffset.x || alignOffset.x,
y: snapOffset.y || alignOffset.y,
};
const snapRect = new Rectangle(
position.x + offset.x,
position.y + offset.y,
targetRect.width,
targetRect.height
);
this.snapEmitter.fire({
snapRect,
snapEdgeLines,
alignRects,
alignSpacing,
});
return offset;
}
private calcSnapOffset(params: {
snapNodeRects: SnapNodeRect[];
targetRect: Rectangle;
edgeThreshold: number;
}): {
snapOffset: IPoint;
snapEdgeLines: SnapEdgeLines;
} {
const { snapNodeRects, edgeThreshold, targetRect } = params;
const snapLines = this.getSnapLines({
snapNodeRects,
});
// 找到最近的线条
const topYClosestLine = snapLines.horizontal.find((line) =>
isLessThanOrEqual(Math.abs(line.y - targetRect.top), edgeThreshold)
);
const bottomYClosestLine = snapLines.horizontal.find((line) =>
isLessThanOrEqual(Math.abs(line.y - targetRect.bottom), edgeThreshold)
);
const leftXClosestLine = snapLines.vertical.find((line) =>
isLessThanOrEqual(Math.abs(line.x - targetRect.left), edgeThreshold)
);
const rightXClosestLine = snapLines.vertical.find((line) =>
isLessThanOrEqual(Math.abs(line.x - targetRect.right), edgeThreshold)
);
const midYClosestLine = snapLines.midHorizontal.find((line) =>
isLessThanOrEqual(Math.abs(line.y - targetRect.center.y), edgeThreshold)
);
const midXClosestLine = snapLines.midVertical.find((line) =>
isLessThanOrEqual(Math.abs(line.x - targetRect.center.x), edgeThreshold)
);
// 计算最近坐标
const topYClosest = topYClosestLine?.y;
const bottomYClosest = isNumber(bottomYClosestLine?.y)
? bottomYClosestLine!.y - targetRect.height
: undefined;
const leftXClosest = leftXClosestLine?.x;
const rightXClosest = isNumber(rightXClosestLine?.x)
? rightXClosestLine!.x - targetRect.width
: undefined;
const midYClosest = isNumber(midYClosestLine?.y)
? midYClosestLine!.y - targetRect.height / 2
: undefined;
const midXClosest = isNumber(midXClosestLine?.x)
? midXClosestLine!.x - targetRect.width / 2
: undefined;
// 吸附后坐标,按优先级取值
const snappingPosition = {
x: midXClosest ?? leftXClosest ?? rightXClosest ?? targetRect.x,
y: midYClosest ?? topYClosest ?? bottomYClosest ?? targetRect.y,
};
// 吸附修正偏移量
const snapOffset: IPoint = {
x: snappingPosition.x - targetRect.x,
y: snappingPosition.y - targetRect.y,
};
// 生效的吸附线条
const snapEdgeLines: SnapEdgeLines = {
top: isEqual(topYClosest, snappingPosition.y) ? topYClosestLine : undefined,
bottom: isEqual(bottomYClosest, snappingPosition.y) ? bottomYClosestLine : undefined,
left: isEqual(leftXClosest, snappingPosition.x) ? leftXClosestLine : undefined,
right: isEqual(rightXClosest, snappingPosition.x) ? rightXClosestLine : undefined,
midVertical: isEqual(midXClosest, snappingPosition.x) ? midXClosestLine : undefined,
midHorizontal: isEqual(midYClosest, snappingPosition.y) ? midYClosestLine : undefined,
};
return { snapOffset, snapEdgeLines };
}
private gridSnapping(params: { gridSize: number; targetNodes: WorkflowNodeEntity[] }): void {
const { gridSize, targetNodes } = params;
const rect = this.getBounds(targetNodes);
const snap = (value: number) => Math.round(value / gridSize) * gridSize;
const snappedPosition: IPoint = {
x: snap(rect.x),
y: snap(rect.y),
};
const offset: IPoint = {
x: snappedPosition.x - rect.x,
y: snappedPosition.y - rect.y,
};
targetNodes.forEach((node) =>
this.updateNodePositionWithOffset({
node,
offset,
})
);
}
private clear() {
this.snapEmitter.fire({
snapEdgeLines: {},
snapRect: Rectangle.EMPTY,
alignRects: {
top: [],
bottom: [],
left: [],
right: [],
},
alignSpacing: {},
});
}
private getSnapLines(params: { snapNodeRects: SnapNodeRect[] }): SnapLines {
const { snapNodeRects } = params;
const horizontalLines: SnapHorizontalLine[] = [];
const verticalLines: SnapVerticalLine[] = [];
const midHorizontalLines: SnapMidHorizontalLine[] = [];
const midVerticalLines: SnapMidVerticalLine[] = [];
snapNodeRects.forEach((snapNodeRect) => {
const nodeBounds = snapNodeRect.rect;
const nodeCenter = nodeBounds.center;
// 边缘横线
const top: SnapHorizontalLine = {
y: nodeBounds.top,
sourceNodeId: snapNodeRect.id,
};
const bottom: SnapHorizontalLine = {
y: nodeBounds.bottom,
sourceNodeId: snapNodeRect.id,
};
// 边缘竖线
const left: SnapVerticalLine = {
x: nodeBounds.left,
sourceNodeId: snapNodeRect.id,
};
const right: SnapVerticalLine = {
x: nodeBounds.right,
sourceNodeId: snapNodeRect.id,
};
// 中间横线
const midHorizontal: SnapMidHorizontalLine = {
y: nodeCenter.y,
sourceNodeId: snapNodeRect.id,
};
// 中间竖线
const midVertical: SnapMidVerticalLine = {
x: nodeCenter.x,
sourceNodeId: snapNodeRect.id,
};
horizontalLines.push(top, bottom);
verticalLines.push(left, right);
midHorizontalLines.push(midHorizontal);
midVerticalLines.push(midVertical);
});
return {
horizontal: horizontalLines,
vertical: verticalLines,
midHorizontal: midHorizontalLines,
midVertical: midVerticalLines,
};
}
private getAvailableNodes(params: {
targetNodes: WorkflowNodeEntity[];
targetRect: Rectangle;
}): WorkflowNodeEntity[] {
const { targetNodes, targetRect } = params;
const targetCenter = targetRect.center;
const targetContainerId = targetNodes[0].parent?.id ?? this.document.root.id;
const disabledNodeIds = targetNodes.map((n) => n.id);
disabledNodeIds.push(FlowNodeBaseType.ROOT);
const availableNodes = this.nodes
.filter((n) => n.parent?.id === targetContainerId)
.filter((n) => !disabledNodeIds.includes(n.id))
.sort((nodeA, nodeB) => {
const nodeCenterA = nodeA.getData(FlowNodeTransformData)!.bounds.center;
const nodeCenterB = nodeB.getData(FlowNodeTransformData)!.bounds.center;
// 距离越近优先级越高
const distanceA =
Math.abs(nodeCenterA.x - targetCenter.x) + Math.abs(nodeCenterA.y - targetCenter.y);
const distanceB =
Math.abs(nodeCenterB.x - targetCenter.x) + Math.abs(nodeCenterB.y - targetCenter.y);
return distanceA - distanceB;
});
return availableNodes;
}
private viewRect(): Rectangle {
const { width, height, scrollX, scrollY, zoom } = this.playgroundConfig.config;
return new Rectangle(scrollX / zoom, scrollY / zoom, width / zoom, height / zoom);
}
private getSnapNodeRects(params: {
targetNodes: WorkflowNodeEntity[];
targetRect: Rectangle;
}): SnapNodeRect[] {
const availableNodes = this.getAvailableNodes(params);
const viewRect = this.viewRect();
return availableNodes
.map((node) => {
const snapNodeRect: SnapNodeRect = {
id: node.id,
rect: node.getData(FlowNodeTransformData).bounds,
entity: node,
};
if (
this.options.enableOnlyViewportSnapping &&
node.parent?.flowNodeType === FlowNodeBaseType.ROOT &&
!Rectangle.intersects(viewRect, snapNodeRect.rect)
) {
// 最外层节点仅包含当前可见节点
return;
}
return snapNodeRect;
})
.filter(Boolean) as SnapNodeRect[];
}
private get nodes(): WorkflowNodeEntity[] {
return this.entityManager.getEntities<WorkflowNodeEntity>(WorkflowNodeEntity);
}
private getBounds(nodes: WorkflowNodeEntity[]): Rectangle {
if (nodes.length === 0) {
return Rectangle.EMPTY;
}
return Rectangle.enlarge(nodes.map((n) => n.getData(FlowNodeTransformData)!.bounds));
}
private updateNodePositionWithOffset(params: { node: WorkflowNodeEntity; offset: IPoint }): void {
const { node, offset } = params;
const transform = node.getData(TransformData);
const positionWithOffset: IPoint = {
x: transform.position.x + offset.x,
y: transform.position.y + offset.y,
};
transform.update({
position: positionWithOffset,
});
this.document.layout.updateAffectedTransform(node);
}
private calcAlignOffset(params: {
snapNodeRects: SnapNodeRect[];
targetRect: Rectangle;
alignThreshold: number;
}): {
alignOffset: IPoint;
alignRects: AlignRects;
alignSpacing: AlignSpacing;
} {
const { snapNodeRects, targetRect, alignThreshold } = params;
const alignRects = this.getAlignRects({
targetRect,
snapNodeRects,
});
const alignSpacing = this.calcAlignSpacing({
targetRect,
alignRects,
});
let topY: number | undefined;
let bottomY: number | undefined;
let leftX: number | undefined;
let rightX: number | undefined;
let midY: number | undefined;
let midX: number | undefined;
if (alignSpacing.top) {
const topAlignY = alignRects.top[0].rect.bottom + alignSpacing.top;
const isAlignTop = isLessThanOrEqual(Math.abs(targetRect.top - topAlignY), alignThreshold);
if (isAlignTop) {
// 生效
topY = topAlignY;
} else {
// 失效
alignSpacing.top = undefined;
}
}
if (alignSpacing.bottom) {
const bottomAlignY = alignRects.bottom[0].rect.top - alignSpacing.bottom;
const isAlignBottom = isLessThan(Math.abs(targetRect.bottom - bottomAlignY), alignThreshold);
if (isAlignBottom) {
bottomY = bottomAlignY - targetRect.height;
} else {
alignSpacing.bottom = undefined;
}
}
if (alignSpacing.left) {
const leftAlignX = alignRects.left[0].rect.right + alignSpacing.left;
const isAlignLeft = isLessThanOrEqual(Math.abs(targetRect.left - leftAlignX), alignThreshold);
if (isAlignLeft) {
leftX = leftAlignX;
} else {
alignSpacing.left = undefined;
}
}
if (alignSpacing.right) {
const rightAlignX = alignRects.right[0].rect.left - alignSpacing.right;
const isAlignRight = isLessThanOrEqual(
Math.abs(targetRect.right - rightAlignX),
alignThreshold
);
if (isAlignRight) {
rightX = rightAlignX - targetRect.width;
} else {
alignSpacing.right = undefined;
}
}
if (alignSpacing.midHorizontal) {
const leftAlignX = alignRects.left[0].rect.right + alignSpacing.midHorizontal;
const isAlignMidHorizontal = isLessThanOrEqual(
Math.abs(targetRect.left - leftAlignX),
alignThreshold
);
if (isAlignMidHorizontal) {
midX = leftAlignX;
} else {
alignSpacing.midHorizontal = undefined;
}
}
if (alignSpacing.midVertical) {
const topAlignY = alignRects.top[0].rect.bottom + alignSpacing.midVertical;
const isAlignMidVertical = isLessThanOrEqual(
Math.abs(targetRect.top - topAlignY),
alignThreshold
);
if (isAlignMidVertical) {
midY = topAlignY;
} else {
alignSpacing.midVertical = undefined;
}
}
const alignPosition: IPoint = {
x: midX ?? leftX ?? rightX ?? targetRect.x,
y: midY ?? topY ?? bottomY ?? targetRect.y,
};
const alignOffset: IPoint = {
x: alignPosition.x - targetRect.x,
y: alignPosition.y - targetRect.y,
};
return { alignOffset, alignRects, alignSpacing };
}
private calcAlignSpacing(params: {
targetRect: Rectangle;
alignRects: AlignRects;
}): AlignSpacing {
const { targetRect, alignRects } = params;
const topSpacing = this.getDirectionAlignSpacing({
rects: alignRects.top,
isHorizontal: false,
});
const bottomSpacing = this.getDirectionAlignSpacing({
rects: alignRects.bottom,
isHorizontal: false,
});
const leftSpacing = this.getDirectionAlignSpacing({
rects: alignRects.left,
isHorizontal: true,
});
const rightSpacing = this.getDirectionAlignSpacing({
rects: alignRects.right,
isHorizontal: true,
});
const midHorizontalSpacing = this.getMidAlignSpacing({
rectA: alignRects.left[0]?.rect,
rectB: alignRects.right[0]?.rect,
targetRect,
isHorizontal: true,
});
const midVerticalSpacing = this.getMidAlignSpacing({
rectA: alignRects.top[0]?.rect,
rectB: alignRects.bottom[0]?.rect,
targetRect,
isHorizontal: false,
});
return {
top: topSpacing,
bottom: bottomSpacing,
left: leftSpacing,
right: rightSpacing,
midHorizontal: midHorizontalSpacing,
midVertical: midVerticalSpacing,
};
}
private getAlignRects(params: {
targetRect: Rectangle;
snapNodeRects: SnapNodeRect[];
}): AlignRects {
const { targetRect, snapNodeRects } = params;
const topVerticalRects: AlignRect[] = [];
const bottomVerticalRects: AlignRect[] = [];
const leftHorizontalRects: AlignRect[] = [];
const rightHorizontalRects: AlignRect[] = [];
snapNodeRects.forEach((snapNodeRect) => {
const nodeRect = snapNodeRect.rect;
const { isVerticalIntersection, isHorizontalIntersection, isIntersection } =
this.intersection(nodeRect, targetRect);
if (isIntersection) {
// 忽略重叠的节点
return;
} else if (isVerticalIntersection) {
// 垂直重合
if (isGreaterThan(nodeRect.center.y, targetRect.center.y)) {
// 下方
bottomVerticalRects.push({
rect: nodeRect,
sourceNodeId: snapNodeRect.id,
});
} else {
// 上方
topVerticalRects.push({
rect: nodeRect,
sourceNodeId: snapNodeRect.id,
});
}
} else if (isHorizontalIntersection) {
// 水平重合
if (isGreaterThan(nodeRect.center.x, targetRect.center.x)) {
// 右方
rightHorizontalRects.push({
rect: nodeRect,
sourceNodeId: snapNodeRect.id,
});
} else {
// 左方
leftHorizontalRects.push({
rect: nodeRect,
sourceNodeId: snapNodeRect.id,
});
}
}
});
return {
top: topVerticalRects,
bottom: bottomVerticalRects,
left: leftHorizontalRects,
right: rightHorizontalRects,
};
}
private getMidAlignSpacing(params: {
rectA?: Rectangle;
rectB?: Rectangle;
targetRect: Rectangle;
isHorizontal: boolean;
}): number | undefined {
const { rectA, rectB, targetRect, isHorizontal } = params;
if (!rectA || !rectB) {
return;
}
const { isVerticalIntersection, isHorizontalIntersection, isIntersection } = this.intersection(
rectA,
rectB
);
if (isIntersection) {
return;
}
if (isHorizontal && isHorizontalIntersection && !isVerticalIntersection) {
const betweenSpacing = Math.min(
Math.abs(rectA.left - rectB.right),
Math.abs(rectA.right - rectB.left)
);
return (betweenSpacing - targetRect.width) / 2;
} else if (!isHorizontal && isVerticalIntersection && !isHorizontalIntersection) {
const betweenSpacing = Math.min(
Math.abs(rectA.top - rectB.bottom),
Math.abs(rectA.bottom - rectB.top)
);
return (betweenSpacing - targetRect.height) / 2;
}
}
private getDirectionAlignSpacing(params: {
rects: AlignRect[];
isHorizontal: boolean;
}): number | undefined {
const { rects, isHorizontal } = params;
if (rects.length < 2) {
// 非法情况
return;
}
const rectA = rects[0].rect;
const rectB = rects[1].rect;
const { isVerticalIntersection, isHorizontalIntersection, isIntersection } = this.intersection(
rectA,
rectB
);
if (isIntersection) {
// 非法情况:重叠
return;
}
if (isHorizontal && isHorizontalIntersection && !isVerticalIntersection) {
return Math.min(Math.abs(rectA.left - rectB.right), Math.abs(rectA.right - rectB.left));
} else if (!isHorizontal && isVerticalIntersection && !isHorizontalIntersection) {
return Math.min(Math.abs(rectA.top - rectB.bottom), Math.abs(rectA.bottom - rectB.top));
}
return;
}
private intersection(
rectA: Rectangle,
rectB: Rectangle
): {
isHorizontalIntersection: boolean;
isVerticalIntersection: boolean;
isIntersection: boolean;
} {
const isVerticalIntersection =
isLessThan(rectA.left, rectB.right) && isGreaterThan(rectA.right, rectB.left);
const isHorizontalIntersection =
isLessThan(rectA.top, rectB.bottom) && isGreaterThan(rectA.bottom, rectB.top);
const isIntersection = isHorizontalIntersection && isVerticalIntersection;
return {
isHorizontalIntersection,
isVerticalIntersection,
isIntersection,
};
}
}
@@ -0,0 +1,98 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import type { WorkflowNodeEntity } from '@flowgram.ai/free-layout-core';
import type { Rectangle } from '@flowgram.ai/utils';
export interface SnapNodeRect {
id: string;
rect: Rectangle;
entity: WorkflowNodeEntity;
}
export interface SnapLine {
x?: number;
y?: number;
sourceNodeId: string;
}
export interface SnapHorizontalLine extends SnapLine {
y: number;
}
export interface SnapVerticalLine extends SnapLine {
x: number;
}
export interface SnapMidHorizontalLine extends SnapLine {
y: number;
}
export interface SnapMidVerticalLine extends SnapLine {
x: number;
}
export interface SnapLines {
horizontal: SnapHorizontalLine[];
vertical: SnapVerticalLine[];
midHorizontal: SnapMidHorizontalLine[];
midVertical: SnapMidVerticalLine[];
}
export interface SnapEdgeLines {
top?: SnapHorizontalLine;
bottom?: SnapHorizontalLine;
left?: SnapVerticalLine;
right?: SnapVerticalLine;
midHorizontal?: SnapMidHorizontalLine;
midVertical?: SnapMidVerticalLine;
}
export interface AlignRect {
rect: Rectangle;
sourceNodeId: string;
}
export interface AlignRects {
top: AlignRect[];
bottom: AlignRect[];
left: AlignRect[];
right: AlignRect[];
}
export interface AlignSpacing {
top?: number;
bottom?: number;
left?: number;
right?: number;
midHorizontal?: number;
midVertical?: number;
}
export interface SnapEvent {
snapEdgeLines: SnapEdgeLines;
snapRect: Rectangle;
alignRects: AlignRects;
alignSpacing: AlignSpacing;
}
export interface WorkflowSnapServiceOptions {
edgeThreshold: number;
gridSize: number;
enableGridSnapping: boolean;
enableEdgeSnapping: boolean;
enableMultiSnapping: boolean;
enableOnlyViewportSnapping: boolean;
}
export interface WorkflowSnapLayerOptions {
edgeColor: string;
alignColor: string;
edgeLineWidth: number;
alignLineWidth: number;
alignCrossWidth: number;
}
export type FreeSnapPluginOptions = Partial<WorkflowSnapServiceOptions & WorkflowSnapLayerOptions>;
@@ -0,0 +1,44 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { Epsilon } from './constant';
/** 检查浮点数 a 是否等于 b */
export const isEqual = (a: number | undefined, b: number | undefined): boolean => {
if (a === undefined || b === undefined) {
return false;
}
// 检查 a 和 b 的差的绝对值是否小于 Epsilon
return Math.abs(a - b) < Epsilon;
};
/** 检查浮点数 a 是否小于 b */
export const isLessThan = (a: number | undefined, b: number | undefined): boolean => {
if (a === undefined || b === undefined) {
return false;
}
// 检查 a 是否显著小于 b
return b - a > Epsilon;
};
/** 检查浮点数 a 是否大于 b */
export const isGreaterThan = (a: number | undefined, b: number | undefined): boolean => {
if (a === undefined || b === undefined) {
return false;
}
return a - b > Epsilon;
};
/** 检查浮点数 a 是否小于等于 b */
export const isLessThanOrEqual = (a: number | undefined, b: number | undefined): boolean =>
isEqual(a, b) || isLessThan(a, b);
/** 检查浮点数 a 是否大于等于 b */
export const isGreaterThanOrEqual = (a: number | undefined, b: number | undefined): boolean =>
isEqual(a, b) || isGreaterThan(a, b);
/** 检查值是否是数字类型 */
export const isNumber = (value: unknown): value is number =>
typeof value === 'number' && !isNaN(value);