feat: manually mirror opencoze's code from bytedance

Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
fanlv
2025-07-20 17:36:12 +08:00
commit 890153324f
14811 changed files with 1923430 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export const WORKFLOW_CLIPBOARD_TYPE = 'coze-workflow-clipboard-data';
export const WORKFLOW_EXPORT_TYPE = 'coze-workflow-export-data';

View File

@@ -0,0 +1,42 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { FlowNodeBaseType } from '@flowgram-adapter/free-layout-editor';
import type {
WorkflowNodeEntity,
WorkflowNodeMeta,
} from '@flowgram-adapter/free-layout-editor';
import { StandardNodeType } from '@coze-workflow/base';
/** 获取可用节点 */
export const getValidNodes = (
nodes: WorkflowNodeEntity[],
): WorkflowNodeEntity[] =>
nodes.filter(n => {
if (
[
StandardNodeType.Start,
StandardNodeType.End,
FlowNodeBaseType.SUB_CANVAS,
].includes(n.flowNodeType as StandardNodeType)
) {
return false;
}
if (n.getNodeMeta<WorkflowNodeMeta>().copyDisable) {
return false;
}
return true;
});

View File

@@ -0,0 +1,276 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { inject, injectable } from 'inversify';
import { TransformData } from '@flowgram-adapter/free-layout-editor';
import {
WorkflowCommands,
WorkflowDocument,
type WorkflowEdgeJSON,
type WorkflowLineEntity,
WorkflowNodeEntity,
WorkflowNodeLinesData,
WorkflowSelectService,
} from '@flowgram-adapter/free-layout-editor';
import { Rectangle } from '@flowgram-adapter/common';
import type {
WorkflowShortcutsContribution,
WorkflowShortcutsRegistry,
} from '@coze-workflow/render';
import { WorkflowNodeData } from '@coze-workflow/nodes';
import type { StandardNodeType } from '@coze-workflow/base';
import { I18n } from '@coze-arch/i18n';
import { Toast } from '@coze-arch/coze-design';
import { WorkflowGlobalStateEntity } from '@/typing';
import { safeFn } from '../../utils';
import type {
WorkflowClipboardData,
WorkflowClipboardJSON,
WorkflowClipboardNodeTemporary,
WorkflowClipboardNodeJSON,
WorkflowClipboardRect,
WorkflowClipboardSource,
} from '../../type';
import { WORKFLOW_CLIPBOARD_TYPE } from '../../constant';
import { isValid } from './is-valid';
import { hasSystemNodes } from './is-system-nodes';
import { getValidNodes } from './get-valid-nodes';
/**
* 复制快捷键
*/
@injectable()
export class WorkflowCopyShortcutsContribution
implements WorkflowShortcutsContribution
{
@inject(WorkflowDocument) private document: WorkflowDocument;
@inject(WorkflowSelectService) private selection: WorkflowSelectService;
@inject(WorkflowGlobalStateEntity)
private globalState: WorkflowGlobalStateEntity;
/** 注册快捷键 */
public registerShortcuts(registry: WorkflowShortcutsRegistry): void {
registry.addHandlers({
commandId: WorkflowCommands.COPY_NODES,
shortcuts: ['meta c', 'ctrl c'],
isEnabled: () => !this.globalState.readonly,
execute: safeFn(this.handle.bind(this)),
});
}
public async toData(): Promise<WorkflowClipboardData> {
const validNodes = getValidNodes(this.selectedNodes);
const source = this.toSource();
const json = await this.toJSON(validNodes);
const bounds = this.getEntireBounds(validNodes);
return {
type: WORKFLOW_CLIPBOARD_TYPE,
source,
json,
bounds,
};
}
/** 获取来源数据 */
public toSource(): WorkflowClipboardSource {
return {
workflowId: this.globalState.workflowId,
flowMode: this.globalState.flowMode,
spaceId: this.globalState.spaceId,
isDouyin: this.globalState.isBindDouyin,
host: window.location.host,
};
}
/** 获取节点的 JSON */
public async toJSON(
nodes: WorkflowNodeEntity[],
): Promise<WorkflowClipboardJSON> {
const nodeJSONs = await this.getNodeJSONs(nodes);
const edgeJSONs = this.getEdgeJSONs(nodes);
return {
nodes: nodeJSONs,
edges: edgeJSONs,
};
}
/** 处理复制事件 */
private async handle(): Promise<void> {
if (await this.hasTextSelected()) {
// 如果有选中的文字,优先复制文字
return;
}
if (!isValid(this.selectedNodes)) {
return;
}
const data = await this.toData();
await this.write(data);
}
/** 写入剪贴板 */
private async write(data: WorkflowClipboardData): Promise<void> {
try {
await navigator.clipboard.writeText(JSON.stringify(data));
if (hasSystemNodes(this.selectedNodes)) {
Toast.warning({
content: I18n.t('workflow_multi_choice_copy_partial_success'),
showClose: false,
});
} else {
Toast.success({
content: I18n.t('workflow_multi_choice_copy_success'),
showClose: false,
});
}
} catch (err) {
console.error('Failed to write text: ', err);
}
}
/** 是否有选中的文字 */
private async hasTextSelected(): Promise<boolean> {
if (!window.getSelection()?.toString()) {
return false;
}
await navigator.clipboard.writeText(
window.getSelection()?.toString() ?? '',
);
Toast.success({
content: I18n.t('workflow_text_copy', {}, '文本已复制到剪贴板'),
});
return true;
}
/** 获取选中的节点 */
private get selectedNodes(): WorkflowNodeEntity[] {
return this.selection.selection.filter(
n => n instanceof WorkflowNodeEntity,
) as WorkflowNodeEntity[];
}
/** 获取节点的 JSON */
private async getNodeJSONs(
nodes: WorkflowNodeEntity[],
): Promise<WorkflowClipboardNodeJSON[]> {
const nodeJSONs = await Promise.all(
nodes.map(node => this.tryToNodeJSON(node)),
);
return nodeJSONs.filter(Boolean) as WorkflowClipboardNodeJSON[];
}
/** 获取节点的 JSON */
private async toNodeJSON(
node: WorkflowNodeEntity,
): Promise<WorkflowClipboardNodeJSON> {
const nodeJSON = (await this.document.toNodeJSON(
node,
)) as WorkflowClipboardNodeJSON;
nodeJSON._temp = this.getNodeTemporary(node);
// 递归处理所有嵌套层级的blocks
if (nodeJSON.blocks?.length) {
await Promise.all(
nodeJSON.blocks.map(async childJSON => {
const child = this.document.getNode(childJSON.id);
if (!child) {
return;
}
childJSON._temp = this.getNodeTemporary(child);
// 递归处理子节点的blocks
if (childJSON.blocks?.length) {
await this.processBlocksRecursively(childJSON.blocks);
}
}),
);
}
return nodeJSON;
}
/** 递归处理blocks */
private async processBlocksRecursively(
blocks: WorkflowClipboardNodeJSON[],
): Promise<void> {
await Promise.all(
blocks.map(async blockJSON => {
const node = this.document.getNode(blockJSON.id);
if (!node) {
return;
}
blockJSON._temp = this.getNodeTemporary(node);
if (blockJSON.blocks?.length) {
await this.processBlocksRecursively(blockJSON.blocks);
}
}),
);
}
private async tryToNodeJSON(
node: WorkflowNodeEntity,
): Promise<WorkflowClipboardNodeJSON | undefined> {
try {
return await this.toNodeJSON(node);
// eslint-disable-next-line @coze-arch/use-error-in-catch -- ignore error
} catch (err) {
return;
}
}
/** 获取节点的额外数据 */
private getNodeTemporary(
node: WorkflowNodeEntity,
): WorkflowClipboardNodeTemporary {
const bounds = this.getNodeBounds(node);
const nodeDataEntity = node.getData<WorkflowNodeData>(WorkflowNodeData);
const externalData = nodeDataEntity.getNodeData<StandardNodeType.Api>();
return {
bounds,
externalData,
};
}
/** 获取节点的矩形 */
private getNodeBounds(node: WorkflowNodeEntity): WorkflowClipboardRect {
const nodeData = node.getData<TransformData>(TransformData);
return {
x: nodeData.bounds.x,
y: nodeData.bounds.y,
width: nodeData.bounds.width,
height: nodeData.bounds.height,
};
}
/** 获取所有节点的边 */
private getEdgeJSONs(nodes: WorkflowNodeEntity[]): WorkflowEdgeJSON[] {
const lineSet = new Set<WorkflowLineEntity>();
const nodeIdSet = new Set(nodes.map(n => n.id));
nodes.forEach(node => {
const linesData = node.getData(WorkflowNodeLinesData);
const lines = [...linesData.inputLines, ...linesData.outputLines];
lines.forEach(line => {
if (
nodeIdSet.has(line.from.id) &&
line.to?.id &&
nodeIdSet.has(line.to.id)
) {
lineSet.add(line);
}
});
});
return Array.from(lineSet).map(line => line.toJSON());
}
/** 获取所有节点的矩形 */
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,
};
}
}

