feat(snap): node snapping and snap line rending can be disabled

This commit is contained in:
liuyangxing 2025-03-17 17:11:59 +08:00
parent 2607ec93d3
commit 5f1f2a6a03

View File

@ -1,11 +1,11 @@
import { inject, injectable } from 'inversify'; 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 { FlowNodeTransformData } from '@flowgram.ai/document';
import { FlowNodeBaseType } from '@flowgram.ai/document'; import { FlowNodeBaseType } from '@flowgram.ai/document';
import { EntityManager, PlaygroundConfigEntity, TransformData } from '@flowgram.ai/core'; import { EntityManager, PlaygroundConfigEntity, TransformData } from '@flowgram.ai/core';
import { WorkflowNodeEntity, WorkflowDocument } from '@flowgram.ai/free-layout-core';
import { WorkflowDragService } from '@flowgram.ai/free-layout-core';
import { Disposable, Emitter, Rectangle } from '@flowgram.ai/utils';
import { IPoint } from '@flowgram.ai/utils';
import { isEqual, isGreaterThan, isLessThan, isLessThanOrEqual, isNumber } from './utils'; import { isEqual, isGreaterThan, isLessThan, isLessThanOrEqual, isNumber } from './utils';
import type { import type {
@ -44,6 +44,8 @@ export class WorkflowSnapService {
public readonly onSnap = this.snapEmitter.event; public readonly onSnap = this.snapEmitter.event;
private _disabled = false;
public init(params: Partial<WorkflowSnapServiceOptions> = {}): void { public init(params: Partial<WorkflowSnapServiceOptions> = {}): void {
this.options = { this.options = {
...SnapDefaultOptions, ...SnapDefaultOptions,
@ -53,18 +55,46 @@ export class WorkflowSnapService {
} }
public dispose(): void { public dispose(): void {
this.disposers.forEach(disposer => disposer.dispose()); 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 { private mountListener(): void {
const dragAdjusterDisposer = this.dragService.registerPosAdjuster(params => const dragAdjusterDisposer = this.dragService.registerPosAdjuster((params) => {
this.snapping({ const { selectedNodes: targetNodes, position } = params;
targetNodes: params.selectedNodes, const isMultiSnapping = this.options.enableMultiSnapping ? false : targetNodes.length !== 1;
position: params.position, if (this._disabled || !this.options.enableEdgeSnapping || isMultiSnapping) {
}), return {
); x: 0,
const dragEndDisposer = this.dragService.onNodesDrag(event => { y: 0,
if (event.type !== 'onDragEnd') { };
}
return this.snapping({
targetNodes,
position,
});
});
const dragEndDisposer = this.dragService.onNodesDrag((event) => {
if (event.type !== 'onDragEnd' || this._disabled) {
return; return;
} }
if (this.options.enableGridSnapping) { if (this.options.enableGridSnapping) {
@ -81,24 +111,15 @@ export class WorkflowSnapService {
} }
private snapping(params: { targetNodes: WorkflowNodeEntity[]; position: IPoint }): IPoint { private snapping(params: { targetNodes: WorkflowNodeEntity[]; position: IPoint }): IPoint {
const { targetNodes: targetNodes, position } = params; const { targetNodes, position } = params;
const isMultiSnapping = this.options.enableMultiSnapping ? false : targetNodes.length !== 1; const targetBounds = this.getBounds(targetNodes);
if (!this.options.enableEdgeSnapping || isMultiSnapping) {
return {
x: 0,
y: 0,
};
}
const selectedBounds = this.getBounds(targetNodes);
const targetRect = new Rectangle( const targetRect = new Rectangle(
position.x, position.x,
position.y, position.y,
selectedBounds.width, targetBounds.width,
selectedBounds.height, targetBounds.height
); );
const snapNodeRects = this.getSnapNodeRects({ const snapNodeRects = this.getSnapNodeRects({
@ -127,7 +148,7 @@ export class WorkflowSnapService {
position.x + offset.x, position.x + offset.x,
position.y + offset.y, position.y + offset.y,
targetRect.width, targetRect.width,
targetRect.height, targetRect.height
); );
this.snapEmitter.fire({ this.snapEmitter.fire({
@ -155,23 +176,23 @@ export class WorkflowSnapService {
}); });
// 找到最近的线条 // 找到最近的线条
const topYClosestLine = snapLines.horizontal.find(line => const topYClosestLine = snapLines.horizontal.find((line) =>
isLessThanOrEqual(Math.abs(line.y - targetRect.top), edgeThreshold), isLessThanOrEqual(Math.abs(line.y - targetRect.top), edgeThreshold)
); );
const bottomYClosestLine = snapLines.horizontal.find(line => const bottomYClosestLine = snapLines.horizontal.find((line) =>
isLessThanOrEqual(Math.abs(line.y - targetRect.bottom), edgeThreshold), isLessThanOrEqual(Math.abs(line.y - targetRect.bottom), edgeThreshold)
); );
const leftXClosestLine = snapLines.vertical.find(line => const leftXClosestLine = snapLines.vertical.find((line) =>
isLessThanOrEqual(Math.abs(line.x - targetRect.left), edgeThreshold), isLessThanOrEqual(Math.abs(line.x - targetRect.left), edgeThreshold)
); );
const rightXClosestLine = snapLines.vertical.find(line => const rightXClosestLine = snapLines.vertical.find((line) =>
isLessThanOrEqual(Math.abs(line.x - targetRect.right), edgeThreshold), isLessThanOrEqual(Math.abs(line.x - targetRect.right), edgeThreshold)
); );
const midYClosestLine = snapLines.midHorizontal.find(line => const midYClosestLine = snapLines.midHorizontal.find((line) =>
isLessThanOrEqual(Math.abs(line.y - targetRect.center.y), edgeThreshold), isLessThanOrEqual(Math.abs(line.y - targetRect.center.y), edgeThreshold)
); );
const midXClosestLine = snapLines.midVertical.find(line => const midXClosestLine = snapLines.midVertical.find((line) =>
isLessThanOrEqual(Math.abs(line.x - targetRect.center.x), edgeThreshold), isLessThanOrEqual(Math.abs(line.x - targetRect.center.x), edgeThreshold)
); );
// 计算最近坐标 // 计算最近坐标
@ -227,11 +248,11 @@ export class WorkflowSnapService {
x: snappedPosition.x - rect.x, x: snappedPosition.x - rect.x,
y: snappedPosition.y - rect.y, y: snappedPosition.y - rect.y,
}; };
targetNodes.forEach(node => targetNodes.forEach((node) =>
this.updateNodePositionWithOffset({ this.updateNodePositionWithOffset({
node, node,
offset, offset,
}), })
); );
} }
@ -256,7 +277,7 @@ export class WorkflowSnapService {
const midHorizontalLines: SnapMidHorizontalLine[] = []; const midHorizontalLines: SnapMidHorizontalLine[] = [];
const midVerticalLines: SnapMidVerticalLine[] = []; const midVerticalLines: SnapMidVerticalLine[] = [];
snapNodeRects.forEach(snapNodeRect => { snapNodeRects.forEach((snapNodeRect) => {
const nodeBounds = snapNodeRect.rect; const nodeBounds = snapNodeRect.rect;
const nodeCenter = nodeBounds.center; const nodeCenter = nodeBounds.center;
// 边缘横线 // 边缘横线
@ -310,11 +331,11 @@ export class WorkflowSnapService {
const targetCenter = targetRect.center; const targetCenter = targetRect.center;
const targetContainerId = targetNodes[0].parent?.id ?? this.document.root.id; const targetContainerId = targetNodes[0].parent?.id ?? this.document.root.id;
const disabledNodeIds = targetNodes.map(n => n.id); const disabledNodeIds = targetNodes.map((n) => n.id);
disabledNodeIds.push(FlowNodeBaseType.ROOT); disabledNodeIds.push(FlowNodeBaseType.ROOT);
const availableNodes = this.nodes const availableNodes = this.nodes
.filter(n => n.parent?.id === targetContainerId) .filter((n) => n.parent?.id === targetContainerId)
.filter(n => !disabledNodeIds.includes(n.id)) .filter((n) => !disabledNodeIds.includes(n.id))
.sort((nodeA, nodeB) => { .sort((nodeA, nodeB) => {
const nodeCenterA = nodeA.getData(FlowNodeTransformData)!.bounds.center; const nodeCenterA = nodeA.getData(FlowNodeTransformData)!.bounds.center;
const nodeCenterB = nodeB.getData(FlowNodeTransformData)!.bounds.center; const nodeCenterB = nodeB.getData(FlowNodeTransformData)!.bounds.center;
@ -340,7 +361,7 @@ export class WorkflowSnapService {
const availableNodes = this.getAvailableNodes(params); const availableNodes = this.getAvailableNodes(params);
const viewRect = this.viewRect(); const viewRect = this.viewRect();
return availableNodes return availableNodes
.map(node => { .map((node) => {
const snapNodeRect: SnapNodeRect = { const snapNodeRect: SnapNodeRect = {
id: node.id, id: node.id,
rect: node.getData(FlowNodeTransformData).bounds, rect: node.getData(FlowNodeTransformData).bounds,
@ -367,7 +388,7 @@ export class WorkflowSnapService {
if (nodes.length === 0) { if (nodes.length === 0) {
return Rectangle.EMPTY; return Rectangle.EMPTY;
} }
return Rectangle.enlarge(nodes.map(n => n.getData(FlowNodeTransformData)!.bounds)); return Rectangle.enlarge(nodes.map((n) => n.getData(FlowNodeTransformData)!.bounds));
} }
private updateNodePositionWithOffset(params: { node: WorkflowNodeEntity; offset: IPoint }): void { private updateNodePositionWithOffset(params: { node: WorkflowNodeEntity; offset: IPoint }): void {
@ -379,7 +400,7 @@ export class WorkflowSnapService {
}; };
if (node.collapsedChildren?.length > 0) { if (node.collapsedChildren?.length > 0) {
// 嵌套情况下需将子节点 transform 设为 dirty // 嵌套情况下需将子节点 transform 设为 dirty
node.collapsedChildren.forEach(childNode => { node.collapsedChildren.forEach((childNode) => {
const childNodeTransformData = const childNodeTransformData =
childNode.getData<FlowNodeTransformData>(FlowNodeTransformData); childNode.getData<FlowNodeTransformData>(FlowNodeTransformData);
childNodeTransformData.fireChange(); childNodeTransformData.fireChange();
@ -451,7 +472,7 @@ export class WorkflowSnapService {
const rightAlignX = alignRects.right[0].rect.left - alignSpacing.right; const rightAlignX = alignRects.right[0].rect.left - alignSpacing.right;
const isAlignRight = isLessThanOrEqual( const isAlignRight = isLessThanOrEqual(
Math.abs(targetRect.right - rightAlignX), Math.abs(targetRect.right - rightAlignX),
alignThreshold, alignThreshold
); );
if (isAlignRight) { if (isAlignRight) {
rightX = rightAlignX - targetRect.width; rightX = rightAlignX - targetRect.width;
@ -463,7 +484,7 @@ export class WorkflowSnapService {
const leftAlignX = alignRects.left[0].rect.right + alignSpacing.midHorizontal; const leftAlignX = alignRects.left[0].rect.right + alignSpacing.midHorizontal;
const isAlignMidHorizontal = isLessThanOrEqual( const isAlignMidHorizontal = isLessThanOrEqual(
Math.abs(targetRect.left - leftAlignX), Math.abs(targetRect.left - leftAlignX),
alignThreshold, alignThreshold
); );
if (isAlignMidHorizontal) { if (isAlignMidHorizontal) {
midX = leftAlignX; midX = leftAlignX;
@ -475,7 +496,7 @@ export class WorkflowSnapService {
const topAlignY = alignRects.top[0].rect.bottom + alignSpacing.midVertical; const topAlignY = alignRects.top[0].rect.bottom + alignSpacing.midVertical;
const isAlignMidVertical = isLessThanOrEqual( const isAlignMidVertical = isLessThanOrEqual(
Math.abs(targetRect.top - topAlignY), Math.abs(targetRect.top - topAlignY),
alignThreshold, alignThreshold
); );
if (isAlignMidVertical) { if (isAlignMidVertical) {
midY = topAlignY; midY = topAlignY;
@ -552,7 +573,7 @@ export class WorkflowSnapService {
const leftHorizontalRects: AlignRect[] = []; const leftHorizontalRects: AlignRect[] = [];
const rightHorizontalRects: AlignRect[] = []; const rightHorizontalRects: AlignRect[] = [];
snapNodeRects.forEach(snapNodeRect => { snapNodeRects.forEach((snapNodeRect) => {
const nodeRect = snapNodeRect.rect; const nodeRect = snapNodeRect.rect;
const { isVerticalIntersection, isHorizontalIntersection, isIntersection } = const { isVerticalIntersection, isHorizontalIntersection, isIntersection } =
this.intersection(nodeRect, targetRect); this.intersection(nodeRect, targetRect);
@ -612,7 +633,7 @@ export class WorkflowSnapService {
} }
const { isVerticalIntersection, isHorizontalIntersection, isIntersection } = this.intersection( const { isVerticalIntersection, isHorizontalIntersection, isIntersection } = this.intersection(
rectA, rectA,
rectB, rectB
); );
if (isIntersection) { if (isIntersection) {
return; return;
@ -620,13 +641,13 @@ export class WorkflowSnapService {
if (isHorizontal && isHorizontalIntersection && !isVerticalIntersection) { if (isHorizontal && isHorizontalIntersection && !isVerticalIntersection) {
const betweenSpacing = Math.min( const betweenSpacing = Math.min(
Math.abs(rectA.left - rectB.right), Math.abs(rectA.left - rectB.right),
Math.abs(rectA.right - rectB.left), Math.abs(rectA.right - rectB.left)
); );
return (betweenSpacing - targetRect.width) / 2; return (betweenSpacing - targetRect.width) / 2;
} else if (!isHorizontal && isVerticalIntersection && !isHorizontalIntersection) { } else if (!isHorizontal && isVerticalIntersection && !isHorizontalIntersection) {
const betweenSpacing = Math.min( const betweenSpacing = Math.min(
Math.abs(rectA.top - rectB.bottom), Math.abs(rectA.top - rectB.bottom),
Math.abs(rectA.bottom - rectB.top), Math.abs(rectA.bottom - rectB.top)
); );
return (betweenSpacing - targetRect.height) / 2; return (betweenSpacing - targetRect.height) / 2;
} }
@ -646,7 +667,7 @@ export class WorkflowSnapService {
const { isVerticalIntersection, isHorizontalIntersection, isIntersection } = this.intersection( const { isVerticalIntersection, isHorizontalIntersection, isIntersection } = this.intersection(
rectA, rectA,
rectB, rectB
); );
if (isIntersection) { if (isIntersection) {
@ -663,7 +684,7 @@ export class WorkflowSnapService {
private intersection( private intersection(
rectA: Rectangle, rectA: Rectangle,
rectB: Rectangle, rectB: Rectangle
): { ): {
isHorizontalIntersection: boolean; isHorizontalIntersection: boolean;
isVerticalIntersection: boolean; isVerticalIntersection: boolean;