This commit is contained in:
@@ -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);
|
||||
Reference in New Issue
Block a user