View File

@@ -0,0 +1,33 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { WorkflowNodeEntity } from '@flowgram-adapter/free-layout-editor';
import { StandardNodeType } from '@coze-workflow/base';
/** 是否有系统节点 */
export const hasSystemNodes = (nodes: WorkflowNodeEntity[]): boolean =>
nodes.some(n =>
[StandardNodeType.Start, StandardNodeType.End].includes(
n.flowNodeType as StandardNodeType,
),
);
export const isAllSystemNodes = (nodes: WorkflowNodeEntity[]): boolean =>
nodes.every(n =>
[StandardNodeType.Start, StandardNodeType.End].includes(
n.flowNodeType as StandardNodeType,
),
);

View File

@@ -0,0 +1,38 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { I18n } from '@coze-arch/i18n';
import { Toast } from '@coze-arch/coze-design';
import type { WorkflowNodeEntity } from '@flowgram-adapter/free-layout-editor';
import { isAllSystemNodes } from './is-system-nodes';
import { getValidNodes } from './get-valid-nodes';
export const isValid = (nodes: WorkflowNodeEntity[]): boolean => {
if (isAllSystemNodes(nodes)) {
Toast.warning({
content: I18n.t('workflow_multi_choice_copy_failed'),
showClose: false,
});
return false;
}
const validNodes = getValidNodes(nodes);
const nodeCount = validNodes.length;
if (nodeCount === 0) {
return false;
}
return true;
};

View File

@@ -0,0 +1,101 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { inject, injectable } from 'inversify';
import {
WorkflowCommands,
WorkflowDocument,
WorkflowLineEntity,
WorkflowNodeEntity,
type WorkflowNodeMeta,
WorkflowSelectService,
} from '@flowgram-adapter/free-layout-editor';
import type {
WorkflowShortcutsContribution,
WorkflowShortcutsRegistry,
} from '@coze-workflow/render';
import { WorkflowGlobalStateEntity } from '@/typing';
import { safeFn } from '../../utils';
import { isValid } from './is-valid';
/**
* 删除快捷键
*/
@injectable()
export class WorkflowDeleteShortcutsContribution
implements WorkflowShortcutsContribution
{
@inject(WorkflowDocument) private document: WorkflowDocument;
@inject(WorkflowSelectService) private selection: WorkflowSelectService;
@inject(WorkflowGlobalStateEntity)
private globalState: WorkflowGlobalStateEntity;
/** 注册快捷键 */
public registerShortcuts(registry: WorkflowShortcutsRegistry): void {
registry.addHandlers({
commandId: WorkflowCommands.DELETE_NODES,
shortcuts: ['backspace', 'delete'],
isEnabled: () => !this.globalState.readonly,
execute: safeFn(this.handle.bind(this)),
});
}
private handle(): void {
if (!isValid(this.selectedNodes)) {
return;
}
// 删除选中实体
this.selection.selection.forEach(entity => {
if (entity instanceof WorkflowNodeEntity) {
this.removeNode(entity);
} else if (entity instanceof WorkflowLineEntity) {
this.removeLine(entity);
} else {
entity.dispose();
}
});
// 过滤掉已删除的实体
this.selection.selection = this.selection.selection.filter(
s => !s.disposed,
);
}
/** 获取选中的节点 */
private get selectedNodes(): WorkflowNodeEntity[] {
return this.selection.selection.filter(
n => n instanceof WorkflowNodeEntity,
) as WorkflowNodeEntity[];
}
/** 删除节点 */
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();
}
/** 删除连线 */
private removeLine(line: WorkflowLineEntity): void {
if (!this.document.linesManager.canRemove(line)) {
return;
}
line.dispose();
}
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { I18n } from '@coze-arch/i18n';
import { Toast } from '@coze-arch/coze-design';
import type { WorkflowNodeEntity } from '@flowgram-adapter/free-layout-editor';
import { hasSystemNodes, isAllSystemNodes } from '../copy/is-system-nodes';
export const isValid = (nodes: WorkflowNodeEntity[]): boolean => {
if (isAllSystemNodes(nodes)) {
Toast.warning({
content: I18n.t('workflow_multi_choice_delete_failed'),
showClose: false,
});
return false;
} else if (hasSystemNodes(nodes)) {
Toast.warning({
content: I18n.t('workflow_multi_choice_delete_failed'),
showClose: false,
});
return true;
}
return true;
};

View File

@@ -0,0 +1,102 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { inject, injectable } from 'inversify';
import { FlowNodeBaseType } from '@flowgram-adapter/free-layout-editor';
import {
WorkflowDocument,
type WorkflowNodeEntity,
} from '@flowgram-adapter/free-layout-editor';
import type {
WorkflowShortcutsContribution,
WorkflowShortcutsRegistry,
} from '@coze-workflow/render';
import { WorkflowGlobalStateEntity } from '@/typing';
import { type WorkflowExportData } from '@/shortcuts/type';
import { WORKFLOW_EXPORT_TYPE } from '@/shortcuts/constant';
import { WorkflowCopyShortcutsContribution } from '../copy';
import { safeFn } from '../../utils';
/**
* 导出快捷键
*/
@injectable()
export class WorkflowExportShortcutsContribution
implements WorkflowShortcutsContribution
{
public static readonly type = 'EXPORT';
@inject(WorkflowDocument)
private document: WorkflowDocument;
@inject(WorkflowGlobalStateEntity)
private globalState: WorkflowGlobalStateEntity;
@inject(WorkflowCopyShortcutsContribution)
private copyShortcuts: WorkflowCopyShortcutsContribution;
/** 注册快捷键 */
public registerShortcuts(registry: WorkflowShortcutsRegistry): void {
registry.addHandlers({
commandId: WorkflowExportShortcutsContribution.type,
shortcuts: ['meta shift s', 'ctrl shift s'],
execute: safeFn(this.handle.bind(this)),
});
}
private async handle(): Promise<void> {
const data = await this.toJSON();
this.download({ data, filename: this.filename });
}
private async toJSON(): Promise<WorkflowExportData> {
const source = this.copyShortcuts.toSource();
const json = await this.copyShortcuts.toJSON(this.validNodes);
const data: WorkflowExportData = {
type: WORKFLOW_EXPORT_TYPE,
source,
json,
};
return data;
}
private get validNodes(): WorkflowNodeEntity[] {
return this.document.root.blocks.filter(
b => b.flowNodeType !== FlowNodeBaseType.SUB_CANVAS,
);
}
private get filename(): string {
return `coze-workflow-${this.globalState.workflowId}.flow`;
}
private download(params: {
data: WorkflowExportData;
filename: string;
}): void {
const { data, filename } = params;
// 创建 Blob 对象
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: 'application/json',
});
// 创建下载链接并触发下载
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
// 清理
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
}

View File

@@ -0,0 +1,24 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { WorkflowPasteShortcutsContribution } from './paste';
export { WorkflowLayoutShortcutsContribution } from './layout';
export { WorkflowDeleteShortcutsContribution } from './delete';
export { WorkflowCopyShortcutsContribution } from './copy';
export { WorkflowZoomShortcutsContribution } from './zoom';
export { WorkflowExportShortcutsContribution } from './export';
export { WorkflowLoadShortcutsContribution } from './load';
export { WorkflowSelectAllShortcutsContribution } from './select-all';

