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,50 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import React, { FC, ReactNode, useEffect, useRef } from 'react';
import { PositionSchema } from '@flowgram.ai/utils';
interface NodePanelContainerProps {
onSelect: (nodeType: string | undefined) => void;
position: PositionSchema;
children: ReactNode;
}
export const NodePanelContainer: FC<NodePanelContainerProps> = (props) => {
const { onSelect, position, children } = props;
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
// 确保点击事件的目标不是组件本身或其子元素
if (panelRef.current && !panelRef.current.contains(event.target as Node)) {
onSelect(undefined);
}
};
// 添加事件监听器到document
document.addEventListener('mousedown', handleClickOutside);
// 清理函数
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [onSelect]); // 依赖项为onSelect,确保回调函数变化时重新绑定
return (
<div
ref={panelRef}
className="node-panel-container"
data-flow-editor-selectable="false"
style={{
position: 'absolute',
zIndex: 9999,
top: position.y,
left: position.x,
}}
>
{children}
</div>
);
};
@@ -0,0 +1,25 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { definePluginCreator, type PluginBindConfig, type PluginContext } from '@flowgram.ai/core';
import { NodePanelPluginOptions } from './type';
import { WorkflowNodePanelService } from './service';
import { WorkflowNodePanelLayer } from './layer';
export const createFreeNodePanelPlugin = definePluginCreator({
onBind({ bind }: PluginBindConfig) {
bind(WorkflowNodePanelService).toSelf().inSingletonScope();
},
onInit: (ctx: PluginContext, opts: NodePanelPluginOptions) => {
ctx.playground.registerLayer(WorkflowNodePanelLayer, {
renderer: opts.renderer,
});
},
onDispose: (ctx: PluginContext) => {
const nodePanelService = ctx.get(WorkflowNodePanelService);
nodePanelService.dispose();
},
});
@@ -0,0 +1,16 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { createFreeNodePanelPlugin } from './create-plugin';
export { WorkflowNodePanelService } from './service';
export type {
NodePanelResult,
NodePanelRenderProps,
NodePanelRender,
NodePanelLayerOptions as NodePanelServiceOptions,
NodePanelPluginOptions,
CallNodePanelParams,
} from './type';
export { type IWorkflowNodePanelUtils, WorkflowNodePanelUtils } from './utils';
@@ -0,0 +1,88 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
/* eslint-disable react/no-deprecated */
import React from 'react';
import { inject } from 'inversify';
import { domUtils } from '@flowgram.ai/utils';
import { nanoid } from '@flowgram.ai/free-layout-core';
import { Layer } from '@flowgram.ai/core';
import type {
CallNodePanelParams,
NodePanelLayerOptions,
NodePanelRenderProps,
NodePanelResult,
} from './type';
import { WorkflowNodePanelService } from './service';
export class WorkflowNodePanelLayer extends Layer<NodePanelLayerOptions> {
public static type = 'WorkflowNodePanelLayer';
@inject(WorkflowNodePanelService) private service: WorkflowNodePanelService;
public node: HTMLDivElement;
private renderList: Map<string, NodePanelRenderProps>;
constructor() {
super();
this.node = domUtils.createDivWithClass('gedit-playground-layer gedit-node-panel-layer');
this.node.style.zIndex = '9999';
this.renderList = new Map();
}
public onReady(): void {
this.service.setCallNodePanel(this.call.bind(this));
}
public onZoom(zoom: number): void {
this.node.style.transform = `scale(${zoom})`;
}
public render(): JSX.Element {
const NodePanelRender = this.options.renderer;
return (
<>
{Array.from(this.renderList.keys()).map((taskId) => {
const renderProps = this.renderList.get(taskId)!;
return <NodePanelRender key={taskId} {...renderProps} />;
})}
</>
);
}
private async call(params: CallNodePanelParams): Promise<void> {
const taskId = nanoid();
const { onSelect, onClose, enableMultiAdd = false, panelProps = {} } = params;
return new Promise((resolve) => {
const unmount = () => {
// 清理挂载的组件
this.renderList.delete(taskId);
this.render();
resolve();
};
const handleClose = () => {
unmount();
onClose();
};
const handleSelect = (params?: NodePanelResult) => {
onSelect(params);
if (!enableMultiAdd) {
unmount();
}
};
const renderProps: NodePanelRenderProps = {
...params,
panelProps,
onSelect: handleSelect,
onClose: handleClose,
};
this.renderList.set(taskId, renderProps);
this.render();
});
}
}
@@ -0,0 +1,232 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { inject, injectable } from 'inversify';
import { DisposableCollection } from '@flowgram.ai/utils';
import type { PositionSchema } from '@flowgram.ai/utils';
import {
WorkflowDocument,
WorkflowDragService,
WorkflowLinesManager,
WorkflowNodeEntity,
} from '@flowgram.ai/free-layout-core';
import { WorkflowSelectService } from '@flowgram.ai/free-layout-core';
import { WorkflowNodeJSON } from '@flowgram.ai/free-layout-core';
import { HistoryService } from '@flowgram.ai/free-history-plugin';
import { PlaygroundConfigEntity } from '@flowgram.ai/core';
import { WorkflowNodePanelUtils } from './utils';
import type {
CallNodePanel,
CallNodePanelParams,
NodePanelCallParams,
NodePanelResult,
} from './type';
/**
* 添加节点面板服务
*/
@injectable()
export class WorkflowNodePanelService {
@inject(WorkflowDocument) private readonly document: WorkflowDocument;
@inject(WorkflowDragService)
private readonly dragService: WorkflowDragService;
@inject(WorkflowSelectService)
private readonly selectService: WorkflowSelectService;
@inject(WorkflowLinesManager)
private readonly linesManager: WorkflowLinesManager;
@inject(PlaygroundConfigEntity)
private readonly playgroundConfig: PlaygroundConfigEntity;
@inject(HistoryService) private readonly historyService: HistoryService;
private readonly toDispose = new DisposableCollection();
public callNodePanel: CallNodePanel = async () => undefined;
/** 销毁 */
public dispose(): void {
this.toDispose.dispose();
}
public setCallNodePanel(callNodePanel: CallNodePanel) {
this.callNodePanel = callNodePanel;
}
/** 唤起节点面板 */
public async call(
callParams: NodePanelCallParams
): Promise<WorkflowNodeEntity | WorkflowNodeEntity[] | undefined> {
const {
panelPosition,
fromPort,
enableMultiAdd = false,
panelProps = {},
containerNode,
afterAddNode,
} = callParams;
if (!panelPosition || this.playgroundConfig.readonly) {
return;
}
const nodes: WorkflowNodeEntity[] = [];
return new Promise((resolve) => {
this.callNodePanel({
position: panelPosition,
enableMultiAdd,
panelProps,
containerNode: WorkflowNodePanelUtils.getContainerNode({
fromPort,
containerNode,
}),
onSelect: async (panelParams?: NodePanelResult) => {
const node = await this.addNode(callParams, panelParams);
afterAddNode?.(node);
if (!enableMultiAdd) {
resolve(node);
} else if (node) {
nodes.push(node);
}
},
onClose: () => {
resolve(enableMultiAdd ? nodes : undefined);
},
});
});
}
/**
* 唤起单选面板
*/
public async singleSelectNodePanel(
params: Omit<CallNodePanelParams, 'onSelect' | 'onClose' | 'enableMultiAdd'>
): Promise<NodePanelResult | undefined> {
return new Promise((resolve) => {
this.callNodePanel({
...params,
enableMultiAdd: false,
onSelect: async (panelParams?: NodePanelResult) => {
resolve(panelParams);
},
onClose: () => {
resolve(undefined);
},
});
});
}
/** 添加节点 */
private async addNode(
callParams: NodePanelCallParams,
panelParams: NodePanelResult
): Promise<WorkflowNodeEntity | undefined> {
const {
panelPosition,
fromPort,
toPort,
canAddNode,
autoOffsetPadding = {
x: 100,
y: 100,
},
enableBuildLine = false,
enableSelectPosition = false,
enableAutoOffset = false,
enableDragNode = false,
} = callParams;
if (!panelPosition || !panelParams) {
return;
}
const { nodeType, selectEvent, nodeJSON } = panelParams;
const containerNode = WorkflowNodePanelUtils.getContainerNode({
fromPort,
containerNode: callParams.containerNode,
});
// 判断是否可以添加节点
if (canAddNode) {
const canAdd = canAddNode({ nodeType, containerNode });
if (!canAdd) {
return;
}
}
// 鼠标选择坐标
const selectPosition = this.playgroundConfig.getPosFromMouseEvent(selectEvent);
// 自定义坐标
const nodePosition: PositionSchema = callParams.customPosition
? callParams.customPosition({ nodeType, selectPosition })
: WorkflowNodePanelUtils.adjustNodePosition({
nodeType,
position: enableSelectPosition ? selectPosition : panelPosition,
fromPort,
toPort,
containerNode,
document: this.document,
dragService: this.dragService,
});
// 创建节点
const node: WorkflowNodeEntity = this.document.createWorkflowNodeByType(
nodeType,
nodePosition,
nodeJSON ?? ({} as WorkflowNodeJSON),
containerNode?.id
);
if (!node) {
return;
}
// 后续节点偏移
if (enableAutoOffset && fromPort && toPort) {
WorkflowNodePanelUtils.subNodesAutoOffset({
node,
fromPort,
toPort,
padding: autoOffsetPadding,
containerNode,
historyService: this.historyService,
dragService: this.dragService,
linesManager: this.linesManager,
});
}
if (!enableBuildLine && !enableDragNode) {
return node;
}
// 等待节点渲染
await WorkflowNodePanelUtils.waitNodeRender();
// 重建连线(需先让端口完成渲染)
if (enableBuildLine) {
WorkflowNodePanelUtils.buildLine({
fromPort,
node,
toPort,
linesManager: this.linesManager,
});
}
// 开始拖拽节点
if (enableDragNode) {
this.selectService.selectNode(node);
this.dragService.startDragSelectedNodes(selectEvent);
}
return node;
}
}
@@ -0,0 +1,70 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import type React from 'react';
import type { PositionSchema } from '@flowgram.ai/utils';
import type { WorkflowNodeEntity, WorkflowPortEntity } from '@flowgram.ai/free-layout-core';
import type { WorkflowNodeJSON } from '@flowgram.ai/free-layout-core';
export interface NodePanelCallParams {
/** 唤起节点面板的位置 */
panelPosition: PositionSchema;
/** 自定义传给面板组件的props */
panelProps?: Record<string, any>;
/** 指定父节点 */
containerNode?: WorkflowNodeEntity;
/** 创建节点后需连接的前序端口 */
fromPort?: WorkflowPortEntity;
/** 创建节点后需连接的后序端口 */
toPort?: WorkflowPortEntity;
/** 偏移后续节点默认间距 */
autoOffsetPadding?: PositionSchema;
/** 自定义节点位置 */
customPosition?: (params: { nodeType: string; selectPosition: PositionSchema }) => PositionSchema;
/** 是否可以添加节点 */
canAddNode?: (params: { nodeType: string; containerNode?: WorkflowNodeEntity }) => boolean;
/** 创建节点后 */
afterAddNode?: (node?: WorkflowNodeEntity) => void;
/** 是否创建线条 */
enableBuildLine?: boolean;
/** 是否使用选中位置作为节点位置 */
enableSelectPosition?: boolean;
/** 是否偏移后续节点 */
enableAutoOffset?: boolean;
/** 是否触发节点拖拽 */
enableDragNode?: boolean;
/** 是否可以添加多个节点 */
enableMultiAdd?: boolean;
}
export type NodePanelResult =
| {
nodeType: string;
nodeJSON?: WorkflowNodeJSON;
selectEvent: React.MouseEvent;
}
| undefined;
export interface CallNodePanelParams {
onSelect: (params?: NodePanelResult) => void;
position: PositionSchema;
onClose: () => void;
panelProps?: Record<string, any>;
enableMultiAdd?: boolean;
containerNode?: WorkflowNodeEntity;
}
export type CallNodePanel = (params: CallNodePanelParams) => Promise<void>;
export interface NodePanelRenderProps extends CallNodePanelParams {}
export type NodePanelRender = React.FC<NodePanelRenderProps>;
export interface NodePanelLayerOptions {
renderer: NodePanelRender;
}
export interface NodePanelPluginOptions extends NodePanelLayerOptions {}
@@ -0,0 +1,63 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { PositionSchema } from '@flowgram.ai/utils';
import {
WorkflowDocument,
WorkflowDragService,
WorkflowNodeEntity,
WorkflowPortEntity,
} from '@flowgram.ai/free-layout-core';
export type IAdjustNodePosition = (params: {
nodeType: string;
position: PositionSchema;
document: WorkflowDocument;
dragService: WorkflowDragService;
fromPort?: WorkflowPortEntity;
toPort?: WorkflowPortEntity;
containerNode?: WorkflowNodeEntity;
}) => PositionSchema;
/** 调整节点坐标 */
export const adjustNodePosition: IAdjustNodePosition = (params) => {
const { nodeType, position, fromPort, toPort, containerNode, document, dragService } = params;
const register = document.getNodeRegistry(nodeType);
const size = register?.meta?.size;
let adjustedPosition = position;
if (!size) {
adjustedPosition = position;
}
// 计算坐标偏移
else if (fromPort && toPort) {
// 输入输出
adjustedPosition = {
x: position.x,
y: position.y - size.height / 2,
};
} else if (fromPort && !toPort) {
// 仅输入
if (fromPort.location === 'bottom') {
adjustedPosition = {
x: position.x,
y: position.y,
};
} else {
adjustedPosition = {
x: position.x + size.width / 2,
y: position.y - size.height / 2,
};
}
} else if (!fromPort && toPort) {
// 仅输出
adjustedPosition = {
x: position.x - size.width / 2,
y: position.y - size.height / 2,
};
} else {
adjustedPosition = position;
}
return dragService.adjustSubNodePosition(nodeType, containerNode, adjustedPosition);
};
@@ -0,0 +1,51 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import {
WorkflowLinesManager,
WorkflowNodeEntity,
WorkflowNodePortsData,
WorkflowPortEntity,
} from '@flowgram.ai/free-layout-core';
export type IBuildLine = (params: {
node: WorkflowNodeEntity;
linesManager: WorkflowLinesManager;
fromPort?: WorkflowPortEntity;
toPort?: WorkflowPortEntity;
}) => void;
/** 建立连线 */
export const buildLine: IBuildLine = (params) => {
const { fromPort, node, toPort, linesManager } = params;
const portsData = node.getData(WorkflowNodePortsData);
if (!portsData) {
return;
}
const shouldBuildFromLine = portsData.inputPorts?.length > 0;
if (fromPort && shouldBuildFromLine) {
const isVertical = fromPort.location === 'bottom';
const toTargetPort =
portsData.inputPorts.find((port) => (isVertical ? port.location === 'top' : true)) ||
portsData.inputPorts[0];
linesManager.createLine({
from: fromPort.node.id,
fromPort: fromPort.portID,
to: node.id,
toPort: toTargetPort.portID,
});
}
const shouldBuildToLine = portsData.outputPorts?.length > 0;
if (toPort && shouldBuildToLine) {
const fromTargetPort = portsData.outputPorts[0];
linesManager.createLine({
from: node.id,
fromPort: fromTargetPort.portID,
to: toPort.node.id,
toPort: toPort.portID,
});
}
};
@@ -0,0 +1,28 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { WorkflowNodeEntity, WorkflowPortEntity } from '@flowgram.ai/free-layout-core';
import { isContainer } from './is-container';
export type IGetContainerNode = (params: {
containerNode?: WorkflowNodeEntity;
fromPort?: WorkflowPortEntity;
toPort?: WorkflowPortEntity;
}) => WorkflowNodeEntity | undefined;
export const getContainerNode: IGetContainerNode = (params) => {
const { fromPort, containerNode } = params;
if (containerNode) {
return containerNode;
}
const fromNode = fromPort?.node;
const fromContainer = fromNode?.parent;
if (isContainer(fromNode)) {
// 子画布内部输入连线
return fromNode;
}
return fromContainer;
};
@@ -0,0 +1,30 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { IPoint, Rectangle } from '@flowgram.ai/utils';
import { WorkflowPortEntity } from '@flowgram.ai/free-layout-core';
import { FlowNodeTransformData } from '@flowgram.ai/document';
import { isContainer } from './is-container';
export type IGetPortBox = (port: WorkflowPortEntity, offset?: IPoint) => Rectangle;
/** 获取端口矩形 */
export const getPortBox: IGetPortBox = (
port: WorkflowPortEntity,
offset: IPoint = { x: 0, y: 0 }
): Rectangle => {
const node = port.node;
if (isContainer(node)) {
// 子画布内部端口需要虚拟节点
const { point } = port;
if (port.portType === 'input') {
return new Rectangle(point.x + offset.x, point.y - 50 + offset.y, 300, 100);
}
return new Rectangle(point.x - 300, point.y - 50, 300, 100);
}
const box = node.getData(FlowNodeTransformData).bounds;
return box;
};
@@ -0,0 +1,50 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { WorkflowNodeEntity, WorkflowLinesManager } from '@flowgram.ai/free-layout-core';
import { isContainer } from './is-container';
export type IGetSubsequentNodes = (params: {
node: WorkflowNodeEntity;
linesManager: WorkflowLinesManager;
}) => WorkflowNodeEntity[];
/** 获取后续节点 */
export const getSubsequentNodes: IGetSubsequentNodes = (params) => {
const { node, linesManager } = params;
if (isContainer(node)) {
return [];
}
const brothers = node.parent?.blocks ?? [];
const linkedBrothers = new Set();
const linesMap = new Map<string, string[]>();
linesManager.getAllAvailableLines().forEach((line) => {
if (!linesMap.has(line.from!.id)) {
linesMap.set(line.from!.id, []);
}
if (
!line.to?.id ||
isContainer(line.to) // 子画布内部成环
) {
return;
}
linesMap.get(line.from!.id)?.push(line.to.id);
});
const bfs = (nodeId: string) => {
if (linkedBrothers.has(nodeId)) {
return;
}
linkedBrothers.add(nodeId);
const nextNodes = linesMap.get(nodeId) ?? [];
nextNodes.forEach(bfs);
};
bfs(node.id);
const subsequentNodes = brothers.filter((node) => linkedBrothers.has(node.id));
return subsequentNodes;
};
@@ -0,0 +1,42 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
/**
* 检查 a 是否大于 b,考虑浮点数精度问题
* @param a 第一个数
* @param b 第二个数
* @returns 如果 a 大于 b 则返回 true,否则返回 false
*/
export const isGreaterThan = (a: number | undefined, b: number | undefined): boolean => {
// 如果任一参数为 undefined,返回 false
if (a === undefined || b === undefined) {
return false;
}
// 定义一个很小的误差值
const EPSILON: number = 0.00001;
// 检查 a 是否显著大于 b
return a - b > EPSILON;
};
/**
* 检查 a 是否小于 b,考虑浮点数精度问题
* @param a 第一个数
* @param b 第二个数
* @returns 如果 a 小于 b 则返回 true,否则返回 false
*/
export const isLessThan = (a: number | undefined, b: number | undefined): boolean => {
// 如果任一参数为 undefined,返回 false
if (a === undefined || b === undefined) {
return false;
}
// 定义一个很小的误差值
const EPSILON: number = 0.00001;
// 检查 a 是否显著小于 b
return b - a > EPSILON;
};
@@ -0,0 +1,44 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { IWaitNodeRender, waitNodeRender } from './wait-node-render';
import {
updateSubSequentNodesPosition,
IUpdateSubSequentNodesPosition,
} from './update-sub-nodes-position';
import { subPositionOffset, ISubPositionOffset } from './sub-position-offset';
import { subNodesAutoOffset, ISubNodesAutoOffset } from './sub-nodes-auto-offset';
import { rectDistance, IRectDistance } from './rect-distance';
import { getSubsequentNodes, IGetSubsequentNodes } from './get-sub-nodes';
import { getPortBox, IGetPortBox } from './get-port-box';
import { getContainerNode, IGetContainerNode } from './get-container-node';
import { buildLine, IBuildLine } from './build-line';
import { adjustNodePosition, IAdjustNodePosition } from './adjust-node-position';
export interface IWorkflowNodePanelUtils {
adjustNodePosition: IAdjustNodePosition;
buildLine: IBuildLine;
getPortBox: IGetPortBox;
getSubsequentNodes: IGetSubsequentNodes;
getContainerNode: IGetContainerNode;
rectDistance: IRectDistance;
subNodesAutoOffset: ISubNodesAutoOffset;
subPositionOffset: ISubPositionOffset;
updateSubSequentNodesPosition: IUpdateSubSequentNodesPosition;
waitNodeRender: IWaitNodeRender;
}
export const WorkflowNodePanelUtils: IWorkflowNodePanelUtils = {
adjustNodePosition,
buildLine,
getPortBox,
getSubsequentNodes,
getContainerNode,
rectDistance,
subNodesAutoOffset,
subPositionOffset,
updateSubSequentNodesPosition,
waitNodeRender,
};
@@ -0,0 +1,10 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { WorkflowNodeEntity, WorkflowNodeMeta } from '@flowgram.ai/free-layout-core';
/** 是否容器节点 */
export const isContainer = (node?: WorkflowNodeEntity): boolean =>
node?.getNodeMeta<WorkflowNodeMeta>().isContainer ?? false;
@@ -0,0 +1,27 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { Rectangle, IPoint } from '@flowgram.ai/utils';
export type IRectDistance = (rectA: Rectangle, rectB: Rectangle) => IPoint;
/** 矩形间距 */
export const rectDistance: IRectDistance = (rectA, rectB) => {
// 计算 x 轴距离
const distanceX = Math.abs(Math.min(rectA.right, rectB.right) - Math.max(rectA.left, rectB.left));
// 计算 y 轴距离
const distanceY = Math.abs(Math.min(rectA.bottom, rectB.bottom) - Math.max(rectA.top, rectB.top));
if (Rectangle.intersects(rectA, rectB)) {
// 相交距离为负
return {
x: -distanceX,
y: -distanceY,
};
}
return {
x: distanceX,
y: distanceY,
};
};
@@ -0,0 +1,63 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import {
WorkflowDragService,
WorkflowLinesManager,
WorkflowNodeEntity,
WorkflowPortEntity,
} from '@flowgram.ai/free-layout-core';
import { HistoryService } from '@flowgram.ai/free-history-plugin';
import { updateSubSequentNodesPosition } from './update-sub-nodes-position';
import { subPositionOffset, XYSchema } from './sub-position-offset';
import { getSubsequentNodes } from './get-sub-nodes';
export type ISubNodesAutoOffset = (params: {
node: WorkflowNodeEntity;
fromPort: WorkflowPortEntity;
toPort: WorkflowPortEntity;
padding?: XYSchema;
linesManager: WorkflowLinesManager;
historyService: HistoryService;
dragService: WorkflowDragService;
containerNode?: WorkflowNodeEntity;
}) => void;
export const subNodesAutoOffset: ISubNodesAutoOffset = (params) => {
const {
node,
fromPort,
toPort,
linesManager,
historyService,
dragService,
containerNode,
padding = {
x: 100,
y: 100,
},
} = params;
const subOffset = subPositionOffset({
node,
fromPort,
toPort,
padding,
});
const subsequentNodes = getSubsequentNodes({
node: toPort.node,
linesManager,
});
updateSubSequentNodesPosition({
node,
subsequentNodes,
fromPort,
toPort,
containerNode,
offset: subOffset,
historyService,
dragService,
});
};
@@ -0,0 +1,95 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { IPoint, Rectangle } from '@flowgram.ai/utils';
import { WorkflowNodeEntity, WorkflowPortEntity } from '@flowgram.ai/free-layout-core';
import { FlowNodeTransformData } from '@flowgram.ai/document';
import { rectDistance } from './rect-distance';
import { isGreaterThan, isLessThan } from './greater-or-less';
import { getPortBox } from './get-port-box';
export interface XYSchema {
x: number;
y: number;
}
export type ISubPositionOffset = (params: {
node: WorkflowNodeEntity;
fromPort: WorkflowPortEntity;
toPort: WorkflowPortEntity;
padding: XYSchema;
}) => XYSchema | undefined;
/** 后续节点位置偏移 */
export const subPositionOffset: ISubPositionOffset = (params) => {
const { node, fromPort, toPort, padding } = params;
const fromBox = getPortBox(fromPort);
const toBox = getPortBox(toPort);
const nodeTrans = node.getData(FlowNodeTransformData);
const nodeSize = node.getNodeMeta()?.size ?? {
width: nodeTrans.bounds.width,
height: nodeTrans.bounds.height,
};
// 最小距离
const minDistance: IPoint = {
x: nodeSize.width + padding.x,
y: nodeSize.height + padding.y,
};
// from 与 to 的距离
const boxDistance = rectDistance(fromBox, toBox);
// 需要的偏移量
const neededOffset: IPoint = {
x: isGreaterThan(boxDistance.x, minDistance.x) ? 0 : minDistance.x - boxDistance.x,
y: isGreaterThan(boxDistance.y, minDistance.y) ? 0 : minDistance.y - boxDistance.y,
};
// 至少有一个方向满足要求,无需偏移
if (neededOffset.x === 0 || neededOffset.y === 0) {
return;
}
// 是否存在相交
const intersection = {
// 这里没有写反,Rectangle内置的算法是反的
vertical: Rectangle.intersects(fromBox, toBox, 'horizontal'),
horizontal: Rectangle.intersects(fromBox, toBox, 'vertical'),
};
// 初始化偏移量
let offsetX: number = 0;
let offsetY: number = 0;
if (!intersection.horizontal) {
// 水平不相交,需要加垂直方向的偏移
if (isGreaterThan(toBox.center.y, fromBox.center.y)) {
// B在A下方
offsetY = neededOffset.y;
} else if (isLessThan(toBox.center.y, fromBox.center.y)) {
// B在A上方
offsetY = -neededOffset.y;
}
}
if (!intersection.vertical) {
// 垂直不相交,需要加水平方向的偏移
if (isGreaterThan(toBox.center.x, fromBox.center.x)) {
// B在A右侧
offsetX = neededOffset.x;
} else if (isLessThan(toBox.center.x, fromBox.center.x)) {
// B在A左侧
offsetX = -neededOffset.x;
}
}
return {
x: offsetX,
y: offsetY,
};
};
@@ -0,0 +1,90 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { IPoint, PositionSchema } from '@flowgram.ai/utils';
import {
WorkflowNodeEntity,
WorkflowPortEntity,
WorkflowDragService,
} from '@flowgram.ai/free-layout-core';
import { FreeOperationType, HistoryService } from '@flowgram.ai/free-history-plugin';
import { TransformData } from '@flowgram.ai/core';
import { getPortBox } from './get-port-box';
export type IUpdateSubSequentNodesPosition = (params: {
node: WorkflowNodeEntity;
subsequentNodes: WorkflowNodeEntity[];
fromPort: WorkflowPortEntity;
toPort: WorkflowPortEntity;
historyService: HistoryService;
dragService: WorkflowDragService;
containerNode?: WorkflowNodeEntity;
offset?: IPoint;
}) => void;
/** 更新后续节点位置 */
export const updateSubSequentNodesPosition: IUpdateSubSequentNodesPosition = (params) => {
const {
node,
subsequentNodes,
fromPort,
toPort,
containerNode,
offset,
historyService,
dragService,
} = params;
if (!offset || !toPort) {
return;
}
// 更新后续节点位置
const subsequentNodesPositions = subsequentNodes.map((node) => {
const nodeTrans = node.getData(TransformData);
return {
x: nodeTrans.position.x,
y: nodeTrans.position.y,
};
});
historyService.pushOperation({
type: FreeOperationType.dragNodes,
value: {
ids: subsequentNodes.map((node) => node.id),
value: subsequentNodesPositions.map((position) => ({
x: position.x + offset.x,
y: position.y + offset.y,
})),
oldValue: subsequentNodesPositions,
},
});
// 新增节点坐标需重新计算
const fromBox = getPortBox(fromPort);
const toBox = getPortBox(toPort, offset);
const nodeTrans = node.getData(TransformData);
let nodePos: PositionSchema = {
x: (fromBox.center.x + toBox.center.x) / 2,
y: (fromBox.y + toBox.y) / 2,
};
if (containerNode) {
nodePos = dragService.adjustSubNodePosition(
node.flowNodeType as string,
containerNode,
nodePos
);
}
historyService.pushOperation({
type: FreeOperationType.dragNodes,
value: {
ids: [node.id],
value: [nodePos],
oldValue: [
{
x: nodeTrans.position.x,
y: nodeTrans.position.y,
},
],
},
});
};
@@ -0,0 +1,12 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { delay } from '@flowgram.ai/free-layout-core';
export type IWaitNodeRender = () => Promise<void>;
export const waitNodeRender: IWaitNodeRender = async () => {
await delay(20);
};