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