View File

@@ -0,0 +1,62 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type {
LayoutNode,
LayoutStore,
} from '@flowgram-adapter/free-layout-editor';
import { FlowNodeBaseType } from '@flowgram-adapter/free-layout-editor';
import { StandardNodeType } from '@coze-workflow/base';
import { QuadTree } from './quad-tree';
export interface CommentContext {
store: LayoutStore;
quadTree: QuadTree;
}
const getQuadTree = (context: CommentContext): QuadTree => {
const nodes = context.store.nodes.filter(
node =>
![StandardNodeType.Comment, FlowNodeBaseType.SUB_CANVAS].includes(
node.entity.flowNodeType as StandardNodeType | FlowNodeBaseType,
),
);
context.quadTree = QuadTree.create(nodes);
return context.quadTree;
};
export const commentNodeHandler = (
node: LayoutNode,
context: CommentContext,
) => {
if (node.entity.flowNodeType !== StandardNodeType.Comment) {
return;
}
const quadTree = getQuadTree(context);
const followToNode = QuadTree.find(quadTree, node);
if (!followToNode) {
return;
}
// 加一点小偏移,防止连续触发两次后跟随节点变动
node.offset = {
x: 0,
y: -5,
};
return {
followTo: followToNode.id,
};
};

View File

@@ -0,0 +1,34 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { GetFollowNode } from '@flowgram-adapter/free-layout-editor';
import { FlowNodeBaseType } from '@flowgram-adapter/free-layout-editor';
import { StandardNodeType } from '@coze-workflow/base';
import { subCanvasHandler } from './sub-canvas-handler';
import {
type CommentContext,
commentNodeHandler,
} from './comment-node-handler';
export const getFollowNode: GetFollowNode = (node, context) => {
if (node.entity.flowNodeType === FlowNodeBaseType.SUB_CANVAS) {
return subCanvasHandler(node);
}
if (node.entity.flowNodeType === StandardNodeType.Comment) {
return commentNodeHandler(node, context as CommentContext);
}
};

View File

@@ -0,0 +1,108 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { inject, injectable } from 'inversify';
import {
FreeOperationType,
HistoryService,
} from '@flowgram-adapter/free-layout-editor';
import { AutoLayoutService } from '@flowgram-adapter/free-layout-editor';
import {
type PositionSchema,
TransformData,
} from '@flowgram-adapter/free-layout-editor';
import {
WorkflowDocument,
type WorkflowNodeEntity,
} from '@flowgram-adapter/free-layout-editor';
import type {
WorkflowShortcutsContribution,
WorkflowShortcutsRegistry,
} from '@coze-workflow/render';
import { WorkflowGlobalStateEntity } from '@/typing';
import { safeFn } from '../../utils';
import { getFollowNode } from './get-follow-node';
/**
* 自动布局快捷键
*/
@injectable()
export class WorkflowLayoutShortcutsContribution
implements WorkflowShortcutsContribution
{
public static readonly type = 'LAYOUT';
@inject(WorkflowDocument) private document: WorkflowDocument;
@inject(WorkflowGlobalStateEntity)
private globalState: WorkflowGlobalStateEntity;
@inject(AutoLayoutService) private autoLayoutService: AutoLayoutService;
@inject(HistoryService) private historyService: HistoryService;
/** 注册快捷键 */
public registerShortcuts(registry: WorkflowShortcutsRegistry): void {
registry.addHandlers({
commandId: WorkflowLayoutShortcutsContribution.type,
shortcuts: ['alt shift f'],
isEnabled: () => !this.globalState.readonly,
execute: safeFn(this.handle.bind(this)),
});
}
private async handle(): Promise<void> {
await this.autoLayout();
}
public async autoLayout(): Promise<void> {
const nodes = this.document.getAllNodes();
const startPositions = nodes.map(this.getNodePosition);
await this.autoLayoutService.layout({
getFollowNode,
});
const endPositions = nodes.map(this.getNodePosition);
this.updateHistory({
nodes,
startPositions,
endPositions,
});
}
private getNodePosition(node: WorkflowNodeEntity): PositionSchema {
const transform = node.getData(TransformData);
return {
x: transform.position.x,
y: transform.position.y,
};
}
private updateHistory(params: {
nodes: WorkflowNodeEntity[];
startPositions: PositionSchema[];
endPositions: PositionSchema[];
}): void {
const { nodes, startPositions: oldValue, endPositions: value } = params;
const ids = nodes.map(node => node.id);
this.historyService.pushOperation(
{
type: FreeOperationType.dragNodes,
value: {
ids,
value,
oldValue,
},
},
{
noApply: true,
},
);
}
}

View File

