新版本流程编辑器
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import {
|
||||
FreeLayoutPluginContext,
|
||||
ShortcutsHandler,
|
||||
WorkflowSelectService,
|
||||
} from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { FlowCommandId } from '../constants';
|
||||
|
||||
export class CollapseShortcut implements ShortcutsHandler {
|
||||
public commandId = FlowCommandId.COLLAPSE;
|
||||
|
||||
public commandDetail: ShortcutsHandler['commandDetail'] = {
|
||||
label: 'Collapse',
|
||||
};
|
||||
|
||||
public shortcuts = ['meta alt openbracket', 'ctrl alt openbracket'];
|
||||
|
||||
private selectService: WorkflowSelectService;
|
||||
|
||||
constructor(context: FreeLayoutPluginContext) {
|
||||
this.selectService = context.get(WorkflowSelectService);
|
||||
this.execute = this.execute.bind(this);
|
||||
}
|
||||
|
||||
public async execute(): Promise<void> {
|
||||
this.selectService.selectedNodes.forEach((node) => {
|
||||
node.renderData.expanded = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export const WorkflowClipboardDataID = 'flowgram-workflow-clipboard-data';
|
||||
|
||||
export enum FlowCommandId {
|
||||
COPY = 'COPY',
|
||||
PASTE = 'PASTE',
|
||||
CUT = 'CUT',
|
||||
GROUP = 'GROUP',
|
||||
UNGROUP = 'UNGROUP',
|
||||
COLLAPSE = 'COLLAPSE',
|
||||
EXPAND = 'EXPAND',
|
||||
DELETE = 'DELETE',
|
||||
ZOOM_IN = 'ZOOM_IN',
|
||||
ZOOM_OUT = 'ZOOM_OUT',
|
||||
RESET_ZOOM = 'RESET_ZOOM',
|
||||
SELECT_ALL = 'SELECT_ALL',
|
||||
CANCEL_SELECT = 'CANCEL_SELECT',
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import {
|
||||
FlowNodeBaseType,
|
||||
FreeLayoutPluginContext,
|
||||
PlaygroundConfigEntity,
|
||||
Rectangle,
|
||||
ShortcutsHandler,
|
||||
TransformData,
|
||||
WorkflowDocument,
|
||||
WorkflowEdgeJSON,
|
||||
WorkflowJSON,
|
||||
WorkflowLineEntity,
|
||||
WorkflowNodeEntity,
|
||||
WorkflowNodeJSON,
|
||||
WorkflowNodeMeta,
|
||||
WorkflowSelectService,
|
||||
} from '@flowgram.ai/free-layout-editor';
|
||||
import { Toast } from '@douyinfe/semi-ui';
|
||||
|
||||
import type {
|
||||
WorkflowClipboardRect,
|
||||
WorkflowClipboardSource,
|
||||
WorkflowClipboardData,
|
||||
} from '../type';
|
||||
import { FlowCommandId, WorkflowClipboardDataID } from '../constants';
|
||||
import { WorkflowNodeType } from '../../nodes';
|
||||
|
||||
export class CopyShortcut implements ShortcutsHandler {
|
||||
public commandId = FlowCommandId.COPY;
|
||||
|
||||
public shortcuts = ['meta c', 'ctrl c'];
|
||||
|
||||
private playgroundConfig: PlaygroundConfigEntity;
|
||||
|
||||
private document: WorkflowDocument;
|
||||
|
||||
private selectService: WorkflowSelectService;
|
||||
|
||||
constructor(context: FreeLayoutPluginContext) {
|
||||
this.playgroundConfig = context.playground.config;
|
||||
this.document = context.get(WorkflowDocument);
|
||||
this.selectService = context.get(WorkflowSelectService);
|
||||
this.execute = this.execute.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* execute copy operation - 执行复制操作
|
||||
*/
|
||||
public async execute(): Promise<void> {
|
||||
if (this.readonly || (await this.hasSelectedText())) {
|
||||
return;
|
||||
}
|
||||
if (!this.isValid(this.selectedNodes)) {
|
||||
return;
|
||||
}
|
||||
const data = this.toClipboardData();
|
||||
await this.write(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* create clipboard data - 转换为剪贴板数据
|
||||
*/
|
||||
public toClipboardData(nodes?: WorkflowNodeEntity[]): WorkflowClipboardData {
|
||||
const validNodes = this.getValidNodes(nodes ? nodes : this.selectedNodes);
|
||||
const source = this.toSource();
|
||||
const json = this.toJSON(validNodes);
|
||||
const bounds = this.getEntireBounds(validNodes);
|
||||
return {
|
||||
type: WorkflowClipboardDataID,
|
||||
source,
|
||||
json,
|
||||
bounds,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* readonly - 是否只读
|
||||
*/
|
||||
private get readonly(): boolean {
|
||||
return this.playgroundConfig.readonly;
|
||||
}
|
||||
|
||||
/**
|
||||
* has selected text - 是否有文字被选中
|
||||
*/
|
||||
private async hasSelectedText(): Promise<boolean> {
|
||||
if (!window.getSelection()?.toString()) {
|
||||
return false;
|
||||
}
|
||||
await navigator.clipboard.writeText(window.getSelection()?.toString() ?? '');
|
||||
Toast.success({
|
||||
content: 'Text copied',
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* get selected nodes - 获取选中的节点
|
||||
*/
|
||||
private get selectedNodes(): WorkflowNodeEntity[] {
|
||||
return this.selectService.selection.filter(
|
||||
(n) => n instanceof WorkflowNodeEntity
|
||||
) as WorkflowNodeEntity[];
|
||||
}
|
||||
|
||||
/**
|
||||
* validate selected nodes - 验证选中的节点
|
||||
*/
|
||||
private isValid(nodes: WorkflowNodeEntity[]): boolean {
|
||||
if (nodes.length === 0) {
|
||||
Toast.warning({
|
||||
content: 'No nodes selected',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* get valid nodes - 获取有效的节点
|
||||
*/
|
||||
private getValidNodes(nodes: WorkflowNodeEntity[]): WorkflowNodeEntity[] {
|
||||
return nodes.filter((n) => {
|
||||
if (
|
||||
[WorkflowNodeType.Start, WorkflowNodeType.End].includes(n.flowNodeType as WorkflowNodeType)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (n.getNodeMeta<WorkflowNodeMeta>().copyDisable) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* get source data - 获取来源数据
|
||||
*/
|
||||
private toSource(): WorkflowClipboardSource {
|
||||
return {
|
||||
host: window.location.host,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* convert nodes to JSON - 将节点转换为JSON
|
||||
*/
|
||||
private toJSON(nodes: WorkflowNodeEntity[]): WorkflowJSON {
|
||||
const nodeJSONs = this.getNodeJSONs(nodes);
|
||||
const edgeJSONs = this.getEdgeJSONs(nodes);
|
||||
return {
|
||||
nodes: nodeJSONs,
|
||||
edges: edgeJSONs,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* get JSON representation of nodes - 获取节点的JSON表示
|
||||
*/
|
||||
private getNodeJSONs(nodes: WorkflowNodeEntity[]): WorkflowNodeJSON[] {
|
||||
const nodeJSONs = nodes.map((node) =>
|
||||
node.flowNodeType === FlowNodeBaseType.GROUP
|
||||
? this.getGroupNodeJSON(node)
|
||||
: this.document.toNodeJSON(node)
|
||||
);
|
||||
return nodeJSONs.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* get JSON representation of group node - 获取分组节点的JSON
|
||||
*/
|
||||
private getGroupNodeJSON(node: WorkflowNodeEntity): WorkflowNodeJSON {
|
||||
const rawJSON = this.document.toNodeJSON(node);
|
||||
return {
|
||||
...rawJSON,
|
||||
blocks: node.blocks.map((block) => this.document.toNodeJSON(block)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* get edges of all nodes - 获取所有节点的边
|
||||
*/
|
||||
private getEdgeJSONs(nodes: WorkflowNodeEntity[]): WorkflowEdgeJSON[] {
|
||||
const lineSet = new Set<WorkflowLineEntity>();
|
||||
const expandedNodes = this.expandGroupNodes(nodes);
|
||||
const nodeIdSet = new Set(expandedNodes.map((n) => n.id));
|
||||
expandedNodes.forEach((node) => {
|
||||
const linesData = node.lines;
|
||||
const lines = [...linesData.inputLines, ...linesData.outputLines];
|
||||
lines.forEach((line) => {
|
||||
if (
|
||||
line.from?.id &&
|
||||
nodeIdSet.has(line.from.id) &&
|
||||
line.to?.id &&
|
||||
nodeIdSet.has(line.to.id)
|
||||
) {
|
||||
lineSet.add(line);
|
||||
}
|
||||
});
|
||||
});
|
||||
return Array.from(lineSet).map((line) => line.toJSON());
|
||||
}
|
||||
|
||||
/**
|
||||
* expand group nodes - 展开分组子节点
|
||||
*/
|
||||
private expandGroupNodes(nodes: WorkflowNodeEntity[]): WorkflowNodeEntity[] {
|
||||
return nodes.flatMap((node) => {
|
||||
if (node.flowNodeType === FlowNodeBaseType.GROUP) {
|
||||
return [node, ...node.blocks];
|
||||
}
|
||||
return node;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* get bounding rectangle of all nodes - 获取所有节点的边界矩形
|
||||
*/
|
||||
private getEntireBounds(nodes: WorkflowNodeEntity[]): WorkflowClipboardRect {
|
||||
const bounds = nodes.map((node) => node.getData<TransformData>(TransformData).bounds);
|
||||
const rect = Rectangle.enlarge(bounds);
|
||||
return {
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* write data to clipboard - 将数据写入剪贴板
|
||||
*/
|
||||
private async write(data: WorkflowClipboardData): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(JSON.stringify(data));
|
||||
this.notifySuccess();
|
||||
} catch (err) {
|
||||
console.error('Failed to write text: ', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* show success notification - 显示成功通知
|
||||
*/
|
||||
private notifySuccess(): void {
|
||||
const startEndNodeTypes: WorkflowNodeType[] = [
|
||||
WorkflowNodeType.Start,
|
||||
WorkflowNodeType.End,
|
||||
WorkflowNodeType.BlockStart,
|
||||
WorkflowNodeType.BlockEnd,
|
||||
];
|
||||
if (
|
||||
this.selectedNodes.some((node) =>
|
||||
startEndNodeTypes.includes(node.flowNodeType as WorkflowNodeType)
|
||||
)
|
||||
) {
|
||||
Toast.warning({
|
||||
content:
|
||||
'The Start/End node cannot be duplicated, other nodes have been copied to the clipboard',
|
||||
showClose: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
Toast.success({
|
||||
content: 'Nodes have been copied to the clipboard',
|
||||
showClose: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import {
|
||||
FreeLayoutPluginContext,
|
||||
ShortcutsHandler,
|
||||
WorkflowDocument,
|
||||
WorkflowLineEntity,
|
||||
WorkflowNodeEntity,
|
||||
WorkflowNodeMeta,
|
||||
WorkflowSelectService,
|
||||
HistoryService,
|
||||
PlaygroundConfigEntity,
|
||||
} from '@flowgram.ai/free-layout-editor';
|
||||
import { Toast } from '@douyinfe/semi-ui';
|
||||
|
||||
import { FlowCommandId } from '../constants';
|
||||
import { WorkflowNodeType } from '../../nodes';
|
||||
|
||||
export class DeleteShortcut implements ShortcutsHandler {
|
||||
public commandId = FlowCommandId.DELETE;
|
||||
|
||||
public shortcuts = ['backspace', 'delete'];
|
||||
|
||||
private playgroundConfig: PlaygroundConfigEntity;
|
||||
|
||||
private document: WorkflowDocument;
|
||||
|
||||
private selectService: WorkflowSelectService;
|
||||
|
||||
private historyService: HistoryService;
|
||||
|
||||
/**
|
||||
* initialize delete shortcut - 初始化删除快捷键
|
||||
*/
|
||||
constructor(context: FreeLayoutPluginContext) {
|
||||
this.playgroundConfig = context.playground.config;
|
||||
this.document = context.get(WorkflowDocument);
|
||||
this.selectService = context.get(WorkflowSelectService);
|
||||
this.historyService = context.get(HistoryService);
|
||||
this.execute = this.execute.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* execute delete operation - 执行删除操作
|
||||
*/
|
||||
public async execute(nodes?: WorkflowNodeEntity[]): Promise<void> {
|
||||
if (this.readonly) {
|
||||
return;
|
||||
}
|
||||
const selection = Array.isArray(nodes) ? nodes : this.selectService.selection;
|
||||
if (
|
||||
!this.isValid(
|
||||
selection.filter((n) => n instanceof WorkflowNodeEntity) as WorkflowNodeEntity[]
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Merge actions to redo/undo
|
||||
this.historyService.startTransaction();
|
||||
// delete selected entities - 删除选中实体
|
||||
selection.forEach((entity) => {
|
||||
if (entity instanceof WorkflowNodeEntity) {
|
||||
this.removeNode(entity);
|
||||
} else if (entity instanceof WorkflowLineEntity) {
|
||||
this.removeLine(entity);
|
||||
} else {
|
||||
entity.dispose();
|
||||
}
|
||||
});
|
||||
// filter out disposed entities - 过滤掉已删除的实体
|
||||
this.selectService.selection = this.selectService.selection.filter((s) => !s.disposed);
|
||||
this.historyService.endTransaction();
|
||||
}
|
||||
|
||||
/**
|
||||
* readonly - 是否只读
|
||||
*/
|
||||
private get readonly(): boolean {
|
||||
return this.playgroundConfig.readonly;
|
||||
}
|
||||
|
||||
/**
|
||||
* validate if nodes can be deleted - 验证节点是否可以删除
|
||||
*/
|
||||
private isValid(nodes: WorkflowNodeEntity[]): boolean {
|
||||
const hasSystemNodes = nodes.some((n) =>
|
||||
[WorkflowNodeType.Start, WorkflowNodeType.End].includes(n.flowNodeType as WorkflowNodeType)
|
||||
);
|
||||
if (hasSystemNodes) {
|
||||
Toast.error({
|
||||
content: 'Start or End node cannot be deleted',
|
||||
showClose: false,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* remove node from workflow - 从工作流中删除节点
|
||||
*/
|
||||
private removeNode(node: WorkflowNodeEntity): void {
|
||||
if (!this.document.canRemove(node)) {
|
||||
return;
|
||||
}
|
||||
const nodeMeta = node.getNodeMeta<WorkflowNodeMeta>();
|
||||
const subCanvas = nodeMeta.subCanvas?.(node);
|
||||
if (subCanvas?.isCanvas) {
|
||||
subCanvas.parentNode.dispose();
|
||||
return;
|
||||
}
|
||||
node.dispose();
|
||||
}
|
||||
|
||||
/**
|
||||
* remove line from workflow - 从工作流中删除连线
|
||||
*/
|
||||
private removeLine(line: WorkflowLineEntity): void {
|
||||
if (!this.document.linesManager.canRemove(line)) {
|
||||
return;
|
||||
}
|
||||
line.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import {
|
||||
FreeLayoutPluginContext,
|
||||
ShortcutsHandler,
|
||||
WorkflowSelectService,
|
||||
} from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { FlowCommandId } from '../constants';
|
||||
|
||||
export class ExpandShortcut implements ShortcutsHandler {
|
||||
public commandId = FlowCommandId.EXPAND;
|
||||
|
||||
public commandDetail: ShortcutsHandler['commandDetail'] = {
|
||||
label: 'Expand',
|
||||
};
|
||||
|
||||
public shortcuts = ['meta alt closebracket', 'ctrl alt openbracket'];
|
||||
|
||||
private selectService: WorkflowSelectService;
|
||||
|
||||
constructor(context: FreeLayoutPluginContext) {
|
||||
this.selectService = context.get(WorkflowSelectService);
|
||||
this.execute = this.execute.bind(this);
|
||||
}
|
||||
|
||||
public async execute(): Promise<void> {
|
||||
this.selectService.selectedNodes.forEach((node) => {
|
||||
node.renderData.expanded = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export * from './constants';
|
||||
export * from './shortcuts';
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import {
|
||||
delay,
|
||||
EntityManager,
|
||||
FlowNodeTransformData,
|
||||
FreeLayoutPluginContext,
|
||||
IPoint,
|
||||
PlaygroundConfigEntity,
|
||||
Rectangle,
|
||||
ShortcutsHandler,
|
||||
WorkflowDocument,
|
||||
WorkflowDragService,
|
||||
WorkflowHoverService,
|
||||
WorkflowJSON,
|
||||
WorkflowNodeEntity,
|
||||
WorkflowNodeMeta,
|
||||
WorkflowSelectService,
|
||||
Playground,
|
||||
} from '@flowgram.ai/free-layout-editor';
|
||||
import { Toast } from '@douyinfe/semi-ui';
|
||||
|
||||
import { WorkflowClipboardData, WorkflowClipboardRect } from '../type';
|
||||
import { FlowCommandId, WorkflowClipboardDataID } from '../constants';
|
||||
import { canContainNode } from '../../utils';
|
||||
import { generateUniqueWorkflow } from './unique-workflow';
|
||||
|
||||
export class PasteShortcut implements ShortcutsHandler {
|
||||
public commandId = FlowCommandId.PASTE;
|
||||
|
||||
public shortcuts = ['meta v', 'ctrl v'];
|
||||
|
||||
private playgroundConfig: PlaygroundConfigEntity;
|
||||
|
||||
private document: WorkflowDocument;
|
||||
|
||||
private selectService: WorkflowSelectService;
|
||||
|
||||
private entityManager: EntityManager;
|
||||
|
||||
private hoverService: WorkflowHoverService;
|
||||
|
||||
private dragService: WorkflowDragService;
|
||||
|
||||
private playground: Playground;
|
||||
|
||||
/**
|
||||
* initialize paste shortcut handler - 初始化粘贴快捷键处理器
|
||||
*/
|
||||
constructor(context: FreeLayoutPluginContext) {
|
||||
this.playgroundConfig = context.playground.config;
|
||||
this.document = context.get(WorkflowDocument);
|
||||
this.selectService = context.get(WorkflowSelectService);
|
||||
this.entityManager = context.get(EntityManager);
|
||||
this.hoverService = context.get(WorkflowHoverService);
|
||||
this.dragService = context.get(WorkflowDragService);
|
||||
this.playground = context.playground;
|
||||
this.execute = this.execute.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* execute paste action - 执行粘贴操作
|
||||
*/
|
||||
public async execute(): Promise<WorkflowNodeEntity[] | undefined> {
|
||||
if (this.readonly) {
|
||||
return;
|
||||
}
|
||||
const data = await this.tryReadClipboard();
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
if (!this.isValidData(data)) {
|
||||
return;
|
||||
}
|
||||
const nodes = this.apply(data);
|
||||
if (nodes.length > 0) {
|
||||
Toast.success({
|
||||
content: 'Copy successfully',
|
||||
showClose: false,
|
||||
});
|
||||
// wait for nodes to render - 等待节点渲染
|
||||
await this.nextTick();
|
||||
// scroll to visible area - 滚动到可视区域
|
||||
this.scrollNodesToView(nodes);
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/** apply clipboard data - 应用剪切板数据 */
|
||||
public apply(data: WorkflowClipboardData): WorkflowNodeEntity[] {
|
||||
// extract raw json from clipboard data - 从剪贴板数据中提取原始JSON
|
||||
const { json: rawJSON } = data;
|
||||
const json = generateUniqueWorkflow({
|
||||
json: rawJSON,
|
||||
isUniqueId: (id: string) => !this.entityManager.getEntityById(id),
|
||||
});
|
||||
|
||||
const offset = this.calcPasteOffset(data.bounds);
|
||||
let parent = this.getSelectedContainer();
|
||||
// loop 不支持嵌套
|
||||
if (parent && json.nodes.some((n) => !canContainNode(n.type, parent!.flowNodeType))) {
|
||||
parent = undefined;
|
||||
}
|
||||
this.applyOffset({ json, offset, parent });
|
||||
const { nodes } = this.document.batchAddFromJSON(json, {
|
||||
parent,
|
||||
});
|
||||
this.selectNodes(nodes);
|
||||
// 这里需要 focus 画布才能继续使用快捷键
|
||||
// The focus canvas is needed here to continue using the shortcuts
|
||||
this.playground.node.focus();
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* readonly - 是否只读
|
||||
*/
|
||||
private get readonly(): boolean {
|
||||
return this.playgroundConfig.readonly;
|
||||
}
|
||||
|
||||
private isValidData(data?: WorkflowClipboardData): boolean {
|
||||
if (data?.type !== WorkflowClipboardDataID) {
|
||||
Toast.error({
|
||||
content: 'Invalid clipboard data',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
// Cross-domain means different environments, different plugins, cannot be copied - 跨域名表示不同环境,上架插件不同,不能复制
|
||||
if (data.source.host !== window.location.host) {
|
||||
Toast.error({
|
||||
content: 'Cannot paste nodes from different host',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
// Check container - 检查容器
|
||||
const parent = this.getSelectedContainer();
|
||||
for (const nodeJSON of data.json.nodes) {
|
||||
const res = this.dragService.canDropToNode({
|
||||
dragNodeType: nodeJSON.type,
|
||||
dropNodeType: parent?.flowNodeType,
|
||||
dropNode: parent,
|
||||
});
|
||||
if (!res.allowDrop) {
|
||||
Toast.error({
|
||||
content: res.message ?? 'Cannot paste nodes to invalid container',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** try to read clipboard - 尝试读取剪贴板 */
|
||||
private async tryReadClipboard(): Promise<WorkflowClipboardData | undefined> {
|
||||
try {
|
||||
// need user permission to access clipboard, may throw NotAllowedError - 需要用户授予网页剪贴板读取权限, 如果用户没有授予权限, 代码可能会抛出异常 NotAllowedError
|
||||
const text: string = (await navigator.clipboard.readText()) || '';
|
||||
const clipboardData: WorkflowClipboardData = JSON.parse(text);
|
||||
return clipboardData;
|
||||
} catch (e) {
|
||||
// clipboard data is not fixed, no need to show error - 这里本身剪贴板里的数据就不固定,所以没必要报错
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/** calculate paste offset - 计算粘贴偏移 */
|
||||
private calcPasteOffset(boundsData: WorkflowClipboardRect): IPoint {
|
||||
// extract bounds data - 提取边界数据
|
||||
const { x, y, width, height } = boundsData;
|
||||
const rect = new Rectangle(x, y, width, height);
|
||||
const { center } = rect;
|
||||
const mousePos = this.hoverService.hoveredPos;
|
||||
return {
|
||||
x: mousePos.x - center.x,
|
||||
y: mousePos.y - center.y,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* apply offset to node positions - 应用偏移到节点位置
|
||||
*/
|
||||
private applyOffset(params: {
|
||||
json: WorkflowJSON;
|
||||
offset: IPoint;
|
||||
parent?: WorkflowNodeEntity;
|
||||
}): void {
|
||||
const { json, offset, parent } = params;
|
||||
json.nodes.forEach((nodeJSON) => {
|
||||
if (!nodeJSON.meta?.position) {
|
||||
return;
|
||||
}
|
||||
// calculate new position - 计算新位置
|
||||
let position = {
|
||||
x: nodeJSON.meta.position.x + offset.x,
|
||||
y: nodeJSON.meta.position.y + offset.y,
|
||||
};
|
||||
if (parent) {
|
||||
position = this.dragService.adjustSubNodePosition(
|
||||
nodeJSON.type as string,
|
||||
parent,
|
||||
position
|
||||
);
|
||||
}
|
||||
nodeJSON.meta.position = position;
|
||||
});
|
||||
}
|
||||
|
||||
/** get selected container node - 获取鼠标选中的容器 */
|
||||
private getSelectedContainer(): WorkflowNodeEntity | undefined {
|
||||
const { activatedNode } = this.selectService;
|
||||
return activatedNode?.getNodeMeta<WorkflowNodeMeta>().isContainer ? activatedNode : undefined;
|
||||
}
|
||||
|
||||
/** select nodes - 选中节点 */
|
||||
private selectNodes(nodes: WorkflowNodeEntity[]): void {
|
||||
this.selectService.selection = nodes;
|
||||
}
|
||||
|
||||
/** scroll to nodes - 滚动到节点 */
|
||||
private async scrollNodesToView(nodes: WorkflowNodeEntity[]): Promise<void> {
|
||||
const nodeBounds = nodes.map((node) => node.getData(FlowNodeTransformData).bounds);
|
||||
await this.document.playgroundConfig.scrollToView({
|
||||
bounds: Rectangle.enlarge(nodeBounds),
|
||||
});
|
||||
}
|
||||
|
||||
/** wait for next frame - 等待下一帧 */
|
||||
private async nextTick(): Promise<void> {
|
||||
// 16ms is one render frame - 16ms 为一个渲染帧
|
||||
const frameTime = 16;
|
||||
await delay(frameTime);
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
// traverse value type - 遍历值类型
|
||||
export type TraverseValue = any;
|
||||
|
||||
// traverse node interface - 遍历节点接口
|
||||
export interface TraverseNode {
|
||||
value: TraverseValue; // node value - 节点值
|
||||
container?: TraverseValue; // parent container - 父容器
|
||||
parent?: TraverseNode; // parent node - 父节点
|
||||
key?: string; // object key - 对象键名
|
||||
index?: number; // array index - 数组索引
|
||||
}
|
||||
|
||||
// traverse context interface - 遍历上下文接口
|
||||
export interface TraverseContext {
|
||||
node: TraverseNode; // current node - 当前节点
|
||||
setValue: (value: TraverseValue) => void; // set value function - 设置值函数
|
||||
getParents: () => TraverseNode[]; // get parents function - 获取父节点函数
|
||||
getPath: () => Array<string | number>; // get path function - 获取路径函数
|
||||
getStringifyPath: () => string; // get string path function - 获取字符串路径函数
|
||||
deleteSelf: () => void; // delete self function - 删除自身函数
|
||||
}
|
||||
|
||||
// traverse handler type - 遍历处理器类型
|
||||
export type TraverseHandler = (context: TraverseContext) => void;
|
||||
|
||||
/**
|
||||
* traverse object deeply and handle each value - 深度遍历对象并处理每个值
|
||||
* @param value traverse target - 遍历目标
|
||||
* @param handle handler function - 处理函数
|
||||
*/
|
||||
export const traverse = <T extends TraverseValue = TraverseValue>(
|
||||
value: T,
|
||||
handler: TraverseHandler | TraverseHandler[]
|
||||
): T => {
|
||||
const traverseHandler: TraverseHandler = Array.isArray(handler)
|
||||
? (context: TraverseContext) => {
|
||||
handler.forEach((handlerFn) => handlerFn(context));
|
||||
}
|
||||
: handler;
|
||||
TraverseUtils.traverseNodes({ value }, traverseHandler);
|
||||
return value;
|
||||
};
|
||||
|
||||
namespace TraverseUtils {
|
||||
/**
|
||||
* traverse nodes deeply and handle each value - 深度遍历节点并处理每个值
|
||||
* @param node traverse node - 遍历节点
|
||||
* @param handle handler function - 处理函数
|
||||
*/
|
||||
export const traverseNodes = (node: TraverseNode, handle: TraverseHandler): void => {
|
||||
const { value } = node;
|
||||
if (!value) {
|
||||
// handle null value - 处理空值
|
||||
return;
|
||||
}
|
||||
if (Object.prototype.toString.call(value) === '[object Object]') {
|
||||
// traverse object properties - 遍历对象属性
|
||||
Object.entries(value).forEach(([key, item]) =>
|
||||
traverseNodes(
|
||||
{
|
||||
value: item,
|
||||
container: value,
|
||||
key,
|
||||
parent: node,
|
||||
},
|
||||
handle
|
||||
)
|
||||
);
|
||||
} else if (Array.isArray(value)) {
|
||||
// traverse array elements from end to start - 从末尾开始遍历数组元素
|
||||
for (let index = value.length - 1; index >= 0; index--) {
|
||||
const item: string = value[index];
|
||||
traverseNodes(
|
||||
{
|
||||
value: item,
|
||||
container: value,
|
||||
index,
|
||||
parent: node,
|
||||
},
|
||||
handle
|
||||
);
|
||||
}
|
||||
}
|
||||
const context: TraverseContext = createContext({ node });
|
||||
handle(context);
|
||||
};
|
||||
|
||||
/**
|
||||
* create traverse context - 创建遍历上下文
|
||||
* @param node traverse node - 遍历节点
|
||||
*/
|
||||
const createContext = ({ node }: { node: TraverseNode }): TraverseContext => ({
|
||||
node,
|
||||
setValue: (value: unknown) => setValue(node, value),
|
||||
getParents: () => getParents(node),
|
||||
getPath: () => getPath(node),
|
||||
getStringifyPath: () => getStringifyPath(node),
|
||||
deleteSelf: () => deleteSelf(node),
|
||||
});
|
||||
|
||||
/**
|
||||
* set node value - 设置节点值
|
||||
* @param node traverse node - 遍历节点
|
||||
* @param value new value - 新值
|
||||
*/
|
||||
const setValue = (node: TraverseNode, value: unknown) => {
|
||||
// handle empty value - 处理空值
|
||||
if (!value || !node) {
|
||||
return;
|
||||
}
|
||||
node.value = value;
|
||||
// get container info from parent scope - 从父作用域获取容器信息
|
||||
const { container, key, index } = node;
|
||||
if (key && container) {
|
||||
container[key] = value;
|
||||
} else if (typeof index === 'number') {
|
||||
container[index] = value;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* get parent nodes - 获取父节点列表
|
||||
* @param node traverse node - 遍历节点
|
||||
*/
|
||||
const getParents = (node: TraverseNode): TraverseNode[] => {
|
||||
const parents: TraverseNode[] = [];
|
||||
let currentNode: TraverseNode | undefined = node;
|
||||
while (currentNode) {
|
||||
parents.unshift(currentNode);
|
||||
currentNode = currentNode.parent;
|
||||
}
|
||||
return parents;
|
||||
};
|
||||
|
||||
/**
|
||||
* get node path - 获取节点路径
|
||||
* @param node traverse node - 遍历节点
|
||||
*/
|
||||
const getPath = (node: TraverseNode): Array<string | number> => {
|
||||
const path: Array<string | number> = [];
|
||||
const parents = getParents(node);
|
||||
parents.forEach((parent) => {
|
||||
if (parent.key) {
|
||||
path.unshift(parent.key);
|
||||
} else if (parent.index) {
|
||||
path.unshift(parent.index);
|
||||
}
|
||||
});
|
||||
return path;
|
||||
};
|
||||
|
||||
/**
|
||||
* get stringify path - 获取字符串路径
|
||||
* @param node traverse node - 遍历节点
|
||||
*/
|
||||
const getStringifyPath = (node: TraverseNode): string => {
|
||||
const path = getPath(node);
|
||||
return path.reduce((stringifyPath: string, pathItem: string | number) => {
|
||||
if (typeof pathItem === 'string') {
|
||||
const re = /\W/g;
|
||||
if (re.test(pathItem)) {
|
||||
// handle special characters - 处理特殊字符
|
||||
return `${stringifyPath}["${pathItem}"]`;
|
||||
}
|
||||
return `${stringifyPath}.${pathItem}`;
|
||||
} else {
|
||||
return `${stringifyPath}[${pathItem}]`;
|
||||
}
|
||||
}, '');
|
||||
};
|
||||
|
||||
/**
|
||||
* delete current node - 删除当前节点
|
||||
* @param node traverse node - 遍历节点
|
||||
*/
|
||||
const deleteSelf = (node: TraverseNode): void => {
|
||||
const { container, key, index } = node;
|
||||
if (key && container) {
|
||||
delete container[key];
|
||||
} else if (typeof index === 'number') {
|
||||
container.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { customAlphabet } from 'nanoid';
|
||||
import type { WorkflowJSON, WorkflowNodeJSON } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { traverse, TraverseContext } from './traverse';
|
||||
|
||||
namespace UniqueWorkflowUtils {
|
||||
/** generate unique id - 生成唯一ID */
|
||||
const generateUniqueId = customAlphabet('1234567890', 6); // create a function to generate 6-digit number - 创建一个生成6位数字的函数
|
||||
|
||||
/** get all node ids from workflow json - 从工作流JSON中获取所有节点ID */
|
||||
export const getAllNodeIds = (json: WorkflowJSON): string[] => {
|
||||
const nodeIds = new Set<string>(); // use set to store unique ids - 使用Set存储唯一ID
|
||||
const addNodeId = (node: WorkflowNodeJSON) => {
|
||||
nodeIds.add(node.id);
|
||||
if (node.blocks?.length) {
|
||||
node.blocks.forEach((child) => addNodeId(child)); // recursively add child node ids - 递归添加子节点ID
|
||||
}
|
||||
};
|
||||
json.nodes.forEach((node) => addNodeId(node));
|
||||
return Array.from(nodeIds);
|
||||
};
|
||||
|
||||
/** generate node replacement mapping - 生成节点替换映射 */
|
||||
export const generateNodeReplaceMap = (
|
||||
nodeIds: string[],
|
||||
isUniqueId: (id: string) => boolean
|
||||
): Map<string, string> => {
|
||||
const nodeReplaceMap = new Map<string, string>(); // create map for id replacement - 创建ID替换映射
|
||||
nodeIds.forEach((id) => {
|
||||
if (isUniqueId(id)) {
|
||||
nodeReplaceMap.set(id, id); // keep original id if unique - 如果ID唯一则保持不变
|
||||
} else {
|
||||
let newId: string;
|
||||
do {
|
||||
newId = generateUniqueId(); // generate new id until unique - 生成新ID直到唯一
|
||||
} while (!isUniqueId(newId));
|
||||
nodeReplaceMap.set(id, newId);
|
||||
}
|
||||
});
|
||||
return nodeReplaceMap;
|
||||
};
|
||||
|
||||
/** check if value exists - 检查值是否存在 */
|
||||
const isExist = (value: unknown): boolean => value !== null && value !== undefined;
|
||||
|
||||
/** check if node should be handled - 检查节点是否需要处理 */
|
||||
const shouldHandle = (context: TraverseContext): boolean => {
|
||||
const { node } = context;
|
||||
// check edge data - 检查边数据
|
||||
if (
|
||||
node?.key &&
|
||||
['sourceNodeID', 'targetNodeID'].includes(node.key) &&
|
||||
node.parent?.parent?.key === 'edges'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// check node data - 检查节点数据
|
||||
if (
|
||||
node?.key === 'id' &&
|
||||
isExist(node.container?.type) &&
|
||||
isExist(node.container?.meta) &&
|
||||
isExist(node.container?.data)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// check variable data - 检查变量数据
|
||||
if (
|
||||
node?.key === 'blockID' &&
|
||||
isExist(node.container?.name) &&
|
||||
node.container?.source === 'block-output'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* replace node ids in workflow json - 替换工作流JSON中的节点ID
|
||||
* notice: this method has side effects, it will modify the input json to avoid deep copy overhead
|
||||
* - 注意:此方法有副作用,会修改输入的json以避免深拷贝开销
|
||||
*/
|
||||
export const replaceNodeId = (
|
||||
json: WorkflowJSON,
|
||||
nodeReplaceMap: Map<string, string>
|
||||
): WorkflowJSON => {
|
||||
traverse(json, (context) => {
|
||||
if (!shouldHandle(context)) {
|
||||
return;
|
||||
}
|
||||
const { node } = context;
|
||||
if (nodeReplaceMap.has(node.value)) {
|
||||
context.setValue(nodeReplaceMap.get(node.value)); // replace old id with new id - 用新ID替换旧ID
|
||||
}
|
||||
});
|
||||
return json;
|
||||
};
|
||||
}
|
||||
|
||||
/** generate unique workflow json - 生成唯一工作流JSON */
|
||||
export const generateUniqueWorkflow = (params: {
|
||||
json: WorkflowJSON;
|
||||
isUniqueId: (id: string) => boolean;
|
||||
}): WorkflowJSON => {
|
||||
const { json, isUniqueId } = params;
|
||||
const nodeIds = UniqueWorkflowUtils.getAllNodeIds(json); // get all existing node ids - 获取所有现有节点ID
|
||||
const nodeReplaceMap = UniqueWorkflowUtils.generateNodeReplaceMap(nodeIds, isUniqueId); // generate id replacement map - 生成ID替换映射
|
||||
return UniqueWorkflowUtils.replaceNodeId(json, nodeReplaceMap); // replace all node ids - 替换所有节点ID
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import {
|
||||
FreeLayoutPluginContext,
|
||||
Playground,
|
||||
ShortcutsHandler,
|
||||
WorkflowDocument,
|
||||
} from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { FlowCommandId } from '../constants';
|
||||
|
||||
export class SelectAllShortcut implements ShortcutsHandler {
|
||||
public commandId = FlowCommandId.SELECT_ALL;
|
||||
|
||||
public shortcuts = ['meta a', 'ctrl a'];
|
||||
|
||||
private document: WorkflowDocument;
|
||||
|
||||
private playground: Playground;
|
||||
|
||||
constructor(context: FreeLayoutPluginContext) {
|
||||
this.document = context.get(WorkflowDocument);
|
||||
this.playground = context.playground;
|
||||
this.execute = this.execute.bind(this);
|
||||
}
|
||||
|
||||
public async execute(): Promise<void> {
|
||||
const allNodes = this.document.getAllNodes();
|
||||
this.playground.selectionService.selection = allNodes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import { FreeLayoutPluginContext, ShortcutsRegistry } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { ZoomOutShortcut } from './zoom-out';
|
||||
import { ZoomInShortcut } from './zoom-in';
|
||||
import { SelectAllShortcut } from './select-all';
|
||||
import { PasteShortcut } from './paste';
|
||||
import { ExpandShortcut } from './expand';
|
||||
import { DeleteShortcut } from './delete';
|
||||
import { CopyShortcut } from './copy';
|
||||
import { CollapseShortcut } from './collapse';
|
||||
|
||||
export function shortcuts(shortcutsRegistry: ShortcutsRegistry, ctx: FreeLayoutPluginContext) {
|
||||
shortcutsRegistry.addHandlers(
|
||||
new CopyShortcut(ctx),
|
||||
new PasteShortcut(ctx),
|
||||
new SelectAllShortcut(ctx),
|
||||
new CollapseShortcut(ctx),
|
||||
new ExpandShortcut(ctx),
|
||||
new DeleteShortcut(ctx),
|
||||
new ZoomInShortcut(ctx),
|
||||
new ZoomOutShortcut(ctx)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import type { WorkflowJSON } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import type { WorkflowClipboardDataID } from './constants';
|
||||
|
||||
export interface WorkflowClipboardSource {
|
||||
host: string;
|
||||
// more: id?, workspaceId? etc.
|
||||
}
|
||||
|
||||
export interface WorkflowClipboardRect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface WorkflowClipboardData {
|
||||
type: typeof WorkflowClipboardDataID;
|
||||
json: WorkflowJSON;
|
||||
source: WorkflowClipboardSource;
|
||||
bounds: WorkflowClipboardRect;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import {
|
||||
FreeLayoutPluginContext,
|
||||
PlaygroundConfigEntity,
|
||||
ShortcutsHandler,
|
||||
} from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { FlowCommandId } from '../constants';
|
||||
|
||||
export class ZoomInShortcut implements ShortcutsHandler {
|
||||
public commandId = FlowCommandId.ZOOM_IN;
|
||||
|
||||
public shortcuts = ['meta =', 'ctrl ='];
|
||||
|
||||
private playgroundConfig: PlaygroundConfigEntity;
|
||||
|
||||
constructor(context: FreeLayoutPluginContext) {
|
||||
this.playgroundConfig = context.get(PlaygroundConfigEntity);
|
||||
this.execute = this.execute.bind(this);
|
||||
}
|
||||
|
||||
public async execute(): Promise<void> {
|
||||
if (this.playgroundConfig.zoom > 1.9) {
|
||||
return;
|
||||
}
|
||||
this.playgroundConfig.zoomin();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import {
|
||||
FreeLayoutPluginContext,
|
||||
PlaygroundConfigEntity,
|
||||
ShortcutsHandler,
|
||||
} from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { FlowCommandId } from '../constants';
|
||||
|
||||
export class ZoomOutShortcut implements ShortcutsHandler {
|
||||
public commandId = FlowCommandId.ZOOM_OUT;
|
||||
|
||||
public shortcuts = ['meta -', 'ctrl -'];
|
||||
|
||||
private playgroundConfig: PlaygroundConfigEntity;
|
||||
|
||||
constructor(context: FreeLayoutPluginContext) {
|
||||
this.playgroundConfig = context.get(PlaygroundConfigEntity);
|
||||
this.execute = this.execute.bind(this);
|
||||
}
|
||||
|
||||
public async execute(): Promise<void> {
|
||||
if (this.playgroundConfig.zoom > 1.9) {
|
||||
return;
|
||||
}
|
||||
this.playgroundConfig.zoomout();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user