新版本流程编辑器

This commit is contained in:
mlogclub
2026-07-27 19:04:15 +08:00
parent d33960961a
commit e072141953
281 changed files with 25145 additions and 3405 deletions
@@ -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',
}
+274
View File
@@ -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;
});
}
}
+7
View File
@@ -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)
);
}
+27
View File
@@ -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();
}
}