@@ -0,0 +1,251 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable prefer-destructuring -- no need */
/* eslint-disable @typescript-eslint/no-namespace -- 使用 namespace 方便管理 */
import type { LayoutNode } from '@flowgram-adapter/free-layout-editor';
interface QuadTreeBounds {
x: number;
y: number;
width: number;
height: number;
}
export interface QuadTree {
bounds: QuadTreeBounds;
nodes: LayoutNode[];
northWest: QuadTree | null;
northEast: QuadTree | null;
southWest: QuadTree | null;
southEast: QuadTree | null;
}
/** 默认容量 */
const DEFAULT_CAPACITY = 4;
/** 创建四叉树节点 */
const createNode = (bounds: QuadTreeBounds): QuadTree => ({
bounds,
nodes: [],
northWest: null,
northEast: null,
southWest: null,
southEast: null,
});
/** 检查节点是否在边界内(需要考虑节点的大小) */
const containsNode = (bounds: QuadTreeBounds, node: LayoutNode): boolean =>
node.position.x >= bounds.x &&
node.position.x + node.size.width <= bounds.x + bounds.width &&
node.position.y >= bounds.y &&
node.position.y + node.size.height <= bounds.y + bounds.height;
/** 细分节点 */
const subdivide = (quadTree: QuadTree): QuadTree => {
const { x, y, width, height } = quadTree.bounds;
const w = width / 2;
const h = height / 2;
return {
...quadTree,
northWest: createNode({ x, y, width: w, height: h }),
northEast: createNode({ x: x + w, y, width: w, height: h }),
southWest: createNode({ x, y: y + h, width: w, height: h }),
southEast: createNode({ x: x + w, y: y + h, width: w, height: h }),
};
};
/** 插入节点 */
const insert = (
quadTree: QuadTree,
node: LayoutNode,
capacity: number,
): QuadTree => {
if (!containsNode(quadTree.bounds, node)) {
return quadTree;
}
if (!quadTree.northWest) {
const newNodes = [...quadTree.nodes, node];
if (newNodes.length <= capacity) {
return { ...quadTree, nodes: newNodes };
}
}
const updatedQuadTree = quadTree.northWest ? quadTree : subdivide(quadTree);
// 检查节点是否可以插入到任何子象限
const canInsertIntoQuadrant = (quadrant: QuadTree | null): boolean =>
quadrant ? containsNode(quadrant.bounds, node) : false;
// 如果节点不能插入到任何子象限,将其保留在当前节点中
if (
!canInsertIntoQuadrant(updatedQuadTree.northWest) &&
!canInsertIntoQuadrant(updatedQuadTree.northEast) &&
!canInsertIntoQuadrant(updatedQuadTree.southWest) &&
!canInsertIntoQuadrant(updatedQuadTree.southEast)
) {
return { ...updatedQuadTree, nodes: [...updatedQuadTree.nodes, node] };
}
const insertIntoQuadrant = (quadrant: QuadTree | null): QuadTree | null =>
quadrant ? insert(quadrant, node, capacity) : null;
return {
...updatedQuadTree,
northWest: insertIntoQuadrant(updatedQuadTree.northWest),
northEast: insertIntoQuadrant(updatedQuadTree.northEast),
southWest: insertIntoQuadrant(updatedQuadTree.southWest),
southEast: insertIntoQuadrant(updatedQuadTree.southEast),
};
};
/** 计算两个节点之间的距离(考虑节点中心点) */
const distance = (n1: LayoutNode, n2: LayoutNode): number => {
const dx =
n1.position.x + n1.size.width / 2 - (n2.position.x + n2.size.width / 2);
const dy =
n1.position.y + n1.size.height / 2 - (n2.position.y + n2.size.height / 2);
return Math.sqrt(dx * dx + dy * dy);
};
/** 计算节点到边界的最小距离(考虑节点的大小) */
const distanceToBounds = (node: LayoutNode, bounds: QuadTreeBounds): number => {
if (containsNode(bounds, node)) {
// 如果节点完全在边界内返回0
return 0;
}
const dx = Math.max(
bounds.x - (node.position.x + node.size.width),
0,
node.position.x - (bounds.x + bounds.width),
);
const dy = Math.max(
bounds.y - (node.position.y + node.size.height),
0,
node.position.y - (bounds.y + bounds.height),
);
return Math.sqrt(dx * dx + dy * dy);
};
/** 计算节点集合的边界(考虑节点的大小) */
const calculateBounds = (nodes: LayoutNode[]): QuadTreeBounds => {
if (nodes.length === 0) {
// 返回一个默认的边界
return {
x: 0,
y: 0,
width: 1,
height: 1,
};
}
const xValues = nodes.map(node => node.position.x);
const yValues = nodes.map(node => node.position.y);
const widths = nodes.map(node => node.size.width);
const heights = nodes.map(node => node.size.height);
const minX = Math.min(...xValues);
const maxX = Math.max(...xValues.map((x, i) => x + widths[i]));
const minY = Math.min(...yValues);
const maxY = Math.max(...yValues.map((y, i) => y + heights[i]));
// 添加一些边距以确保所有点都在边界内
const margin = Math.max(maxX - minX, maxY - minY) * 0.1;
return {
x: minX - margin,
y: minY - margin,
width: maxX - minX + 2 * margin,
height: maxY - minY + 2 * margin,
};
};
/** 创建四叉树 */
export const createQuadTree = (nodes: LayoutNode[]): QuadTree => {
const bounds = calculateBounds(nodes);
let quadTree = createNode(bounds);
nodes.forEach(node => {
quadTree = insert(quadTree, node, DEFAULT_CAPACITY);
});
return quadTree;
};
/** 查找最近邻 */
export const findNearestNeighbor = (
quadTree: QuadTree,
targetNode: LayoutNode,
): LayoutNode | null => {
const findNearest = (
node: QuadTree,
nearest: LayoutNode | null,
minDistance: number,
): { nearest: LayoutNode | null; minDistance: number } => {
// 检查当前节点中的所有点
for (const currentNode of node.nodes) {
if (currentNode === targetNode) {
// 排除自身
continue;
}
const currentDistance = distance(targetNode, currentNode);
if (currentDistance < minDistance) {
nearest = currentNode;
minDistance = currentDistance;
}
}
// 如果这是叶子节点,返回结果
if (!node.northWest) {
return { nearest, minDistance };
}
// 检查子四叉树
const quadrants = [
node.northWest,
node.northEast,
node.southWest,
node.southEast,
]
.filter((q): q is QuadTree => q !== null)
.sort(
(a, b) =>
distanceToBounds(targetNode, a.bounds) -
distanceToBounds(targetNode, b.bounds),
);
for (const quadrant of quadrants) {
if (distanceToBounds(targetNode, quadrant.bounds) < minDistance) {
const result = findNearest(quadrant, nearest, minDistance);
nearest = result.nearest;
minDistance = result.minDistance;
}
}
return { nearest, minDistance };
};
const result = findNearest(quadTree, null, Infinity);
return result.nearest;
};
/** 四叉树工具类 */
export namespace QuadTree {
export const create = createQuadTree;
export const find = findNearestNeighbor;
}

View File

@@ -0,0 +1,30 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { LayoutNode } from '@flowgram-adapter/free-layout-editor';
import { FlowNodeBaseType } from '@flowgram-adapter/free-layout-editor';
import type { WorkflowNodeMeta } from '@coze-workflow/nodes';
export const subCanvasHandler = (node: LayoutNode) => {
if (node.entity.flowNodeType !== FlowNodeBaseType.SUB_CANVAS) {
return;
}
const nodeMeta = node.entity.getNodeMeta<WorkflowNodeMeta>();
const subCanvas = nodeMeta.subCanvas?.(node.entity);
return {
followTo: subCanvas?.parentNode.id,
};
};

View File

@@ -0,0 +1,149 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { inject, injectable } from 'inversify';
import {
EntityManager,
WorkflowDocument,
} from '@flowgram-adapter/free-layout-editor';
import type {
WorkflowShortcutsContribution,
WorkflowShortcutsRegistry,
} from '@coze-workflow/render';
import { Toast } from '@coze-arch/coze-design';
import { WorkflowGlobalStateEntity } from '@/typing';
import { type WorkflowExportData } from '@/shortcuts/type';
import { WORKFLOW_EXPORT_TYPE } from '@/shortcuts/constant';
import { WorkflowOperationService, WorkflowSaveService } from '@/services';
import { WorkflowPasteShortcutsContribution } from '../paste';
import { safeFn } from '../../utils';
/**
* 加载文件快捷键
*/
@injectable()
export class WorkflowLoadShortcutsContribution
implements WorkflowShortcutsContribution
{
public static readonly type = 'LOAD';
@inject(WorkflowPasteShortcutsContribution)
private pasteShortcuts: WorkflowPasteShortcutsContribution;
@inject(WorkflowDocument)
private document: WorkflowDocument;
@inject(WorkflowGlobalStateEntity)
private globalState: WorkflowGlobalStateEntity;
@inject(WorkflowSaveService)
private saveService: WorkflowSaveService;
@inject(WorkflowOperationService) operationService: WorkflowOperationService;
@inject(EntityManager) private entityManager: EntityManager;
/** 注册快捷键 */
public registerShortcuts(registry: WorkflowShortcutsRegistry): void {
registry.addHandlers({
commandId: WorkflowLoadShortcutsContribution.type,
shortcuts: ['meta shift l', 'ctrl shift l'],
isEnabled: () => !this.globalState.readonly,
execute: safeFn(this.handle.bind(this)),
});
}
/** 处理 */
private async handle(): Promise<void> {
const data = await this.load();
if (!data) {
return;
}
if (data.json.nodes.length > 200) {
// 大型工作流,刷新页面
await this.refresh(data);
} else {
// 小型工作流,重新渲染
await this.rerender(data);
}
}
/** 刷新 */
private async refresh(data: WorkflowExportData): Promise<void> {
await this.operationService.save(
data.json,
this.saveService.ignoreStatusTransfer,
);
await this.globalState.reload();
window.location.reload();
}
/** 重新渲染 */
private async rerender(data: WorkflowExportData): Promise<void> {
this.document.clear();
this.entityManager.changeEntityLocked = true;
this.pasteShortcuts.render({
json: data.json,
source: data.source,
});
this.entityManager.changeEntityLocked = false;
await this.saveService.fitView();
this.saveService.save();
}
/** 加载 */
private load(): Promise<WorkflowExportData | undefined> {
return new Promise((resolve, reject) => {
const handleError = (error: Error) => {
console.error(error);
Toast.error(`Load failed: ${error.message}`);
resolve(undefined);
};
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = '.json,.flow';
fileInput.addEventListener('change', event => {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) {
resolve(undefined);
return;
}
const reader = new FileReader();
reader.onload = e => {
try {
const content: WorkflowExportData = JSON.parse(
e.target?.result as string,
);
if (!this.validate(content)) {
handleError(new Error('Invalid file'));
return;
}
resolve(content);
} catch (error) {
handleError(error as Error);
}
};
reader.onerror = () => handleError(new Error('Read file failed'));
reader.readAsText(file);
});
fileInput.click();
});
}
/** 验证数据 */
private validate(data: WorkflowExportData): boolean {
if (data.type !== WORKFLOW_EXPORT_TYPE) {
return false;
}
return true;
}
}

View File

@@ -0,0 +1,438 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { inject, injectable } from 'inversify';
import {
FlowNodeBaseType,
FlowNodeTransformData,
} from '@flowgram-adapter/free-layout-editor';
import { EntityManager } from '@flowgram-adapter/free-layout-editor';
import {
WorkflowCommands,
WorkflowDocument,
type WorkflowEdgeJSON,
WorkflowHoverService,
type WorkflowLineEntity,
type WorkflowLinePortInfo,
WorkflowLinesManager,
WorkflowNodeEntity,
type WorkflowNodeMeta,
WorkflowSelectService,
type WorkflowSubCanvas,
} from '@flowgram-adapter/free-layout-editor';
import {
delay,
type IPoint,
type PositionSchema,
Rectangle,
} from '@flowgram-adapter/common';
import {
type WorkflowShortcutsContribution,
type WorkflowShortcutsRegistry,
} from '@coze-workflow/render';
import { type WorkflowNodeJSON } from '@coze-workflow/base';
import { I18n } from '@coze-arch/i18n';
import { Toast } from '@coze-arch/coze-design';
import { WorkflowGlobalStateEntity } from '@/typing';
import {
WorkflowCustomDragService,
WorkflowEditService,
WorkflowSaveService,
} from '@/services';
import { generateUniqueWorkflow } from '../../utils/unique-workflow';
import { safeFn } from '../../utils';
import type {
WorkflowClipboardData,
WorkflowClipboardJSON,
WorkflowClipboardNodeJSON,
WorkflowClipboardRect,
WorkflowClipboardSource,
} from '../../type';
import { isValidNode } from './is-valid-node';
import { isValidData } from './is-valid-data';
/**
* 粘贴快捷键
*/
@injectable()
export class WorkflowPasteShortcutsContribution
implements WorkflowShortcutsContribution
{
@inject(EntityManager) private entityManager: EntityManager;
@inject(WorkflowLinesManager) private linesManager: WorkflowLinesManager;
@inject(WorkflowDocument) private document: WorkflowDocument;
@inject(WorkflowHoverService) private hoverService: WorkflowHoverService;
@inject(WorkflowCustomDragService)
private dragService: WorkflowCustomDragService;
@inject(WorkflowSelectService) private selection: WorkflowSelectService;
@inject(WorkflowEditService) private editService: WorkflowEditService;
@inject(WorkflowGlobalStateEntity)
private globalState: WorkflowGlobalStateEntity;
@inject(WorkflowSaveService) private saveService: WorkflowSaveService;
/** 注册快捷键 */
public registerShortcuts(registry: WorkflowShortcutsRegistry): void {
registry.addHandlers({
commandId: WorkflowCommands.PASTE_NODES,
shortcuts: ['meta v', 'ctrl v'],
isEnabled: () => !this.globalState.readonly,
execute: safeFn(this.handle.bind(this)),
});
}
/** 渲染 */
public async render(params: {
json: WorkflowClipboardJSON;
source: WorkflowClipboardSource;
titleCache?: string[];
offset?: IPoint;
parent?: WorkflowNodeEntity;
toContainer?: WorkflowNodeEntity;
}): Promise<WorkflowNodeEntity[]> {
const {
json,
source,
titleCache = [],
offset = { x: 0, y: 0 },
parent,
toContainer,
} = params;
const nodes: WorkflowNodeEntity[] = await this.createNodes({
json: json.nodes,
source,
titleCache,
offset,
parent,
toContainer,
});
await this.nextTick(); // 等待节点渲染与动态端口渲染
this.createLines({
json: json.edges,
parent,
});
return nodes;
}
/** 处理复制事件 */
private async handle(
_event: KeyboardEvent,
): Promise<WorkflowNodeEntity[] | undefined> {
const data = await this.tryReadClipboard();
if (!data) {
return;
}
if (!isValidData({ data, globalState: this.globalState })) {
return;
}
const nodes = await this.apply(data);
if (nodes.length > 0) {
Toast.success({
content: I18n.t('copy_success'),
showClose: false,
});
// 滚动到可视区域
this.scrollToNodes(nodes);
}
return nodes;
}
/** 尝试读取剪贴板 */
private async tryReadClipboard(): Promise<WorkflowClipboardData | undefined> {
try {
// 需要用户授予网页剪贴板读取权限, 如果用户没有授予权限, 代码可能会抛出异常 NotAllowedError
const text: string = (await navigator.clipboard.readText()) || '';
const clipboardData: WorkflowClipboardData = JSON.parse(text);
return clipboardData;
// eslint-disable-next-line @coze-arch/use-error-in-catch -- no need report error
} catch (e) {
// 这里本身剪贴板里的数据就不固定,所以没必要报错
return;
}
}
/** 应用剪切板数据 */
private async apply(
data: WorkflowClipboardData,
): Promise<WorkflowNodeEntity[]> {
const { source, json: rawJSON } = data;
const json = generateUniqueWorkflow({
json: rawJSON,
isUniqueId: (id: string) => !this.entityManager.getEntityById(id),
});
// 重建节点前需要初始化节点数据
await this.saveService.initNodeData(json.nodes as WorkflowNodeJSON[]);
const titleCache: string[] = [];
const offset = this.calcPasteOffset(data.bounds);
const container = this.getSelectedContainer();
const nodes = await this.render({
json,
source,
titleCache,
offset,
parent: container,
toContainer: container,
});
this.selectNodes(nodes);
return nodes;
}
/** 计算粘贴偏移 */
private calcPasteOffset(boundsData: WorkflowClipboardRect): IPoint {
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,
};
}
/** 创建节点 */
private async createNodes(params: {
json: WorkflowClipboardNodeJSON[];
source: WorkflowClipboardSource;
titleCache: string[];
offset: IPoint;
parent?: WorkflowNodeEntity;
toContainer?: WorkflowNodeEntity;
}): Promise<WorkflowNodeEntity[]> {
const { json, source, titleCache, offset, parent, toContainer } = params;
const nodes: WorkflowNodeEntity[] = [];
await Promise.all(
json
.map(async (rawNodeJSON: WorkflowClipboardNodeJSON) => {
const { blocks, edges, ...nodeJSON } = rawNodeJSON;
const node = await this.createNode({
nodeJSON,
source,
titleCache,
offset,
parent,
toContainer,
});
if (!node) {
return;
}
const subCanvas = this.getNodeSubCanvas(node);
if (subCanvas) {
nodes.push(subCanvas.canvasNode);
}
if (blocks?.length) {
const container = this.getNodeSubCanvas(node)?.canvasNode ?? node;
this.render({
json: {
nodes: blocks,
edges: edges ?? [],
},
source,
titleCache,
offset,
parent: container,
});
}
nodes.push(node);
})
.filter(Boolean),
);
return nodes;
}
/** 获取节点子画布信息 */
private getNodeSubCanvas(
node: WorkflowNodeEntity,
): WorkflowSubCanvas | undefined {
if (!node) {
return;
}
const nodeMeta = node.getNodeMeta<WorkflowNodeMeta>();
const subCanvas = nodeMeta.subCanvas?.(node);
return subCanvas;
}
/** 创建节点 */
private async createNode(params: {
nodeJSON: WorkflowClipboardNodeJSON;
source: WorkflowClipboardSource;
titleCache: string[];
offset: IPoint;
parent?: WorkflowNodeEntity;
toContainer?: WorkflowNodeEntity;
}): Promise<WorkflowNodeEntity | undefined> {
const { nodeJSON, source, titleCache, offset, parent, toContainer } =
params;
if (
!isValidNode({
node: nodeJSON,
parent,
source,
globalState: this.globalState,
dragService: this.dragService,
})
) {
return;
}
// 生成唯一节点标题
const processedNodeJSON = this.editService.recreateNodeJSON(
nodeJSON,
titleCache,
false,
);
if (processedNodeJSON.meta?.canvasPosition) {
processedNodeJSON.meta.canvasPosition = {
x: processedNodeJSON.meta.canvasPosition.x + offset.x,
y: processedNodeJSON.meta.canvasPosition.y + offset.y,
};
}
const nodePosition = this.calcNodePosition({
nodeJSON,
offset,
parent,
toContainer,
});
const node = await this.document.copyNodeFromJSON(
nodeJSON.type as string,
processedNodeJSON,
nodeJSON.id,
nodePosition,
parent?.id,
);
return node;
}
/** 创建连线 */
private createLines(params: {
json: WorkflowEdgeJSON[];
parent?: WorkflowNodeEntity;
}): WorkflowLineEntity[] {
const { json, parent } = params;
return json
.map(edgeJSON => this.createLine({ edgeJSON, parent }))
.filter(Boolean) as WorkflowLineEntity[];
}
/** 创建连线 */
private createLine(params: {
edgeJSON: WorkflowEdgeJSON;
parent?: WorkflowNodeEntity;
}): WorkflowLineEntity | undefined {
const { edgeJSON, parent } = params;
const fromNode = this.entityManager.getEntityById<WorkflowNodeEntity>(
edgeJSON.sourceNodeID,
);
const toNode = this.entityManager.getEntityById<WorkflowNodeEntity>(
edgeJSON.targetNodeID,
);
if (!fromNode || !toNode) {
return;
}
const lineInfo: WorkflowLinePortInfo = {
from: edgeJSON.sourceNodeID,
fromPort: edgeJSON.sourcePortID,
to: edgeJSON.targetNodeID,
toPort: edgeJSON.targetPortID,
};
if (!parent) {
return this.linesManager.createLine(lineInfo);
}
// 父子节点之间连线,需替换父节点为子画布
const parentSubCanvas = this.getNodeSubCanvas(parent);
if (!parentSubCanvas) {
return this.linesManager.createLine(lineInfo);
}
if (lineInfo.from === parentSubCanvas.parentNode.id) {
return this.linesManager.createLine({
...lineInfo,
from: parentSubCanvas.canvasNode.id,
});
}
if (lineInfo.to === parentSubCanvas.parentNode.id) {
return this.linesManager.createLine({
...lineInfo,
to: parentSubCanvas.canvasNode.id,
});
}
return this.linesManager.createLine(lineInfo);
}
/** 计算节点位置 */
private calcNodePosition(params: {
nodeJSON: WorkflowClipboardNodeJSON;
parent?: WorkflowNodeEntity;
offset: IPoint;
toContainer?: WorkflowNodeEntity;
}): PositionSchema {
const { nodeJSON, parent, offset, toContainer } = params;
if (!nodeJSON.meta?.position) {
return this.hoverService.hoveredPos;
}
const bounds = new Rectangle(
nodeJSON._temp.bounds.x,
nodeJSON._temp.bounds.y,
nodeJSON._temp.bounds.width,
nodeJSON._temp.bounds.height,
);
const basePosition: PositionSchema =
parent && !toContainer
? nodeJSON.meta.position
: {
x: offset.x + bounds.center.x,
y: offset.y + bounds.y,
};
if (toContainer) {
return this.dragService.adjustSubNodePosition(
nodeJSON.type as string,
toContainer,
basePosition,
);
}
return basePosition;
}
/** 获取鼠标选中的容器 */
private getSelectedContainer(): WorkflowNodeEntity | undefined {
const { activatedNode } = this.selection;
if (activatedNode?.flowNodeType === FlowNodeBaseType.SUB_CANVAS) {
return activatedNode;
}
}
/** 获取选中的节点 */
private get selectedNodes(): WorkflowNodeEntity[] {
return this.selection.selection.filter(
n => n instanceof WorkflowNodeEntity,
) as WorkflowNodeEntity[];
}
/** 选中节点 */
private selectNodes(nodes: WorkflowNodeEntity[]): void {
if (nodes.length === 1) {
this.editService.focusNode(nodes[0]);
} else {
this.selection.selection = nodes;
}
}
/** 滚动到节点 */
private async scrollToNodes(nodes: WorkflowNodeEntity[]): Promise<void> {
const nodeBounds = nodes.map(
node => node.getData(FlowNodeTransformData).bounds,
);
await this.document.playgroundConfig.scrollToView({
bounds: Rectangle.enlarge(nodeBounds),
});
}
/** 等待下一帧 */
private async nextTick(): Promise<void> {
const frameTime = 16; // 16ms 为一个渲染帧
await delay(frameTime);
await new Promise(resolve => requestAnimationFrame(resolve));
}
}

View File

@@ -0,0 +1,63 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { I18n } from '@coze-arch/i18n';
import { Toast } from '@coze-arch/coze-design';
import { type WorkflowGlobalStateEntity } from '@/typing';
import { type WorkflowClipboardData } from '../../type';
import { WORKFLOW_CLIPBOARD_TYPE } from '../../constant';
/** 检查数据是否合法 */
export const isValidData = (params: {
data: WorkflowClipboardData;
globalState: WorkflowGlobalStateEntity;
}): boolean => {
const { data, globalState } = params;
if (data.type !== WORKFLOW_CLIPBOARD_TYPE) {
return false;
}
// 跨域名表示不同环境,上架插件不同,不能复制
if (data.source.host !== window.location.host) {
return false;
}
// 抖音空间不兼容正常空间
if (data.source.isDouyin !== globalState.isBindDouyin) {
Toast.warning({
content: I18n.t(
'workflow_node_copy_othercanva',
{},
'当前画布类型不一致,无法粘贴',
),
showClose: false,
});
return false;
}
// 不同的画布类型不能复制
if (data.source.flowMode !== globalState.flowMode) {
Toast.warning({
content: I18n.t(
'workflow_node_copy_othercanva',
{},
'当前画布类型不一致,无法粘贴',
),
showClose: false,
});
return false;
}
return true;
};

View File

@@ -0,0 +1,63 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { WorkflowNodeEntity } from '@flowgram-adapter/free-layout-editor';
import type { WorkflowCustomDragService } from '@/services';
import type { WorkflowGlobalStateEntity } from '@/typing';
import type {
WorkflowClipboardNodeJSON,
WorkflowClipboardSource,
} from '../../type';
import {
ApiNodeValidator,
CrossSpaceNodeValidator,
DropValidator,
LoopContextValidator,
NestedLoopBatchValidator,
SameSpaceValidator,
SameWorkflowValidator,
SceneNodeValidator,
SubWorkflowSelfRefValidator,
} from './validators';
import { ValidationChain } from './validators/validation-chain';
/** 是否合法节点 */
export const isValidNode = (params: {
node: WorkflowClipboardNodeJSON;
parent?: WorkflowNodeEntity;
source: WorkflowClipboardSource;
globalState: WorkflowGlobalStateEntity;
dragService: WorkflowCustomDragService;
}): boolean => {
const validationChain = new ValidationChain();
validationChain
// 1. 相同空间,相同工作流
.setNext(new DropValidator())
.setNext(new LoopContextValidator())
.setNext(new NestedLoopBatchValidator())
.setNext(new SubWorkflowSelfRefValidator())
// 2. 相同空间,不同工作流
.setNext(new SameWorkflowValidator())
.setNext(new SceneNodeValidator())
// 3. 跨空间空间
.setNext(new SameSpaceValidator())
.setNext(new ApiNodeValidator())
.setNext(new CrossSpaceNodeValidator());
return validationChain.run(params);
};

View File

@@ -0,0 +1,40 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ApiNodeData } from '@coze-workflow/nodes';
import { StandardNodeType } from '@coze-workflow/base';
import { PluginProductStatus } from '@coze-arch/idl/developer_api';
import {
BaseNodeValidator,
type NodeValidationContext,
} from './base-validator';
export class ApiNodeValidator extends BaseNodeValidator {
protected validate(context: NodeValidationContext): boolean | null {
const { node } = context;
if (node.type !== StandardNodeType.Api) {
return null;
}
// 不允许跨空间复制未上架的插件节点
const apiNodeData = node._temp.externalData as ApiNodeData;
const isListed =
apiNodeData?.pluginProductStatus === PluginProductStatus.Listed;
return isListed;
}
}

View File

@@ -0,0 +1,57 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { WorkflowNodeEntity } from '@flowgram-adapter/free-layout-editor';
import type { WorkflowGlobalStateEntity } from '@/typing';
import type { WorkflowCustomDragService } from '@/services';
import type {
WorkflowClipboardNodeJSON,
WorkflowClipboardSource,
} from '../../../type';
export interface NodeValidationContext {
node: WorkflowClipboardNodeJSON;
source: WorkflowClipboardSource;
globalState: WorkflowGlobalStateEntity;
dragService: WorkflowCustomDragService;
parent?: WorkflowNodeEntity;
}
export interface NodeValidator {
run: (context: NodeValidationContext) => boolean;
setNext: (validator: NodeValidator) => NodeValidator;
}
export abstract class BaseNodeValidator implements NodeValidator {
protected next: NodeValidator | null = null;
setNext(validator: NodeValidator): NodeValidator {
this.next = validator;
return validator;
}
run(context: NodeValidationContext): boolean {
const result = this.validate(context);
if (result !== null) {
return result;
}
return this.next?.run(context) ?? true;
}
protected abstract validate(context: NodeValidationContext): boolean | null;
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { StandardNodeType } from '@coze-workflow/base';
import {
BaseNodeValidator,
type NodeValidationContext,
} from './base-validator';
export class CrossSpaceNodeValidator extends BaseNodeValidator {
protected validate(context: NodeValidationContext): boolean | null {
const { node } = context;
// 不允许跨空间复制的节点
if (
[
StandardNodeType.Dataset,
StandardNodeType.DatasetWrite,
StandardNodeType.Database,
StandardNodeType.DatabaseQuery,
StandardNodeType.DatabaseCreate,
StandardNodeType.DatabaseUpdate,
StandardNodeType.DatabaseDelete,
StandardNodeType.SubWorkflow,
StandardNodeType.Imageflow,
].includes(node.type as StandardNodeType)
) {
return false;
}
return null;
}
}

View File

@@ -0,0 +1,37 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { StandardNodeType } from '@coze-workflow/base';
import {
BaseNodeValidator,
type NodeValidationContext,
} from './base-validator';
export class DropValidator extends BaseNodeValidator {
protected validate(context: NodeValidationContext): boolean | null {
const { node, dragService, parent } = context;
const canDropInfo = dragService.canDropToNode({
dragNodeType: node.type as StandardNodeType,
dropNode: parent,
});
if (!canDropInfo.allowDrop) {
return false;
}
return null;
}
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { ApiNodeValidator } from './api-node-validator';
export {
NodeValidationContext,
NodeValidator,
BaseNodeValidator,
} from './base-validator';
export { CrossSpaceNodeValidator } from './cross-space-node-validator';
export { DropValidator } from './drop-validator';
export { LoopContextValidator } from './loop-context-validator';
export { NestedLoopBatchValidator } from './nested-loop-batch-validator';
export { SameSpaceValidator } from './same-space-validator';
export { SameWorkflowValidator } from './same-workflow-validator';
export { SceneNodeValidator } from './scene-node-validator';
export { ValidationChain } from './validation-chain';
export { SubWorkflowSelfRefValidator } from './sub-workflow-self-ref-validator';

View File

@@ -0,0 +1,51 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { StandardNodeType } from '@coze-workflow/base';
import {
BaseNodeValidator,
type NodeValidationContext,
} from './base-validator';
export class LoopContextValidator extends BaseNodeValidator {
protected validate(context: NodeValidationContext): boolean | null {
const { node, parent } = context;
const nodeType = node.type as StandardNodeType;
if (!parent) {
return null;
}
// Break / SetVariable / Continue 只允许在 Loop SubCanvas 内
if (
[
StandardNodeType.Break,
StandardNodeType.Continue,
StandardNodeType.SetVariable,
].includes(nodeType)
) {
const parentNodeMeta = parent.getNodeMeta();
const parentSubCanvas = parentNodeMeta.subCanvas?.(parent);
return (
parentSubCanvas?.isCanvas &&
parentSubCanvas.parentNode.flowNodeType === StandardNodeType.Loop
);
}
return null;
}
}

View File

@@ -0,0 +1,41 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { FlowNodeBaseType } from '@flowgram-adapter/free-layout-editor';
import { StandardNodeType } from '@coze-workflow/base';
import {
BaseNodeValidator,
type NodeValidationContext,
} from './base-validator';
export class NestedLoopBatchValidator extends BaseNodeValidator {
protected validate(context: NodeValidationContext): boolean | null {
const { node, parent } = context;
const nodeType = node.type as StandardNodeType;
if (!parent) {
return null;
}
// Loop / Batch 不允许嵌套
if ([StandardNodeType.Loop, StandardNodeType.Batch].includes(nodeType)) {
return parent.flowNodeType !== FlowNodeBaseType.SUB_CANVAS;
}
return null;
}
}

View File

@@ -0,0 +1,28 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
BaseNodeValidator,
type NodeValidationContext,
} from './base-validator';
export class SameSpaceValidator extends BaseNodeValidator {
protected validate(context: NodeValidationContext): boolean | null {
const { source, globalState } = context;
return source.spaceId === globalState.spaceId ? true : null;
}
}

View File

@@ -0,0 +1,27 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
BaseNodeValidator,
type NodeValidationContext,
} from './base-validator';
export class SameWorkflowValidator extends BaseNodeValidator {
protected validate(context: NodeValidationContext): boolean | null {
const { source, globalState } = context;
return source.workflowId === globalState.workflowId ? true : null;
}
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { StandardNodeType } from '@coze-workflow/base';
import {
BaseNodeValidator,
type NodeValidationContext,
} from './base-validator';
export class SceneNodeValidator extends BaseNodeValidator {
protected validate(context: NodeValidationContext): boolean | null {
const { node } = context;
// 不允许跨工作流复制场景工作流专属节点
if (
node.type === StandardNodeType.SceneChat ||
node.type === StandardNodeType.SceneVariable
) {
return false;
}
return null;
}
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { StandardNodeType } from '@coze-workflow/base';
import {
BaseNodeValidator,
type NodeValidationContext,
} from './base-validator';
export class SubWorkflowSelfRefValidator extends BaseNodeValidator {
protected validate(context: NodeValidationContext): boolean | null {
const { node, globalState } = context;
// 不允许工作流引用自己作为子工作流
if (
node.type === StandardNodeType.SubWorkflow &&
node.data?.inputs?.workflowId === globalState.workflowId
) {
return false;
}
return null;
}
}

View File

@@ -0,0 +1,23 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { BaseNodeValidator } from './base-validator';
export class ValidationChain extends BaseNodeValidator {
protected validate(): boolean | null {
return null;
}
}

View File

@@ -0,0 +1,59 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { inject, injectable } from 'inversify';
import {
WorkflowDocument,
WorkflowSelectService,
} from '@flowgram-adapter/free-layout-editor';
import type {
WorkflowShortcutsContribution,
WorkflowShortcutsRegistry,
} from '@coze-workflow/render';
import { WorkflowGlobalStateEntity } from '@/typing';
import { safeFn } from '../../utils';
/**
* 全选快捷键
*/
@injectable()
export class WorkflowSelectAllShortcutsContribution
implements WorkflowShortcutsContribution
{
public static readonly type = 'SELECT_ALL';
@inject(WorkflowDocument)
private document: WorkflowDocument;
@inject(WorkflowGlobalStateEntity)
private globalState: WorkflowGlobalStateEntity;
@inject(WorkflowSelectService)
private selectService: WorkflowSelectService;
/** 注册快捷键 */
public registerShortcuts(registry: WorkflowShortcutsRegistry): void {
registry.addHandlers({
commandId: WorkflowSelectAllShortcutsContribution.type,
shortcuts: ['meta a', 'ctrl a'],
isEnabled: () => !this.globalState.readonly,
execute: safeFn(this.handle.bind(this)),
});
}
private handle(): void {
const nodes = this.document.root.blocks;
this.selectService.selection = nodes;
}
}

View File

@@ -0,0 +1,61 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { inject, injectable } from 'inversify';
import { PlaygroundConfigEntity } from '@flowgram-adapter/free-layout-editor';
import { WorkflowCommands } from '@flowgram-adapter/free-layout-editor';
import type {
WorkflowShortcutsContribution,
WorkflowShortcutsRegistry,
} from '@coze-workflow/render';
import { safeFn } from '../../utils';
/**
* 缩放快捷键
*/
@injectable()
export class WorkflowZoomShortcutsContribution
implements WorkflowShortcutsContribution
{
@inject(PlaygroundConfigEntity)
private playgroundConfig: PlaygroundConfigEntity;
/** 注册快捷键 */
public registerShortcuts(registry: WorkflowShortcutsRegistry): void {
registry.addHandlers({
commandId: WorkflowCommands.ZOOM_IN,
shortcuts: ['meta =', 'ctrl ='],
execute: safeFn(this.zoomIn.bind(this)),
});
registry.addHandlers({
commandId: WorkflowCommands.ZOOM_OUT,
shortcuts: ['meta -', 'ctrl -'],
execute: safeFn(this.zoomOut.bind(this)),
});
}
private zoomIn(): void {
if (this.playgroundConfig.zoom > 1.9) {
return;
}
this.playgroundConfig.zoomin();
}
private zoomOut(): void {
if (this.playgroundConfig.zoom < 0.1) {
return;
}
this.playgroundConfig.zoomout();
}
}

View File

@@ -0,0 +1,26 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { WORKFLOW_CLIPBOARD_TYPE } from './constant';
export { bindShortcuts } from './utils';
export {
WorkflowClipboardData,
WorkflowClipboardJSON,
WorkflowClipboardSource,
WorkflowClipboardNodeJSON,
} from './type';
// eslint-disable-next-line @coze-arch/no-batch-import-or-export -- no duplicate declaration
export * from './contributions';

View File

@@ -0,0 +1,68 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type {
WorkflowEdgeJSON,
WorkflowNodeJSON,
} from '@flowgram-adapter/free-layout-editor';
import type { NodeData } from '@coze-workflow/nodes';
import type { ValueOf, WorkflowMode } from '@coze-workflow/base';
import type { WORKFLOW_CLIPBOARD_TYPE, WORKFLOW_EXPORT_TYPE } from './constant';
export interface WorkflowClipboardData {
type: typeof WORKFLOW_CLIPBOARD_TYPE;
json: WorkflowClipboardJSON;
source: WorkflowClipboardSource;
bounds: WorkflowClipboardRect;
}
export interface WorkflowExportData {
type: typeof WORKFLOW_EXPORT_TYPE;
json: WorkflowClipboardJSON;
source: WorkflowClipboardSource;
}
export interface WorkflowClipboardJSON {
nodes: WorkflowClipboardNodeJSON[];
edges: WorkflowEdgeJSON[];
}
export interface WorkflowClipboardSource {
workflowId: string;
flowMode: WorkflowMode;
spaceId: string;
host: string;
isDouyin: boolean;
}
export interface WorkflowClipboardRect {
x: number;
y: number;
width: number;
height: number;
}
export interface WorkflowClipboardNodeTemporary {
externalData?: ValueOf<NodeData>;
bounds: WorkflowClipboardRect;
}
export interface WorkflowClipboardNodeJSON extends WorkflowNodeJSON {
blocks?: WorkflowClipboardNodeJSON[];
// eslint-disable-next-line @typescript-eslint/naming-convention -- _temp 是内部字段,不对外暴露
_temp: WorkflowClipboardNodeTemporary;
}

View File

@@ -0,0 +1,18 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { bindShortcuts } from './register';
export { safeFn } from './safe-fn';

View File

@@ -0,0 +1,33 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { interfaces } from 'inversify';
import { bindContributions } from '@flowgram-adapter/common';
import type { WorkflowShortcutsContribution } from '@coze-workflow/render';
interface ShortcutFactory {
new (): WorkflowShortcutsContribution;
}
export const bindShortcuts = (
bind: interfaces.Bind,
to: typeof WorkflowShortcutsContribution,
shortcuts: ShortcutFactory[],
) => {
shortcuts.forEach(shortcut => {
bindContributions(bind, shortcut, [to]);
});
};

View File

@@ -0,0 +1,38 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { reporter, logger } from '@coze-arch/logger';
import { Toast } from '@coze-arch/coze-design';
export const safeFn =
(fn: Function) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- no need to check type
(...args: any[]) => {
try {
return fn(...args);
} catch (e) {
Toast.error({
content: `[Coze Workflow] Failed to run function: ${fn.name || '() => any'}`,
});
console.error('Failed to run function: ', e);
reporter.errorEvent({
namespace: 'workflow',
eventName: 'workflow_shortcuts_error',
error: e,
});
logger.error(e);
}
};

View File

@@ -0,0 +1,125 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable complexity -- no need to fix */
/* eslint-disable @typescript-eslint/no-namespace -- no need to fix */
import { customAlphabet } from 'nanoid';
import { traverse, type TraverseContext } from '@coze-workflow/base';
import type { WorkflowClipboardJSON, WorkflowClipboardNodeJSON } from '../type';
namespace UniqueWorkflowUtils {
/** 生成唯一ID */
const generateUniqueId = customAlphabet('1234567890', 6);
/** 获取所有节点ID */
export const getAllNodeIds = (json: WorkflowClipboardJSON): string[] => {
const nodeIds = new Set<string>();
const addNodeId = (node: WorkflowClipboardNodeJSON) => {
nodeIds.add(node.id);
if (node.blocks?.length) {
node.blocks.forEach(child => addNodeId(child));
}
};
json.nodes.forEach(node => addNodeId(node));
return Array.from(nodeIds);
};
/** 生成节点替换映射 */
export const generateNodeReplaceMap = (
nodeIds: string[],
isUniqueId: (id: string) => boolean,
): Map<string, string> => {
const nodeReplaceMap = new Map<string, string>();
nodeIds.forEach(id => {
if (isUniqueId(id)) {
nodeReplaceMap.set(id, id);
} else {
let newId: string;
do {
// 这里添加一个固定前缀,避免 ID 以 0 开头,后端会报错
newId = `1${generateUniqueId()}`;
} while (!isUniqueId(newId));
nodeReplaceMap.set(id, newId);
}
});
return nodeReplaceMap;
};
/** 是否存在 */
const isExist = (value: unknown): boolean =>
value !== null && value !== undefined;
/** 是否需要处理 */
const shouldHandle = (context: TraverseContext): boolean => {
const { node } = context;
// 线条数据
if (
node?.key &&
['sourceNodeID', 'targetNodeID'].includes(node.key) &&
node.parent?.parent?.key === 'edges'
) {
return true;
}
// 节点数据
if (
node?.key === 'id' &&
isExist(node.container?.type) &&
isExist(node.container?.meta) &&
isExist(node.container?.data)
) {
return true;
}
// 变量数据
if (
node?.key === 'blockID' &&
isExist(node.container?.name) &&
node.container?.source === 'block-output'
) {
return true;
}
return false;
};
/**
* 替换节点ID
* NOTICE: 该方法有副作用会修改传入的json防止深拷贝造成额外性能开销
*/
export const replaceNodeId = (
json: WorkflowClipboardJSON,
nodeReplaceMap: Map<string, string>,
): WorkflowClipboardJSON => {
traverse(json, context => {
if (!shouldHandle(context)) {
return;
}
const { node } = context;
if (nodeReplaceMap.has(node.value)) {
context.setValue(nodeReplaceMap.get(node.value));
}
});
return json;
};
}
/** 生成唯一工作流JSON */
export const generateUniqueWorkflow = (params: {
json: WorkflowClipboardJSON;
isUniqueId: (id: string) => boolean;
}): WorkflowClipboardJSON => {
const { json, isUniqueId } = params;
const nodeIds = UniqueWorkflowUtils.getAllNodeIds(json);
const nodeReplaceMap = UniqueWorkflowUtils.generateNodeReplaceMap(
nodeIds,
isUniqueId,
);
return UniqueWorkflowUtils.replaceNodeId(json, nodeReplaceMap);
};