feat(flows): 新增流程编辑器基础功能与相关组件

feat(backend): 添加流程模型与服务支持
feat(frontend): 实现流程编辑器UI与交互
feat(assets): 添加流程节点图标资源
feat(plugins): 实现上下文菜单和运行时插件
feat(components): 新增基础节点和侧边栏组件
feat(routes): 添加流程相关路由配置
feat(models): 创建流程和运行日志数据模型
feat(services): 实现流程服务层逻辑
feat(migration): 添加流程相关数据库迁移
feat(config): 更新前端配置支持流程编辑器
feat(utils): 增强axios错误处理和工具函数
This commit is contained in:
2025-09-15 00:27:13 +08:00
parent 9da3978f91
commit b0963e5e37
291 changed files with 17947 additions and 86 deletions

View File

@ -0,0 +1,29 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { Button } from '@douyinfe/semi-ui';
import { IconPlus } from '@douyinfe/semi-icons';
import { I18n } from '@flowgram.ai/free-layout-editor';
import { useAddNode } from './use-add-node';
export const AddNode = (props: { disabled: boolean }) => {
const addNode = useAddNode();
return (
<Button
data-testid="flow.add-node"
icon={<IconPlus />}
color="highlight"
style={{ backgroundColor: 'rgba(171,181,255,0.3)', borderRadius: '8px' }}
disabled={props.disabled}
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
addNode(rect);
}}
>
{I18n.t('Add Node')}
</Button>
);
};

View File

@ -0,0 +1,115 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useCallback } from 'react';
import { NodePanelResult, WorkflowNodePanelService } from '@flowgram.ai/free-node-panel-plugin';
import {
useService,
WorkflowDocument,
usePlayground,
PositionSchema,
WorkflowNodeEntity,
WorkflowSelectService,
WorkflowNodeJSON,
getAntiOverlapPosition,
WorkflowNodeMeta,
FlowNodeBaseType,
} from '@flowgram.ai/free-layout-editor';
// hook to get panel position from mouse event - 从鼠标事件获取面板位置的 hook
const useGetPanelPosition = () => {
const playground = usePlayground();
return useCallback(
(targetBoundingRect: DOMRect): PositionSchema =>
// convert mouse position to canvas position - 将鼠标位置转换为画布位置
playground.config.getPosFromMouseEvent({
clientX: targetBoundingRect.left + 64,
clientY: targetBoundingRect.top - 7,
}),
[playground]
);
};
// hook to handle node selection - 处理节点选择的 hook
const useSelectNode = () => {
const selectService = useService(WorkflowSelectService);
return useCallback(
(node?: WorkflowNodeEntity) => {
if (!node) {
return;
}
// select the target node - 选择目标节点
selectService.selectNode(node);
},
[selectService]
);
};
const getContainerNode = (selectService: WorkflowSelectService) => {
const { activatedNode } = selectService;
if (!activatedNode) {
return;
}
const { isContainer } = activatedNode.getNodeMeta<WorkflowNodeMeta>();
if (isContainer) {
return activatedNode;
}
const parentNode = activatedNode.parent;
if (!parentNode || parentNode.flowNodeType === FlowNodeBaseType.ROOT) {
return;
}
return parentNode;
};
// main hook for adding new nodes - 添加新节点的主 hook
export const useAddNode = () => {
const workflowDocument = useService(WorkflowDocument);
const nodePanelService = useService<WorkflowNodePanelService>(WorkflowNodePanelService);
const selectService = useService(WorkflowSelectService);
const playground = usePlayground();
const getPanelPosition = useGetPanelPosition();
const select = useSelectNode();
return useCallback(
async (targetBoundingRect: DOMRect): Promise<void> => {
// calculate panel position based on target element - 根据目标元素计算面板位置
const panelPosition = getPanelPosition(targetBoundingRect);
const containerNode = getContainerNode(selectService);
await new Promise<void>((resolve) => {
// call the node panel service to show the panel - 调用节点面板服务来显示面板
nodePanelService.callNodePanel({
position: panelPosition,
enableMultiAdd: true,
containerNode,
panelProps: {},
// handle node selection from panel - 处理从面板中选择节点
onSelect: async (panelParams?: NodePanelResult) => {
if (!panelParams) {
return;
}
const { nodeType, nodeJSON } = panelParams;
const position = Boolean(containerNode)
? getAntiOverlapPosition(workflowDocument, {
x: 0,
y: 200,
})
: undefined;
// create new workflow node based on selected type - 根据选择的类型创建新的工作流节点
const node: WorkflowNodeEntity = workflowDocument.createWorkflowNodeByType(
nodeType,
position, // position undefined means create node in center of canvas - position undefined 可以在画布中间创建节点
nodeJSON ?? ({} as WorkflowNodeJSON),
containerNode?.id
);
select(node);
},
// handle panel close - 处理面板关闭
onClose: () => {
resolve();
},
});
});
},
[getPanelPosition, nodePanelService, playground.config.zoom, workflowDocument, select]
);
};

View File

@ -0,0 +1,45 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useCallback } from 'react';
import { FlowNodeEntity, useNodeRender } from '@flowgram.ai/free-layout-editor';
import { ConfigProvider } from '@douyinfe/semi-ui';
import { NodeStatusBar } from '../testrun/node-status-bar';
import { NodeRenderContext } from '../../context';
import { ErrorIcon } from './styles';
import { NodeWrapper } from './node-wrapper';
export const BaseNode = ({ node }: { node: FlowNodeEntity }) => {
/**
* Provides methods related to node rendering
* 提供节点渲染相关的方法
*/
const nodeRender = useNodeRender();
/**
* It can only be used when nodeEngine is enabled
* 只有在节点引擎开启时候才能使用表单
*/
const form = nodeRender.form;
/**
* Used to make the Tooltip scale with the node, which can be implemented by itself depending on the UI library
* 用于让 Tooltip 跟随节点缩放, 这个可以根据不同的 ui 库自己实现
*/
const getPopupContainer = useCallback(() => node.renderData.node || document.body, []);
return (
<ConfigProvider getPopupContainer={getPopupContainer}>
<NodeRenderContext.Provider value={nodeRender}>
<NodeWrapper>
{form?.state.invalid && <ErrorIcon />}
{form?.render()}
</NodeWrapper>
<NodeStatusBar />
</NodeRenderContext.Provider>
</ConfigProvider>
);
};

View File

@ -0,0 +1,81 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import React, { useState, useContext } from 'react';
import { WorkflowPortRender } from '@flowgram.ai/free-layout-editor';
import { useClientContext } from '@flowgram.ai/free-layout-editor';
import { FlowNodeMeta } from '../../typings';
import { useNodeRenderContext, usePortClick } from '../../hooks';
import { SidebarContext } from '../../context';
import { scrollToView } from './utils';
import { NodeWrapperStyle } from './styles';
export interface NodeWrapperProps {
isScrollToView?: boolean;
children: React.ReactNode;
}
/**
* Used for drag-and-drop/click events and ports rendering of nodes
* 用于节点的拖拽/点击事件和点位渲染
*/
export const NodeWrapper: React.FC<NodeWrapperProps> = (props) => {
const { children, isScrollToView = false } = props;
const nodeRender = useNodeRenderContext();
const { node, selected, startDrag, ports, selectNode, nodeRef, onFocus, onBlur, readonly } =
nodeRender;
const [isDragging, setIsDragging] = useState(false);
const sidebar = useContext(SidebarContext);
const form = nodeRender.form;
const ctx = useClientContext();
const onPortClick = usePortClick();
const meta = node.getNodeMeta<FlowNodeMeta>();
const portsRender = ports.map((p) => (
<WorkflowPortRender key={p.id} entity={p} onClick={!readonly ? onPortClick : undefined} />
));
return (
<>
<NodeWrapperStyle
className={selected ? 'selected' : ''}
ref={nodeRef}
draggable
onDragStart={(e) => {
startDrag(e);
setIsDragging(true);
}}
onTouchStart={(e) => {
startDrag(e as unknown as React.MouseEvent);
setIsDragging(true);
}}
onClick={(e) => {
selectNode(e);
if (!isDragging) {
sidebar.setNodeId(nodeRender.node.id);
// 可选:将 isScrollToView 设为 true可以让节点选中后滚动到画布中间
// Optional: Set isScrollToView to true to scroll the node to the center of the canvas after it is selected.
if (isScrollToView) {
scrollToView(ctx, nodeRender.node);
}
}
}}
onMouseUp={() => setIsDragging(false)}
onFocus={onFocus}
onBlur={onBlur}
data-node-selected={String(selected)}
style={{
...meta.wrapperStyle,
outline: form?.state.invalid ? '1px solid red' : 'none',
}}
>
{children}
</NodeWrapperStyle>
{portsRender}
</>
);
};

View File

@ -0,0 +1,39 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import styled from 'styled-components';
import { IconInfoCircle } from '@douyinfe/semi-icons';
export const NodeWrapperStyle = styled.div`
align-items: flex-start;
background-color: #fff;
border: 1px solid rgba(6, 7, 9, 0.15);
border-radius: 8px;
box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.04), 0 4px 12px 0 rgba(0, 0, 0, 0.02);
display: flex;
flex-direction: column;
justify-content: center;
position: relative;
width: 360px;
height: auto;
&.selected {
border: 1px solid #4e40e5;
}
`;
export const ErrorIcon = () => (
<IconInfoCircle
style={{
position: 'absolute',
color: 'red',
left: -6,
top: -6,
zIndex: 1,
background: 'white',
borderRadius: 8,
}}
/>
);

View File

@ -0,0 +1,23 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FreeLayoutPluginContext, FlowNodeEntity } from '@flowgram.ai/free-layout-editor';
export function scrollToView(
ctx: FreeLayoutPluginContext,
node: FlowNodeEntity,
sidebarWidth = 448
) {
const bounds = node.transform.bounds;
ctx.playground.scrollToView({
bounds,
scrollDelta: {
x: sidebarWidth / 2,
y: 0,
},
zoom: 1,
scrollToCenter: true,
});
}

View File

@ -0,0 +1,48 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import type { FC } from 'react';
import { useNodeRender, usePlayground } from '@flowgram.ai/free-layout-editor';
import type { CommentEditorModel } from '../model';
import { DragArea } from './drag-area';
interface IBlankArea {
model: CommentEditorModel;
}
export const BlankArea: FC<IBlankArea> = (props) => {
const { model } = props;
const playground = usePlayground();
const { selectNode } = useNodeRender();
return (
<div
className="workflow-comment-blank-area h-full w-full"
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
model.setFocus(false);
selectNode(e);
playground.node.focus(); // 防止节点无法被删除
}}
onClick={(e) => {
model.setFocus(true);
model.selectEnd();
}}
>
<DragArea
style={{
position: 'relative',
width: '100%',
height: '100%',
}}
model={model}
stopEvent={false}
/>
</div>
);
};

View File

@ -0,0 +1,120 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { type FC } from 'react';
import type { CommentEditorModel } from '../model';
import { ResizeArea } from './resize-area';
import { DragArea } from './drag-area';
interface IBorderArea {
model: CommentEditorModel;
overflow: boolean;
onResize?: () => {
resizing: (delta: { top: number; right: number; bottom: number; left: number }) => void;
resizeEnd: () => void;
};
}
export const BorderArea: FC<IBorderArea> = (props) => {
const { model, overflow, onResize } = props;
return (
<div style={{ zIndex: 999 }}>
{/* 左边 */}
<DragArea
style={{
position: 'absolute',
left: -10,
top: 10,
width: 20,
height: 'calc(100% - 20px)',
}}
model={model}
/>
{/* 右边 */}
<DragArea
style={{
position: 'absolute',
right: -10,
top: 10,
height: 'calc(100% - 20px)',
width: overflow ? 10 : 20, // 防止遮挡滚动条
}}
model={model}
/>
{/* 上边 */}
<DragArea
style={{
position: 'absolute',
top: -10,
left: 10,
width: 'calc(100% - 20px)',
height: 20,
}}
model={model}
/>
{/* 下边 */}
<DragArea
style={{
position: 'absolute',
bottom: -10,
left: 10,
width: 'calc(100% - 20px)',
height: 20,
}}
model={model}
/>
{/** 左上角 */}
<ResizeArea
style={{
position: 'absolute',
left: 0,
top: 0,
cursor: 'nwse-resize',
}}
model={model}
getDelta={({ x, y }) => ({ top: y, right: 0, bottom: 0, left: x })}
onResize={onResize}
/>
{/** 右上角 */}
<ResizeArea
style={{
position: 'absolute',
right: 0,
top: 0,
cursor: 'nesw-resize',
}}
model={model}
getDelta={({ x, y }) => ({ top: y, right: x, bottom: 0, left: 0 })}
onResize={onResize}
/>
{/** 右下角 */}
<ResizeArea
style={{
position: 'absolute',
right: 0,
bottom: 0,
cursor: 'nwse-resize',
}}
model={model}
getDelta={({ x, y }) => ({ top: 0, right: x, bottom: y, left: 0 })}
onResize={onResize}
/>
{/** 左下角 */}
<ResizeArea
style={{
position: 'absolute',
left: 0,
bottom: 0,
cursor: 'nesw-resize',
}}
model={model}
getDelta={({ x, y }) => ({ top: 0, right: 0, bottom: y, left: x })}
onResize={onResize}
/>
</div>
);
};

View File

@ -0,0 +1,50 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import type { ReactNode, FC, CSSProperties } from 'react';
interface ICommentContainer {
focused: boolean;
children?: ReactNode;
style?: React.CSSProperties;
}
export const CommentContainer: FC<ICommentContainer> = (props) => {
const { focused, children, style } = props;
const scrollbarStyle = {
// 滚动条样式
scrollbarWidth: 'thin',
scrollbarColor: 'rgb(159 159 158 / 65%) transparent',
// 针对 WebKit 浏览器(如 Chrome、Safari的样式
'&:WebkitScrollbar': {
width: '4px',
},
'&::WebkitScrollbarTrack': {
background: 'transparent',
},
'&::WebkitScrollbarThumb': {
backgroundColor: 'rgb(159 159 158 / 65%)',
borderRadius: '20px',
border: '2px solid transparent',
},
} as unknown as CSSProperties;
return (
<div
className="workflow-comment-container"
data-flow-editor-selectable="false"
style={{
// tailwind 不支持 outline 的样式,所以这里需要使用 style 来设置
outline: focused ? '1px solid #FF811A' : '1px solid #F2B600',
backgroundColor: focused ? '#FFF3EA' : '#FFFBED',
...scrollbarStyle,
...style,
}}
>
{children}
</div>
);
};

View File

@ -0,0 +1,94 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { type FC, useState, useEffect, type WheelEventHandler } from 'react';
import { useNodeRender, usePlayground } from '@flowgram.ai/free-layout-editor';
import type { CommentEditorModel } from '../model';
import { DragArea } from './drag-area';
interface IContentDragArea {
model: CommentEditorModel;
focused: boolean;
overflow: boolean;
}
export const ContentDragArea: FC<IContentDragArea> = (props) => {
const { model, focused, overflow } = props;
const playground = usePlayground();
const { selectNode } = useNodeRender();
const [active, setActive] = useState(false);
useEffect(() => {
// 当编辑器失去焦点时,取消激活状态
if (!focused) {
setActive(false);
}
}, [focused]);
const handleWheel: WheelEventHandler<HTMLDivElement> = (e) => {
const editorElement = model.element;
if (active || !overflow || !editorElement) {
return;
}
e.stopPropagation();
const maxScroll = editorElement.scrollHeight - editorElement.clientHeight;
const newScrollTop = Math.min(Math.max(editorElement.scrollTop + e.deltaY, 0), maxScroll);
editorElement.scroll(0, newScrollTop);
};
const handleMouseDown = (mouseDownEvent: React.MouseEvent) => {
if (active) {
return;
}
mouseDownEvent.preventDefault();
mouseDownEvent.stopPropagation();
model.setFocus(false);
selectNode(mouseDownEvent);
playground.node.focus(); // 防止节点无法被删除
const startX = mouseDownEvent.clientX;
const startY = mouseDownEvent.clientY;
const handleMouseUp = (mouseMoveEvent: MouseEvent) => {
const deltaX = mouseMoveEvent.clientX - startX;
const deltaY = mouseMoveEvent.clientY - startY;
// 判断是拖拽还是点击
const delta = 5;
if (Math.abs(deltaX) < delta && Math.abs(deltaY) < delta) {
// 点击后隐藏
setActive(true);
}
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('click', handleMouseUp);
};
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('click', handleMouseUp);
};
return (
<div
className="workflow-comment-content-drag-area"
onMouseDown={handleMouseDown}
onWheel={handleWheel}
style={{
display: active ? 'none' : undefined,
}}
>
<DragArea
style={{
position: 'relative',
width: '100%',
height: '100%',
}}
model={model}
stopEvent={false}
/>
</div>
);
};

View File

@ -0,0 +1,48 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { CSSProperties, MouseEvent, TouchEvent, type FC } from 'react';
import { useNodeRender, usePlayground } from '@flowgram.ai/free-layout-editor';
import { type CommentEditorModel } from '../model';
interface IDragArea {
model: CommentEditorModel;
stopEvent?: boolean;
style?: CSSProperties;
}
export const DragArea: FC<IDragArea> = (props) => {
const { model, stopEvent = true, style } = props;
const playground = usePlayground();
const { startDrag: onStartDrag, onFocus, onBlur, selectNode } = useNodeRender();
const handleDrag = (e: MouseEvent | TouchEvent) => {
if (stopEvent) {
e.preventDefault();
e.stopPropagation();
}
model.setFocus(false);
onStartDrag(e as MouseEvent);
selectNode(e as MouseEvent);
playground.node.focus(); // 防止节点无法被删除
};
return (
<div
className="workflow-comment-drag-area"
data-flow-editor-selectable="false"
draggable={true}
style={style}
onMouseDown={handleDrag}
onTouchStart={handleDrag}
onFocus={onFocus}
onBlur={onBlur}
/>
);
};

View File

@ -0,0 +1,65 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { type FC, type CSSProperties, useEffect, useRef, useState, useMemo } from 'react';
import { usePlayground } from '@flowgram.ai/free-layout-editor';
import { CommentEditorModel } from '../model';
import { CommentEditorEvent } from '../constant';
interface ICommentEditor {
model: CommentEditorModel;
style?: CSSProperties;
value?: string;
onChange?: (value: string) => void;
}
export const CommentEditor: FC<ICommentEditor> = (props) => {
const { model, style, onChange } = props;
const playground = usePlayground();
const editorRef = useRef<HTMLTextAreaElement | null>(null);
const placeholder = model.value || model.focused ? undefined : 'Enter a comment...';
// 同步编辑器内部值变化
useEffect(() => {
const disposer = model.on((params) => {
if (params.type !== CommentEditorEvent.Change) {
return;
}
onChange?.(model.value);
});
return () => disposer.dispose();
}, [model, onChange]);
useEffect(() => {
if (!editorRef.current) {
return;
}
model.element = editorRef.current;
}, [editorRef]);
return (
<div className="workflow-comment-editor">
<p className="workflow-comment-editor-placeholder">{placeholder}</p>
<textarea
className="workflow-comment-editor-textarea"
ref={editorRef}
style={style}
readOnly={playground.config.readonly}
onChange={(e) => {
const { value } = e.target;
model.setValue(value);
}}
onFocus={() => {
model.setFocus(true);
}}
onBlur={() => {
model.setFocus(false);
}}
/>
</div>
);
};

View File

@ -0,0 +1,108 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
.workflow-comment {
width: auto;
height: auto;
min-width: 120px;
min-height: 80px;
}
.workflow-comment-container {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
width: 100%;
height: 100%;
border-radius: 8px;
outline: 1px solid;
padding: 6px 2px 6px 10px;
overflow: hidden;
}
.workflow-comment-drag-area {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
cursor: move;
}
.workflow-comment-content-drag-area {
position: absolute;
height: 100%;
width: calc(100% - 22px);
}
.workflow-comment-resize-area {
position: absolute;
width: 10px;
height: 10px;
}
.workflow-comment-editor {
width: 100%;
height: 100%;
}
.workflow-comment-editor-placeholder {
margin: 0;
position: absolute;
pointer-events: none;
color: rgba(55, 67, 106, 0.38);
font-weight: 500;
}
.workflow-comment-editor-textarea {
width: 100%;
height: 100%;
box-sizing: border-box;
appearance: none;
border: none;
margin: 0;
padding: 0;
width: 100%;
background: none;
color: inherit;
font-family: inherit;
font-size: 16px;
resize: none;
outline: none;
}
.workflow-comment-more-button {
position: absolute;
right: 6px;
}
.workflow-comment-more-button > .semi-button {
color: rgba(255, 255, 255, 0);
background: none;
}
.workflow-comment-more-button > .semi-button:hover {
color: #ffa100;
background: #fbf2d2cc;
backdrop-filter: blur(1px);
}
.workflow-comment-more-button-focused > .semi-button:hover {
color: #ff811a;
background: #ffe3cecc;
backdrop-filter: blur(1px);
}
.workflow-comment-more-button > .semi-button:active {
color: #f2b600;
background: #ede5c7cc;
backdrop-filter: blur(1px);
}
.workflow-comment-more-button-focused > .semi-button:active {
color: #ff811a;
background: #eed5c1cc;
backdrop-filter: blur(1px);
}

View File

@ -0,0 +1,8 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import './index.css';
export { CommentRender } from './render';

View File

@ -0,0 +1,26 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FC } from 'react';
import { WorkflowNodeEntity } from '@flowgram.ai/free-layout-editor';
import { NodeMenu } from '../../node-menu';
interface IMoreButton {
node: WorkflowNodeEntity;
focused: boolean;
deleteNode: () => void;
}
export const MoreButton: FC<IMoreButton> = ({ node, focused, deleteNode }) => (
<div
className={`workflow-comment-more-button ${
focused ? 'workflow-comment-more-button-focused' : ''
}`}
>
<NodeMenu node={node} deleteNode={deleteNode} />
</div>
);

View File

@ -0,0 +1,83 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FC } from 'react';
import {
Field,
FieldRenderProps,
FlowNodeFormData,
Form,
FormModelV2,
useNodeRender,
WorkflowNodeEntity,
} from '@flowgram.ai/free-layout-editor';
import { useOverflow } from '../hooks/use-overflow';
import { useModel } from '../hooks/use-model';
import { useSize } from '../hooks';
import { CommentEditorFormField } from '../constant';
import { MoreButton } from './more-button';
import { CommentEditor } from './editor';
import { ContentDragArea } from './content-drag-area';
import { CommentContainer } from './container';
import { BorderArea } from './border-area';
export const CommentRender: FC<{
node: WorkflowNodeEntity;
}> = (props) => {
const { node } = props;
const model = useModel();
const { selected: focused, selectNode, nodeRef, deleteNode } = useNodeRender();
const formModel = node.getData(FlowNodeFormData).getFormModel<FormModelV2>();
const formControl = formModel?.formControl;
const { width, height, onResize } = useSize();
const { overflow, updateOverflow } = useOverflow({ model, height });
return (
<div
className="workflow-comment"
style={{
width,
height,
}}
ref={nodeRef}
data-node-selected={String(focused)}
onMouseEnter={updateOverflow}
onMouseDown={(e) => {
setTimeout(() => {
// 防止 selectNode 拦截事件,导致 slate 编辑器无法聚焦
selectNode(e);
// eslint-disable-next-line @typescript-eslint/no-magic-numbers -- delay
}, 20);
}}
>
<Form control={formControl}>
<>
{/* 背景 */}
<CommentContainer focused={focused} style={{ height }}>
<Field name={CommentEditorFormField.Note}>
{({ field }: FieldRenderProps<string>) => (
<>
{/** 编辑器 */}
<CommentEditor model={model} value={field.value} onChange={field.onChange} />
{/* 内容拖拽区域(点击后隐藏) */}
<ContentDragArea model={model} focused={focused} overflow={overflow} />
{/* 更多按钮 */}
<MoreButton node={node} focused={focused} deleteNode={deleteNode} />
</>
)}
</Field>
</CommentContainer>
{/* 边框 */}
<BorderArea model={model} overflow={overflow} onResize={onResize} />
</>
</Form>
</div>
);
};

View File

@ -0,0 +1,89 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { CSSProperties, type FC } from 'react';
import { MouseTouchEvent, useNodeRender, usePlayground } from '@flowgram.ai/free-layout-editor';
import type { CommentEditorModel } from '../model';
interface IResizeArea {
model: CommentEditorModel;
onResize?: () => {
resizing: (delta: { top: number; right: number; bottom: number; left: number }) => void;
resizeEnd: () => void;
};
getDelta?: (delta: { x: number; y: number }) => {
top: number;
right: number;
bottom: number;
left: number;
};
style?: CSSProperties;
}
export const ResizeArea: FC<IResizeArea> = (props) => {
const { model, onResize, getDelta, style } = props;
const playground = usePlayground();
const { selectNode } = useNodeRender();
const handleResizeStart = (
startResizeEvent: React.MouseEvent | React.TouchEvent | MouseEvent
) => {
MouseTouchEvent.preventDefault(startResizeEvent);
startResizeEvent.stopPropagation();
if (!onResize) {
return;
}
const { resizing, resizeEnd } = onResize();
model.setFocus(false);
selectNode(startResizeEvent as React.MouseEvent);
playground.node.focus(); // 防止节点无法被删除
const { clientX: startX, clientY: startY } = MouseTouchEvent.getEventCoord(
startResizeEvent as MouseEvent
);
const handleResizing = (mouseMoveEvent: MouseEvent | TouchEvent) => {
const { clientX: moveX, clientY: moveY } = MouseTouchEvent.getEventCoord(mouseMoveEvent);
const deltaX = moveX - startX;
const deltaY = moveY - startY;
const delta = getDelta?.({ x: deltaX, y: deltaY });
if (!delta || !resizing) {
return;
}
resizing(delta);
};
const handleResizeEnd = () => {
resizeEnd();
document.removeEventListener('mousemove', handleResizing);
document.removeEventListener('mouseup', handleResizeEnd);
document.removeEventListener('click', handleResizeEnd);
document.removeEventListener('touchmove', handleResizing);
document.removeEventListener('touchend', handleResizeEnd);
document.removeEventListener('touchcancel', handleResizeEnd);
};
document.addEventListener('mousemove', handleResizing);
document.addEventListener('mouseup', handleResizeEnd);
document.addEventListener('click', handleResizeEnd);
document.addEventListener('touchmove', handleResizing, { passive: false });
document.addEventListener('touchend', handleResizeEnd);
document.addEventListener('touchcancel', handleResizeEnd);
};
return (
<div
className="workflow-comment-resize-area"
style={style}
data-flow-editor-selectable="false"
onMouseDown={handleResizeStart}
onTouchStart={handleResizeStart}
/>
);
};

View File

@ -0,0 +1,25 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
/* eslint-disable @typescript-eslint/naming-convention -- enum */
export enum CommentEditorFormField {
Size = 'size',
Note = 'note',
}
/** 编辑器事件 */
export enum CommentEditorEvent {
/** 内容变更事件 */
Change = 'change',
/** 多选事件 */
MultiSelect = 'multiSelect',
/** 单选事件 */
Select = 'select',
/** 失焦事件 */
Blur = 'blur',
}
export const CommentEditorDefaultValue = '';

View File

@ -0,0 +1,6 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { useSize } from './use-size';

View File

@ -0,0 +1,55 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useEffect, useMemo } from 'react';
import {
FlowNodeFormData,
FormModelV2,
useEntityFromContext,
useNodeRender,
WorkflowNodeEntity,
} from '@flowgram.ai/free-layout-editor';
import { CommentEditorModel } from '../model';
import { CommentEditorFormField } from '../constant';
export const useModel = () => {
const node = useEntityFromContext<WorkflowNodeEntity>();
const { selected: focused } = useNodeRender();
const formModel = node.getData(FlowNodeFormData).getFormModel<FormModelV2>();
const model = useMemo(() => new CommentEditorModel(), []);
// 同步失焦状态
useEffect(() => {
if (focused) {
return;
}
model.setFocus(focused);
}, [focused, model]);
// 同步表单值初始化
useEffect(() => {
const value = formModel.getValueIn<string>(CommentEditorFormField.Note);
model.setValue(value); // 设置初始值
model.selectEnd(); // 设置初始化光标位置
}, [formModel, model]);
// 同步表单外部值变化undo/redo/协同
useEffect(() => {
const disposer = formModel.onFormValuesChange(({ name }) => {
if (name !== CommentEditorFormField.Note) {
return;
}
const value = formModel.getValueIn<string>(CommentEditorFormField.Note);
model.setValue(value);
});
return () => disposer.dispose();
}, [formModel, model]);
return model;
};

View File

@ -0,0 +1,50 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useCallback, useState, useEffect } from 'react';
import { usePlayground } from '@flowgram.ai/free-layout-editor';
import { CommentEditorModel } from '../model';
import { CommentEditorEvent } from '../constant';
export const useOverflow = (params: { model: CommentEditorModel; height: number }) => {
const { model, height } = params;
const playground = usePlayground();
const [overflow, setOverflow] = useState(false);
const isOverflow = useCallback((): boolean => {
if (!model.element) {
return false;
}
return model.element.scrollHeight > model.element.clientHeight;
}, [model, height, playground]);
// 更新 overflow
const updateOverflow = useCallback(() => {
setOverflow(isOverflow());
}, [isOverflow]);
// 监听高度变化
useEffect(() => {
updateOverflow();
}, [height, updateOverflow]);
// 监听 change 事件
useEffect(() => {
const changeDisposer = model.on((params) => {
if (params.type !== CommentEditorEvent.Change) {
return;
}
updateOverflow();
});
return () => {
changeDisposer.dispose();
};
}, [model, updateOverflow]);
return { overflow, updateOverflow };
};

View File

@ -0,0 +1,168 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useCallback, useEffect, useState } from 'react';
import {
FlowNodeFormData,
FormModelV2,
FreeOperationType,
HistoryService,
TransformData,
useCurrentEntity,
usePlayground,
useService,
} from '@flowgram.ai/free-layout-editor';
import { CommentEditorFormField } from '../constant';
export const useSize = () => {
const node = useCurrentEntity();
const nodeMeta = node.getNodeMeta();
const playground = usePlayground();
const historyService = useService(HistoryService);
const { size = { width: 240, height: 150 } } = nodeMeta;
const transform = node.getData(TransformData);
const formModel = node.getData(FlowNodeFormData).getFormModel<FormModelV2>();
const formSize = formModel.getValueIn<{ width: number; height: number }>(
CommentEditorFormField.Size
);
const [width, setWidth] = useState(formSize?.width ?? size.width);
const [height, setHeight] = useState(formSize?.height ?? size.height);
// 初始化表单值
useEffect(() => {
const initSize = formModel.getValueIn<{ width: number; height: number }>(
CommentEditorFormField.Size
);
if (!initSize) {
formModel.setValueIn(CommentEditorFormField.Size, {
width,
height,
});
}
}, [formModel, width, height]);
// 同步表单外部值变化:初始化/undo/redo/协同
useEffect(() => {
const disposer = formModel.onFormValuesChange(({ name }) => {
if (name !== CommentEditorFormField.Size) {
return;
}
const newSize = formModel.getValueIn<{ width: number; height: number }>(
CommentEditorFormField.Size
);
if (!newSize) {
return;
}
setWidth(newSize.width);
setHeight(newSize.height);
});
return () => disposer.dispose();
}, [formModel]);
const onResize = useCallback(() => {
const resizeState = {
width,
height,
originalWidth: width,
originalHeight: height,
positionX: transform.position.x,
positionY: transform.position.y,
offsetX: 0,
offsetY: 0,
};
const resizing = (delta: { top: number; right: number; bottom: number; left: number }) => {
if (!resizeState) {
return;
}
const { zoom } = playground.config;
const top = delta.top / zoom;
const right = delta.right / zoom;
const bottom = delta.bottom / zoom;
const left = delta.left / zoom;
const minWidth = 120;
const minHeight = 80;
const newWidth = Math.max(minWidth, resizeState.originalWidth + right - left);
const newHeight = Math.max(minHeight, resizeState.originalHeight + bottom - top);
// 如果宽度或高度小于最小值,则不更新偏移量
const newOffsetX =
(left > 0 || right < 0) && newWidth <= minWidth
? resizeState.offsetX
: left / 2 + right / 2;
const newOffsetY =
(top > 0 || bottom < 0) && newHeight <= minHeight ? resizeState.offsetY : top;
const newPositionX = resizeState.positionX + newOffsetX;
const newPositionY = resizeState.positionY + newOffsetY;
resizeState.width = newWidth;
resizeState.height = newHeight;
resizeState.offsetX = newOffsetX;
resizeState.offsetY = newOffsetY;
// 更新状态
setWidth(newWidth);
setHeight(newHeight);
// 更新偏移量
transform.update({
position: {
x: newPositionX,
y: newPositionY,
},
});
};
const resizeEnd = () => {
historyService.transact(() => {
historyService.pushOperation(
{
type: FreeOperationType.dragNodes,
value: {
ids: [node.id],
value: [
{
x: resizeState.positionX + resizeState.offsetX,
y: resizeState.positionY + resizeState.offsetY,
},
],
oldValue: [
{
x: resizeState.positionX,
y: resizeState.positionY,
},
],
},
},
{
noApply: true,
}
);
formModel.setValueIn(CommentEditorFormField.Size, {
width: resizeState.width,
height: resizeState.height,
});
});
};
return {
resizing,
resizeEnd,
};
}, [node, width, height, transform, playground, formModel, historyService]);
return {
width,
height,
onResize,
};
};

View File

@ -0,0 +1,6 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { CommentRender } from './components';

View File

@ -0,0 +1,111 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { Emitter } from '@flowgram.ai/free-layout-editor';
import { CommentEditorEventParams } from './type';
import { CommentEditorDefaultValue, CommentEditorEvent } from './constant';
export class CommentEditorModel {
private innerValue: string = CommentEditorDefaultValue;
private emitter: Emitter<CommentEditorEventParams> = new Emitter();
private editor: HTMLTextAreaElement;
/** 注册事件 */
public on = this.emitter.event;
/** 获取当前值 */
public get value(): string {
return this.innerValue;
}
/** 外部设置模型值 */
public setValue(value: string = CommentEditorDefaultValue): void {
if (!this.initialized) {
return;
}
if (value === this.innerValue) {
return;
}
this.innerValue = value;
this.syncEditorValue();
this.emitter.fire({
type: CommentEditorEvent.Change,
value: this.innerValue,
});
}
public set element(el: HTMLTextAreaElement) {
if (this.initialized) {
return;
}
this.editor = el;
}
/** 获取编辑器 DOM 节点 */
public get element(): HTMLTextAreaElement | null {
return this.editor;
}
/** 编辑器聚焦/失焦 */
public setFocus(focused: boolean): void {
if (!this.initialized) {
return;
}
if (focused && !this.focused) {
this.editor.focus();
} else if (!focused && this.focused) {
this.editor.blur();
this.deselect();
this.emitter.fire({
type: CommentEditorEvent.Blur,
});
}
}
/** 选择末尾 */
public selectEnd(): void {
if (!this.initialized) {
return;
}
// 获取文本长度
const length = this.editor.value.length;
// 将选择范围设置为文本末尾(开始位置和结束位置都是文本长度)
this.editor.setSelectionRange(length, length);
}
/** 获取聚焦状态 */
public get focused(): boolean {
return document.activeElement === this.editor;
}
/** 取消选择文本 */
private deselect(): void {
const selection: Selection | null = window.getSelection();
// 清除所有选择区域
if (selection) {
selection.removeAllRanges();
}
}
/** 是否初始化 */
private get initialized(): boolean {
return Boolean(this.editor);
}
/**
* 同步编辑器实例内容
* > **NOTICE:** *为确保不影响性能,应仅在外部值变更导致编辑器值与模型值不一致时调用*
*/
private syncEditorValue(): void {
if (!this.initialized) {
return;
}
this.editor.value = this.innerValue;
}
}

View File

@ -0,0 +1,29 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import type { CommentEditorEvent } from './constant';
interface CommentEditorChangeEvent {
type: CommentEditorEvent.Change;
value: string;
}
interface CommentEditorMultiSelectEvent {
type: CommentEditorEvent.MultiSelect;
}
interface CommentEditorSelectEvent {
type: CommentEditorEvent.Select;
}
interface CommentEditorBlurEvent {
type: CommentEditorEvent.Blur;
}
export type CommentEditorEventParams =
| CommentEditorChangeEvent
| CommentEditorMultiSelectEvent
| CommentEditorSelectEvent
| CommentEditorBlurEvent;

View File

@ -0,0 +1,105 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
type GroupColor = {
'50': string;
'300': string;
'400': string;
};
export const defaultColor = 'Blue';
export const groupColors: Record<string, GroupColor> = {
Red: {
'50': '#fef2f2',
'300': '#fca5a5',
'400': '#f87171',
},
Orange: {
'50': '#fff7ed',
'300': '#fdba74',
'400': '#fb923c',
},
Amber: {
'50': '#fffbeb',
'300': '#fcd34d',
'400': '#fbbf24',
},
Yellow: {
'50': '#fef9c3',
'300': '#fde047',
'400': '#facc15',
},
Lime: {
'50': '#f7fee7',
'300': '#bef264',
'400': '#a3e635',
},
Green: {
'50': '#f0fdf4',
'300': '#86efac',
'400': '#4ade80',
},
Emerald: {
'50': '#ecfdf5',
'300': '#6ee7b7',
'400': '#34d399',
},
Teal: {
'50': '#f0fdfa',
'300': '#5eead4',
'400': '#2dd4bf',
},
Cyan: {
'50': '#ecfeff',
'300': '#67e8f9',
'400': '#22d3ee',
},
Sky: {
'50': '#ecfeff',
'300': '#7dd3fc',
'400': '#38bdf8',
},
Blue: {
'50': '#eff6ff',
'300': '#93c5fd',
'400': '#60a5fa',
},
Indigo: {
'50': '#eef2ff',
'300': '#a5b4fc',
'400': '#818cf8',
},
Violet: {
'50': '#f5f3ff',
'300': '#c4b5fd',
'400': '#a78bfa',
},
Purple: {
'50': '#faf5ff',
'300': '#d8b4fe',
'400': '#c084fc',
},
Fuchsia: {
'50': '#fdf4ff',
'300': '#f0abfc',
'400': '#e879f9',
},
Pink: {
'50': '#fdf2f8',
'300': '#f9a8d4',
'400': '#f472b6',
},
Rose: {
'50': '#fff1f2',
'300': '#fda4af',
'400': '#fb7185',
},
Gray: {
'50': '#f9fafb',
'300': '#d1d5db',
'400': '#9ca3af',
},
};

View File

@ -0,0 +1,55 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { CSSProperties, FC, useEffect } from 'react';
import { useWatch, WorkflowNodeEntity } from '@flowgram.ai/free-layout-editor';
import { GroupField } from '../constant';
import { defaultColor, groupColors } from '../color';
interface GroupBackgroundProps {
node: WorkflowNodeEntity;
style?: CSSProperties;
selected: boolean;
}
export const GroupBackground: FC<GroupBackgroundProps> = ({ node, style, selected }) => {
const colorName = useWatch<string>(GroupField.Color) ?? defaultColor;
const color = groupColors[colorName];
useEffect(() => {
const styleElement = document.createElement('style');
// 使用独特的选择器
const styleContent = `
.workflow-group-render[data-group-id="${node.id}"] .workflow-group-background {
border: 1px solid ${color['300']};
}
.workflow-group-render.selected[data-group-id="${node.id}"] .workflow-group-background {
border: 1px solid #4e40e5;
}
`;
styleElement.textContent = styleContent;
document.head.appendChild(styleElement);
return () => {
styleElement.remove();
};
}, [color]);
return (
<div
className="workflow-group-background"
data-flow-editor-selectable="true"
style={{
...style,
backgroundColor: `${color['300']}${selected ? '40' : '29'}`,
}}
/>
);
};

View File

@ -0,0 +1,50 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FC } from 'react';
import { Field } from '@flowgram.ai/free-layout-editor';
import { Popover, Tooltip } from '@douyinfe/semi-ui';
import { GroupField } from '../constant';
import { defaultColor, groupColors } from '../color';
export const GroupColor: FC = () => (
<Field<string> name={GroupField.Color}>
{({ field }) => {
const colorName = field.value ?? defaultColor;
return (
<Popover
position="top"
mouseLeaveDelay={300}
content={
<div className="workflow-group-color-palette">
{Object.entries(groupColors).map(([name, color]) => (
<Tooltip content={name} key={name} mouseEnterDelay={300}>
<span
className="workflow-group-color-item"
key={name}
style={{
backgroundColor: color['300'],
borderColor: name === colorName ? color['400'] : '#fff',
}}
onClick={() => field.onChange(name)}
/>
</Tooltip>
))}
</div>
}
>
<span
className="workflow-group-color"
style={{
backgroundColor: groupColors[colorName]['300'],
}}
/>
</Popover>
);
}}
</Field>
);

View File

@ -0,0 +1,41 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import type { FC, ReactNode, MouseEvent, CSSProperties, TouchEvent } from 'react';
import { useWatch } from '@flowgram.ai/free-layout-editor';
import { GroupField } from '../constant';
import { defaultColor, groupColors } from '../color';
interface GroupHeaderProps {
onDrag: (e: MouseEvent | TouchEvent) => void;
onFocus: () => void;
onBlur: () => void;
children: ReactNode;
style?: CSSProperties;
}
export const GroupHeader: FC<GroupHeaderProps> = ({ onDrag, onFocus, onBlur, children, style }) => {
const colorName = useWatch<string>(GroupField.Color) ?? defaultColor;
const color = groupColors[colorName];
return (
<div
className="workflow-group-header"
data-flow-editor-selectable="false"
onMouseDown={onDrag}
onTouchStart={onDrag}
onFocus={onFocus}
onBlur={onBlur}
style={{
...style,
backgroundColor: color['50'],
borderColor: color['300'],
}}
>
{children}
</div>
);
};

View File

@ -0,0 +1,52 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FC } from 'react';
interface IconGroupProps {
size?: number;
}
export const IconGroup: FC<IconGroupProps> = ({ size }) => (
<svg
width="10"
height="10"
viewBox="0 0 10 10"
xmlns="http://www.w3.org/2000/svg"
style={{
width: size,
height: size,
}}
>
<path
id="group"
fill="currentColor"
fillRule="evenodd"
stroke="none"
d="M 0.009766 10 L 0.009766 9.990234 L 0 9.990234 L 0 7.5 L 1 7.5 L 1 9 L 2.5 9 L 2.5 10 L 0.009766 10 Z M 3.710938 10 L 3.710938 9 L 6.199219 9 L 6.199219 10 L 3.710938 10 Z M 7.5 10 L 7.5 9 L 9 9 L 9 7.5 L 10 7.5 L 10 9.990234 L 9.990234 9.990234 L 9.990234 10 L 7.5 10 Z M 0 6.289063 L 0 3.800781 L 1 3.800781 L 1 6.289063 L 0 6.289063 Z M 9 6.289063 L 9 3.800781 L 10 3.800781 L 10 6.289063 L 9 6.289063 Z M 0 2.5 L 0 0.009766 L 0.009766 0.009766 L 0.009766 0 L 2.5 0 L 2.5 1 L 1 1 L 1 2.5 L 0 2.5 Z M 9 2.5 L 9 1 L 7.5 1 L 7.5 0 L 9.990234 0 L 9.990234 0.009766 L 10 0.009766 L 10 2.5 L 9 2.5 Z M 3.710938 1 L 3.710938 0 L 6.199219 0 L 6.199219 1 L 3.710938 1 Z"
/>
</svg>
);
export const IconUngroup: FC<IconGroupProps> = ({ size }) => (
<svg
width="10"
height="10"
viewBox="0 0 10 10"
xmlns="http://www.w3.org/2000/svg"
style={{
width: size,
height: size,
}}
>
<path
id="ungroup"
fill="currentColor"
fillRule="evenodd"
stroke="none"
d="M 9.654297 10.345703 L 8.808594 9.5 L 7.175781 9.5 L 7.175781 8.609375 L 7.917969 8.609375 L 1.390625 2.082031 L 1.390625 2.824219 L 0.5 2.824219 L 0.5 1.191406 L -0.345703 0.345703 L 0.283203 -0.283203 L 1.166016 0.599609 L 2.724609 0.599609 L 2.724609 1.490234 L 2.056641 1.490234 L 8.509766 7.943359 L 8.509766 7.275391 L 9.400391 7.275391 L 9.400391 8.833984 L 10.283203 9.716797 L 9.654297 10.345703 Z M 0.509766 9.5 L 0.509766 9.490234 L 0.5 9.490234 L 0.5 7.275391 L 1.390625 7.275391 L 1.390625 8.609375 L 2.724609 8.609375 L 2.724609 9.5 L 0.509766 9.5 Z M 3.802734 9.5 L 3.802734 8.609375 L 6.017578 8.609375 L 6.017578 9.5 L 3.802734 9.5 Z M 0.5 6.197266 L 0.5 3.982422 L 1.390625 3.982422 L 1.390625 6.197266 L 0.5 6.197266 Z M 8.509766 6.197266 L 8.509766 3.982422 L 9.400391 3.982422 L 9.400391 6.197266 L 8.509766 6.197266 Z M 8.509766 2.824219 L 8.509766 1.490234 L 7.175781 1.490234 L 7.175781 0.599609 L 9.390625 0.599609 L 9.390625 0.609375 L 9.400391 0.609375 L 9.400391 2.824219 L 8.509766 2.824219 Z M 3.802734 1.490234 L 3.802734 0.599609 L 6.017578 0.599609 L 6.017578 1.490234 L 3.802734 1.490234 Z"
/>
</svg>
);

View File

@ -0,0 +1,7 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { GroupNodeRender } from './node-render';
export { IconGroup } from './icon-group';

View File

@ -0,0 +1,81 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { MouseEvent, useEffect } from 'react';
import {
FlowNodeFormData,
Form,
FormModelV2,
useNodeRender,
} from '@flowgram.ai/free-layout-editor';
import { useNodeSize } from '@flowgram.ai/free-container-plugin';
import { HEADER_HEIGHT, HEADER_PADDING } from '../constant';
import { UngroupButton } from './ungroup';
import { GroupTools } from './tools';
import { GroupTips } from './tips';
import { GroupHeader } from './header';
import { GroupBackground } from './background';
export const GroupNodeRender = () => {
const { node, selected, selectNode, nodeRef, startDrag, onFocus, onBlur } = useNodeRender();
const nodeSize = useNodeSize();
const formModel = node.getData(FlowNodeFormData).getFormModel<FormModelV2>();
const formControl = formModel?.formControl;
const { height, width } = nodeSize ?? {};
const nodeHeight = height ?? 0;
useEffect(() => {
// prevent lines in outside cannot be selected - 防止外层线条不可选中
const element = node.renderData.node;
element.style.pointerEvents = 'none';
}, [node]);
return (
<div
className={`workflow-group-render ${selected ? 'selected' : ''}`}
ref={nodeRef}
data-group-id={node.id}
data-node-selected={String(selected)}
onMouseDown={selectNode}
onClick={(e) => {
selectNode(e);
}}
style={{
width,
height,
}}
>
<Form control={formControl}>
<>
<GroupHeader
onDrag={(e) => {
startDrag(e as MouseEvent);
}}
onFocus={onFocus}
onBlur={onBlur}
style={{
height: HEADER_HEIGHT,
}}
>
<GroupTools />
</GroupHeader>
<GroupTips />
<UngroupButton node={node} />
<GroupBackground
node={node}
selected={selected}
style={{
top: HEADER_HEIGHT + HEADER_PADDING,
height: nodeHeight - HEADER_HEIGHT - HEADER_PADDING,
}}
/>
</>
</Form>
</div>
);
};

View File

@ -0,0 +1,38 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
/* eslint-disable @typescript-eslint/naming-convention -- no need */
const STORAGE_KEY = 'workflow-move-into-group-tip-visible';
const STORAGE_VALUE = 'false';
export class TipsGlobalStore {
private static _instance?: TipsGlobalStore;
public static get instance(): TipsGlobalStore {
if (!this._instance) {
this._instance = new TipsGlobalStore();
}
return this._instance;
}
private closed = false;
public isClosed(): boolean {
return this.isCloseForever() || this.closed;
}
public close(): void {
this.closed = true;
}
public isCloseForever(): boolean {
return localStorage.getItem(STORAGE_KEY) === STORAGE_VALUE;
}
public closeForever(): void {
localStorage.setItem(STORAGE_KEY, STORAGE_VALUE);
}
}

View File

@ -0,0 +1,14 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export const IconClose = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 16 16">
<path
fill="#060709"
fillOpacity="0.5"
d="M12.13 12.128a.5.5 0 0 0 .001-.706L8.71 8l3.422-3.423a.5.5 0 0 0-.001-.705.5.5 0 0 0-.706-.002L8.002 7.293 4.579 3.87a.5.5 0 0 0-.705.002.5.5 0 0 0-.002.705L7.295 8l-3.423 3.422a.5.5 0 0 0 .002.706c.195.195.51.197.705.001l3.423-3.422 3.422 3.422c.196.196.51.194.706-.001"
></path>
</svg>
);

View File

@ -0,0 +1,42 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useControlTips } from './use-control';
import { GroupTipsStyle } from './style';
import { isMacOS } from './is-mac-os';
import { IconClose } from './icon-close';
import { I18n } from '@flowgram.ai/free-layout-editor';
export const GroupTips = () => {
const { visible, close, closeForever } = useControlTips();
if (!visible) {
return null;
}
return (
<GroupTipsStyle className={'workflow-group-tips'}>
<div className="container">
<div className="content">
<p className="text">{I18n.t('Hold {{key}} to drag node out', { key: isMacOS ? 'Cmd ⌘' : 'Ctrl' })}</p>
<div
className="space"
style={{
width: 0,
}}
/>
</div>
<div className="actions">
<p className="close-forever" onClick={closeForever}>
{I18n.t('Never Remind')}
</p>
<div className="close" onClick={close}>
<IconClose />
</div>
</div>
</div>
</GroupTipsStyle>
);
};

View File

@ -0,0 +1,6 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export const isMacOS = /(Macintosh|MacIntel|MacPPC|Mac68K|iPad)/.test(navigator.userAgent);

View File

@ -0,0 +1,79 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import styled from 'styled-components';
export const GroupTipsStyle = styled.div`
position: absolute;
top: 35px;
width: 100%;
height: 28px;
white-space: nowrap;
pointer-events: auto;
.container {
display: inline-flex;
justify-content: center;
height: 100%;
width: 100%;
background-color: rgb(255 255 255);
border-radius: 8px 8px 0 0;
.content {
overflow: hidden;
display: inline-flex;
align-items: center;
justify-content: flex-start;
width: fit-content;
height: 100%;
padding: 0 12px;
.text {
font-size: 14px;
font-weight: 400;
font-style: normal;
line-height: 20px;
color: rgba(15, 21, 40, 82%);
text-overflow: ellipsis;
margin: 0;
}
.space {
width: 128px;
}
}
.actions {
display: flex;
gap: 8px;
align-items: center;
height: 28px;
padding: 0 12px;
.close-forever {
cursor: pointer;
padding: 0 3px;
font-size: 12px;
font-weight: 400;
font-style: normal;
line-height: 12px;
color: rgba(32, 41, 69, 62%);
margin: 0;
}
.close {
display: flex;
cursor: pointer;
height: 100%;
align-items: center;
}
}
}
`;

View File

@ -0,0 +1,71 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useCallback, useEffect, useState } from 'react';
import { useCurrentEntity, useService } from '@flowgram.ai/free-layout-editor';
import {
NodeIntoContainerService,
NodeIntoContainerType,
} from '@flowgram.ai/free-container-plugin';
import { TipsGlobalStore } from './global-store';
export const useControlTips = () => {
const node = useCurrentEntity();
const [visible, setVisible] = useState(false);
const globalStore = TipsGlobalStore.instance;
const nodeIntoContainerService = useService<NodeIntoContainerService>(NodeIntoContainerService);
const show = useCallback(() => {
if (globalStore.isClosed()) {
return;
}
setVisible(true);
}, [globalStore]);
const close = useCallback(() => {
globalStore.close();
setVisible(false);
}, [globalStore]);
const closeForever = useCallback(() => {
globalStore.closeForever();
close();
}, [close, globalStore]);
useEffect(() => {
// 监听移入
const inDisposer = nodeIntoContainerService.on((e) => {
if (e.type !== NodeIntoContainerType.In) {
return;
}
if (e.targetContainer === node) {
show();
}
});
// 监听移出事件
const outDisposer = nodeIntoContainerService.on((e) => {
if (e.type !== NodeIntoContainerType.Out) {
return;
}
if (e.sourceContainer === node && !node.blocks.length) {
setVisible(false);
}
});
return () => {
inDisposer.dispose();
outDisposer.dispose();
};
}, [nodeIntoContainerService, node, show, close, visible]);
return {
visible,
close,
closeForever,
};
};

View File

@ -0,0 +1,38 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FC, useState } from 'react';
import { Field } from '@flowgram.ai/free-layout-editor';
import { Input } from '@douyinfe/semi-ui';
import { GroupField } from '../constant';
export const GroupTitle: FC = () => {
const [inputting, setInputting] = useState(false);
return (
<Field<string> name={GroupField.Title}>
{({ field }) =>
inputting ? (
<Input
autoFocus
className="workflow-group-title-input"
size="small"
value={field.value}
onChange={field.onChange}
onMouseDown={(e) => e.stopPropagation()}
onBlur={() => setInputting(false)}
draggable={false}
onEnterPress={() => setInputting(false)}
/>
) : (
<p className="workflow-group-title" onDoubleClick={() => setInputting(true)}>
{field.value ?? 'Group'}
</p>
)
}
</Field>
);
};

View File

@ -0,0 +1,19 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FC } from 'react';
import { IconHandle } from '@douyinfe/semi-icons';
import { GroupTitle } from './title';
import { GroupColor } from './color';
export const GroupTools: FC = () => (
<div className="workflow-group-tools">
<IconHandle className="workflow-group-tools-drag" />
<GroupTitle />
<GroupColor />
</div>
);

View File

@ -0,0 +1,36 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { CSSProperties, FC } from 'react';
import { CommandRegistry, useService, WorkflowNodeEntity } from '@flowgram.ai/free-layout-editor';
import { WorkflowGroupCommand } from '@flowgram.ai/free-group-plugin';
import { Button, Tooltip } from '@douyinfe/semi-ui';
import { IconUngroup } from './icon-group';
interface UngroupButtonProps {
node: WorkflowNodeEntity;
style?: CSSProperties;
}
export const UngroupButton: FC<UngroupButtonProps> = ({ node, style }) => {
const commandRegistry = useService(CommandRegistry);
return (
<Tooltip content="Ungroup">
<div className="workflow-group-ungroup" style={style}>
<Button
icon={<IconUngroup size={14} />}
style={{ height: 30, width: 30 }}
theme="borderless"
type="tertiary"
onClick={() => {
commandRegistry.executeCommand(WorkflowGroupCommand.Ungroup, node);
}}
/>
</div>
</Tooltip>
);
};

View File

@ -0,0 +1,12 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export const HEADER_HEIGHT = 30;
export const HEADER_PADDING = 5;
export enum GroupField {
Title = 'title',
Color = 'color',
}

View File

@ -0,0 +1,117 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
.workflow-group-render {
border-radius: 8px;
pointer-events: none;
}
.workflow-group-background {
box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.04), 0 4px 12px 0 rgba(0, 0, 0, 0.02);
}
.workflow-group-header {
height: 30px;
width: fit-content;
background-color: #fefce8;
border: 1px solid #facc15;
border-radius: 8px;
padding-right: 8px;
pointer-events: auto;
}
.workflow-group-ungroup {
display: flex;
justify-content: center;
align-items: center;
height: 30px;
width: 30px;
position: absolute;
top: 35px;
right: 0;
border-radius: 8px;
cursor: pointer;
pointer-events: auto;
}
.workflow-group-ungroup .semi-button {
color: #9ca3af;
}
.workflow-group-ungroup:hover .semi-button {
color: #374151;
}
.workflow-group-background {
position: absolute;
pointer-events: none;
top: 0;
background-color: #fddf4729;
border: 1px solid #fde047;
border-radius: 8px;
width: 100%;
}
.workflow-group-render.selected .workflow-group-background {
border: 1px solid #facc15;
}
.workflow-group-tools {
display: flex;
justify-content: flex-start;
align-items: center;
gap: 4px;
height: 100%;
cursor: move;
color: oklch(44.6% 0.043 257.281);
font-size: 14px;
}
.workflow-group-title {
margin: 0;
max-width: 242px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
}
.workflow-group-tools-drag {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
padding-left: 4px;
}
.workflow-group-color {
width: 16px;
height: 16px;
border-radius: 8px;
background-color: #fde047;
margin-left: 4px;
cursor: pointer;
}
.workflow-group-title-input {
width: 242px;
border: none;
color: #374151;
}
.workflow-group-color-palette {
display: grid;
grid-template-columns: repeat(6, 24px);
gap: 12px;
margin: 8px;
padding: 8px;
}
.workflow-group-color-item {
width: 24px;
height: 24px;
border-radius: 50%;
background-color: #fde047;
cursor: pointer;
border: 3px solid;
}

View File

@ -0,0 +1,9 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import './index.css';
export { GroupNodeRender } from './components';
export { IconGroup } from './components';

View File

@ -0,0 +1,10 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export * from './base-node';
export * from './line-add-button';
export * from './node-panel';
export * from './comment';
export * from './group';

View File

@ -0,0 +1,31 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export const IconPlusCircle = () => (
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g id="add">
<path
id="background"
fill="#ffffff"
fillRule="evenodd"
stroke="none"
d="M 24 12 C 24 5.372583 18.627417 0 12 0 C 5.372583 0 -0 5.372583 -0 12 C -0 18.627417 5.372583 24 12 24 C 18.627417 24 24 18.627417 24 12 Z"
/>
<path
id="content"
fill="currentColor"
fillRule="evenodd"
stroke="none"
d="M 22 12.005 C 22 6.482153 17.522848 2.004999 12 2.004999 C 6.477152 2.004999 2 6.482153 2 12.005 C 2 17.527847 6.477152 22.004999 12 22.004999 C 17.522848 22.004999 22 17.527847 22 12.005 Z"
/>
<path
id="cross"
fill="#ffffff"
stroke="none"
d="M 11.411996 16.411797 C 11.411996 16.736704 11.675362 17 12.00023 17 C 12.325109 17 12.588474 16.736704 12.588474 16.411797 L 12.588474 12.58826 L 16.41201 12.58826 C 16.736919 12.58826 17.000216 12.324894 17.000216 12.000015 C 17.000216 11.675147 16.736919 11.411781 16.41201 11.411781 L 12.588474 11.411781 L 12.588474 7.588234 C 12.588474 7.263367 12.325109 7 12.00023 7 C 11.675362 7 11.411996 7.263367 11.411996 7.588234 L 11.411996 11.411781 L 7.588449 11.411781 C 7.263581 11.411781 7.000215 11.675147 7.000215 12.000015 C 7.000215 12.324894 7.263581 12.58826 7.588449 12.58826 L 11.411996 12.58826 L 11.411996 16.411797 Z"
/>
</g>
</svg>
);

View File

@ -0,0 +1,13 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
.line-add-button {
position: absolute;
width: 24px;
height: 24px;
cursor: pointer;
color: inherit;
pointer-events: all;
}

View File

@ -0,0 +1,128 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useCallback } from 'react';
import {
WorkflowNodePanelService,
WorkflowNodePanelUtils,
} from '@flowgram.ai/free-node-panel-plugin';
import { LineRenderProps } from '@flowgram.ai/free-lines-plugin';
import {
delay,
HistoryService,
useService,
WorkflowDocument,
WorkflowDragService,
WorkflowLinesManager,
WorkflowNodeEntity,
WorkflowNodeJSON,
} from '@flowgram.ai/free-layout-editor';
import './index.less';
import { useVisible } from './use-visible';
import { IconPlusCircle } from './button';
export const LineAddButton = (props: LineRenderProps) => {
const { line, selected, hovered, color } = props;
const visible = useVisible({ line, selected, hovered });
const nodePanelService = useService<WorkflowNodePanelService>(WorkflowNodePanelService);
const document = useService(WorkflowDocument);
const dragService = useService(WorkflowDragService);
const linesManager = useService(WorkflowLinesManager);
const historyService = useService(HistoryService);
const { fromPort, toPort } = line;
const onClick = useCallback(async () => {
// calculate the middle point of the line - 计算线条的中点位置
const position = {
x: (line.position.from.x + line.position.to.x) / 2,
y: (line.position.from.y + line.position.to.y) / 2,
};
// get container node for the new node - 获取新节点的容器节点
const containerNode = fromPort.node.parent;
// show node selection panel - 显示节点选择面板
const result = await nodePanelService.singleSelectNodePanel({
position,
containerNode,
panelProps: {
enableScrollClose: true,
},
});
if (!result) {
return;
}
const { nodeType, nodeJSON } = result;
// adjust position for the new node - 调整新节点的位置
const nodePosition = WorkflowNodePanelUtils.adjustNodePosition({
nodeType,
position,
fromPort,
toPort,
containerNode,
document,
dragService,
});
// create new workflow node - 创建新的工作流节点
const node: WorkflowNodeEntity = document.createWorkflowNodeByType(
nodeType,
nodePosition,
nodeJSON ?? ({} as WorkflowNodeJSON),
containerNode?.id
);
// auto offset subsequent nodes - 自动偏移后续节点
if (fromPort && toPort) {
WorkflowNodePanelUtils.subNodesAutoOffset({
node,
fromPort,
toPort,
containerNode,
historyService,
dragService,
linesManager,
});
}
// wait for node render - 等待节点渲染
await delay(20);
// build connection lines - 构建连接线
WorkflowNodePanelUtils.buildLine({
fromPort,
node,
toPort,
linesManager,
});
// remove original line - 移除原始线条
line.dispose();
}, []);
if (!visible) {
return <></>;
}
return (
<div
className="line-add-button"
style={{
transform: `translate(-50%, -50%) translate(${line.center.labelX}px, ${line.center.labelY}px)`,
color,
}}
data-testid="sdk.workflow.canvas.line.add"
data-line-id={line.id}
onClick={onClick}
>
<IconPlusCircle />
</div>
);
};

View File

@ -0,0 +1,28 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { usePlayground, WorkflowLineEntity } from '@flowgram.ai/free-layout-editor';
import './index.less';
export const useVisible = (params: {
line: WorkflowLineEntity;
selected?: boolean;
hovered?: boolean;
}): boolean => {
const playground = usePlayground();
const { line, selected = false, hovered } = params;
if (line.disposed) {
// 在 dispose 后,再去获取 line.to | line.from 会导致错误创建端口
return false;
}
if (playground.config.readonly) {
return false;
}
if (!selected && !hovered) {
return false;
}
return true;
};

View File

@ -0,0 +1,116 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FC, useCallback, useState, type MouseEvent } from 'react';
import {
delay,
useClientContext,
useService,
WorkflowDragService,
WorkflowNodeEntity,
WorkflowSelectService,
} from '@flowgram.ai/free-layout-editor';
import { NodeIntoContainerService } from '@flowgram.ai/free-container-plugin';
import { IconButton, Dropdown } from '@douyinfe/semi-ui';
import { IconMore } from '@douyinfe/semi-icons';
import { I18n } from '@flowgram.ai/free-layout-editor';
import { FlowNodeRegistry } from '../../typings';
import { PasteShortcut } from '../../shortcuts/paste';
import { CopyShortcut } from '../../shortcuts/copy';
interface NodeMenuProps {
node: WorkflowNodeEntity;
updateTitleEdit: (setEditing: boolean) => void;
deleteNode: () => void;
}
export const NodeMenu: FC<NodeMenuProps> = ({ node, deleteNode, updateTitleEdit }) => {
const [visible, setVisible] = useState(true);
const clientContext = useClientContext();
const registry = node.getNodeRegistry<FlowNodeRegistry>();
const nodeIntoContainerService = useService(NodeIntoContainerService);
const selectService = useService(WorkflowSelectService);
const dragService = useService(WorkflowDragService);
const canMoveOut = nodeIntoContainerService.canMoveOutContainer(node);
const rerenderMenu = useCallback(() => {
// force destroy component - 强制销毁组件触发重新渲染
setVisible(false);
requestAnimationFrame(() => {
setVisible(true);
});
}, []);
const handleMoveOut = useCallback(
async (e: MouseEvent) => {
e.stopPropagation();
const sourceParent = node.parent;
// move out of container - 移出容器
nodeIntoContainerService.moveOutContainer({ node });
await delay(16);
// clear invalid lines - 清除非法线条
await nodeIntoContainerService.clearInvalidLines({
dragNode: node,
sourceParent,
});
rerenderMenu();
// select node - 选中节点
selectService.selectNode(node);
// start drag node - 开始拖拽
dragService.startDragSelectedNodes(e);
},
[nodeIntoContainerService, node, rerenderMenu]
);
const handleCopy = useCallback(
(e: React.MouseEvent) => {
const copyShortcut = new CopyShortcut(clientContext);
const pasteShortcut = new PasteShortcut(clientContext);
const data = copyShortcut.toClipboardData([node]);
pasteShortcut.apply(data);
e.stopPropagation(); // Disable clicking prevents the sidebar from opening
},
[clientContext, node]
);
const handleDelete = useCallback(
(e: React.MouseEvent) => {
deleteNode();
e.stopPropagation(); // Disable clicking prevents the sidebar from opening
},
[clientContext, node]
);
const handleEditTitle = useCallback(() => {
updateTitleEdit(true);
}, [updateTitleEdit]);
if (!visible) {
return <></>;
}
return (
<Dropdown
trigger="hover"
position="bottomRight"
render={
<Dropdown.Menu>
<Dropdown.Item onClick={handleEditTitle}>{I18n.t('Edit Title')}</Dropdown.Item>
{canMoveOut && <Dropdown.Item onClick={handleMoveOut}>{I18n.t('Move out')}</Dropdown.Item>}
<Dropdown.Item onClick={handleCopy}>{I18n.t('Create Copy')}</Dropdown.Item>
<Dropdown.Item type="danger" onClick={handleDelete}>{I18n.t('Delete')}</Dropdown.Item>
</Dropdown.Menu>
}
>
<IconButton
color="secondary"
size="small"
theme="borderless"
icon={<IconMore />}
onClick={(e) => e.stopPropagation()}
/>
</Dropdown>
);
};

View File

@ -0,0 +1,60 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
.node-placeholder {
width: 360px;
background-color: rgba(252, 252, 255, 1);
border: 1px solid rgba(68, 83, 130, 0.25);
border-radius: 8px;
box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 2%), 0 2px 6px 0 rgba(0, 0, 0, 4%);
}
.node-placeholder-skeleton {
width: 100%;
padding: 12px;
background-color: rgba(252, 252, 255, 1);
border-radius: 8px;
.semi-skeleton-avatar {
background-color: rgba(68, 83, 130, 0.25);
}
.semi-skeleton-title {
height: 16px;
background-color: rgba(82, 100, 154, 0.13);
border-radius: 4px;
}
}
.node-placeholder-hd {
display: flex;
align-items: center;
margin-bottom: 12px;
}
.node-placeholder-avatar {
width: 24px;
height: 24px;
margin-right: 8px;
border-radius: 6px;
}
.node-placeholder-content {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 3px;
}
.node-placeholder-footer {
display: flex;
flex-direction: row;
align-items: center;
gap: 2.5px;
}

View File

@ -0,0 +1,54 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FC } from 'react';
import { NodePanelRenderProps } from '@flowgram.ai/free-node-panel-plugin';
import { Popover } from '@douyinfe/semi-ui';
import { NodePlaceholder } from './node-placeholder';
import { NodeList } from './node-list';
import './index.less';
export const NodePanel: FC<NodePanelRenderProps> = (props) => {
const { onSelect, position, onClose, containerNode, panelProps = {} } = props;
const { enableNodePlaceholder } = panelProps;
return (
<Popover
trigger="click"
visible={true}
onVisibleChange={(v) => (v ? null : onClose())}
content={<NodeList onSelect={onSelect} containerNode={containerNode} />}
placement="right"
popupAlign={{ offset: [30, 0] }}
overlayStyle={{
padding: 0,
}}
>
<div
style={
enableNodePlaceholder
? {
position: 'absolute',
top: position.y - 61.5,
left: position.x,
width: 360,
height: 100,
}
: {
position: 'absolute',
top: position.y,
left: position.x,
width: 0,
height: 0,
}
}
>
{enableNodePlaceholder && <NodePlaceholder />}
</div>
</Popover>
);
};

View File

@ -0,0 +1,102 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import React, { FC } from 'react';
import styled from 'styled-components';
import { NodePanelRenderProps } from '@flowgram.ai/free-node-panel-plugin';
import { useClientContext, WorkflowNodeEntity } from '@flowgram.ai/free-layout-editor';
import { FlowNodeRegistry } from '../../typings';
import { nodeRegistries } from '../../nodes';
const NodeWrap = styled.div`
width: 100%;
height: 32px;
border-radius: 5px;
display: flex;
align-items: center;
cursor: pointer;
font-size: 19px;
padding: 0 15px;
&:hover {
background-color: hsl(252deg 62% 55% / 9%);
color: hsl(252 62% 54.9%);
}
`;
const NodeLabel = styled.div`
font-size: 12px;
margin-left: 10px;
`;
interface NodeProps {
label: string;
icon: JSX.Element;
onClick: React.MouseEventHandler<HTMLDivElement>;
disabled: boolean;
}
function Node(props: NodeProps) {
return (
<NodeWrap
data-testid={`flow-node-list-${props.label}`}
onClick={props.disabled ? undefined : props.onClick}
style={props.disabled ? { opacity: 0.3 } : {}}
>
<div style={{ fontSize: 14 }}>{props.icon}</div>
<NodeLabel>{props.label}</NodeLabel>
</NodeWrap>
);
}
const NodesWrap = styled.div`
max-height: 500px;
overflow: auto;
&::-webkit-scrollbar {
display: none;
}
`;
interface NodeListProps {
onSelect: NodePanelRenderProps['onSelect'];
containerNode?: WorkflowNodeEntity;
}
export const NodeList: FC<NodeListProps> = (props) => {
const { onSelect, containerNode } = props;
const context = useClientContext();
const handleClick = (e: React.MouseEvent, registry: FlowNodeRegistry) => {
const json = registry.onAdd?.(context);
onSelect({
nodeType: registry.type as string,
selectEvent: e,
nodeJSON: json,
});
};
return (
<NodesWrap style={{ width: 80 * 2 + 20 }}>
{nodeRegistries
.filter((register) => register.meta.nodePanelVisible !== false)
.filter((register) => {
if (register.meta.onlyInContainer) {
return register.meta.onlyInContainer === containerNode?.flowNodeType;
}
return true;
})
.map((registry) => (
<Node
key={registry.type}
disabled={!(registry.canAdd?.(context) ?? true)}
icon={
<img style={{ width: 10, height: 10, borderRadius: 4 }} src={registry.info?.icon} />
}
label={registry.type as string}
onClick={(e) => handleClick(e, registry)}
/>
))}
</NodesWrap>
);
};

View File

@ -0,0 +1,31 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { Skeleton } from '@douyinfe/semi-ui';
export const NodePlaceholder = () => (
<div className="node-placeholder" data-testid="workflow.detail.node-panel.placeholder">
<Skeleton
className="node-placeholder-skeleton"
loading={true}
active={true}
placeholder={
<div className="">
<div className="node-placeholder-hd">
<Skeleton.Avatar shape="square" className="node-placeholder-avatar" />
<Skeleton.Title style={{ width: 141 }} />
</div>
<div className="node-placeholder-content">
<div className="node-placeholder-footer">
<Skeleton.Title style={{ width: 85 }} />
<Skeleton.Title style={{ width: 241 }} />
</div>
<Skeleton.Title style={{ width: 220 }} />
</div>
</div>
}
/>
</div>
);

View File

@ -0,0 +1,104 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FunctionComponent } from 'react';
import { SelectorBoxPopoverProps } from '@flowgram.ai/free-layout-editor';
import { WorkflowGroupCommand } from '@flowgram.ai/free-group-plugin';
import { Button, ButtonGroup, Tooltip } from '@douyinfe/semi-ui';
import { I18n } from '@flowgram.ai/free-layout-editor';
import { IconCopy, IconDeleteStroked, IconExpand, IconShrink } from '@douyinfe/semi-icons';
import { IconGroup } from '../group';
import { FlowCommandId } from '../../shortcuts/constants';
const BUTTON_HEIGHT = 24;
export const SelectorBoxPopover: FunctionComponent<SelectorBoxPopoverProps> = ({
bounds,
children,
flowSelectConfig,
commandRegistry,
}) => (
<>
<div
style={{
position: 'absolute',
left: bounds.right,
top: bounds.top,
transform: 'translate(-100%, -100%)',
}}
onMouseDown={(e) => {
e.stopPropagation();
}}
>
<ButtonGroup
size="small"
style={{ display: 'flex', flexWrap: 'nowrap', height: BUTTON_HEIGHT }}
>
<Tooltip content={I18n.t('Collapse')}>
<Button
icon={<IconShrink />}
style={{ height: BUTTON_HEIGHT }}
type="primary"
theme="solid"
onMouseDown={(e) => {
commandRegistry.executeCommand(FlowCommandId.COLLAPSE);
}}
/>
</Tooltip>
<Tooltip content={I18n.t('Expand')}>
<Button
icon={<IconExpand />}
style={{ height: BUTTON_HEIGHT }}
type="primary"
theme="solid"
onMouseDown={(e) => {
commandRegistry.executeCommand(FlowCommandId.EXPAND);
}}
/>
</Tooltip>
<Tooltip content={I18n.t('Create Group')}>
<Button
icon={<IconGroup size={14} />}
style={{ height: BUTTON_HEIGHT }}
type="primary"
theme="solid"
onClick={() => {
commandRegistry.executeCommand(WorkflowGroupCommand.Group);
}}
/>
</Tooltip>
<Tooltip content={I18n.t('Copy')}>
<Button
icon={<IconCopy />}
style={{ height: BUTTON_HEIGHT }}
type="primary"
theme="solid"
onClick={() => {
commandRegistry.executeCommand(FlowCommandId.COPY);
}}
/>
</Tooltip>
<Tooltip content={I18n.t('Delete')}>
<Button
type="primary"
theme="solid"
icon={<IconDeleteStroked />}
style={{ height: BUTTON_HEIGHT }}
onClick={() => {
commandRegistry.executeCommand(FlowCommandId.DELETE);
}}
/>
</Tooltip>
</ButtonGroup>
</div>
<div>{children}</div>
</>
);

View File

@ -0,0 +1,7 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { SidebarProvider } from './sidebar-provider';
export { SidebarRenderer } from './sidebar-renderer';

View File

@ -0,0 +1,29 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useNodeRender, FlowNodeEntity } from '@flowgram.ai/free-layout-editor';
import { NodeRenderContext } from '../../context';
export function SidebarNodeRenderer(props: { node: FlowNodeEntity }) {
const { node } = props;
const nodeRender = useNodeRender(node);
return (
<NodeRenderContext.Provider value={nodeRender}>
<div
style={{
background: 'rgb(251, 251, 251)',
height: 'calc(100vh - 40px)',
margin: '8px 8px 8px 0',
borderRadius: 8,
border: '1px solid rgba(82,100,154, 0.13)',
}}
>
{nodeRender.form?.render()}
</div>
</NodeRenderContext.Provider>
);
}

View File

@ -0,0 +1,17 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useState } from 'react';
import { SidebarContext } from '../../context';
export function SidebarProvider({ children }: { children: React.ReactNode }) {
const [nodeId, setNodeId] = useState<string | undefined>();
return (
<SidebarContext.Provider value={{ visible: !!nodeId, nodeId, setNodeId }}>
{children}
</SidebarContext.Provider>
);
}

View File

@ -0,0 +1,113 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useCallback, useContext, useEffect, useMemo, startTransition } from 'react';
import {
PlaygroundEntityContext,
useRefresh,
useClientContext,
} from '@flowgram.ai/free-layout-editor';
import { SideSheet } from '@douyinfe/semi-ui';
import { FlowNodeMeta } from '../../typings';
import { SidebarContext, IsSidebarContext } from '../../context';
import { SidebarNodeRenderer } from './sidebar-node-renderer';
export const SidebarRenderer = () => {
const { nodeId, setNodeId } = useContext(SidebarContext);
const { selection, playground, document } = useClientContext();
const refresh = useRefresh();
const handleClose = useCallback(() => {
// Sidebar delayed closing
startTransition(() => {
setNodeId(undefined);
});
}, []);
const node = nodeId ? document.getNode(nodeId) : undefined;
/**
* Listen readonly
*/
useEffect(() => {
const disposable = playground.config.onReadonlyOrDisabledChange(() => {
handleClose();
refresh();
});
return () => disposable.dispose();
}, [playground]);
/**
* Listen selection
*/
useEffect(() => {
const toDispose = selection.onSelectionChanged(() => {
/**
* 如果没有选中任何节点,则自动关闭侧边栏
* If no node is selected, the sidebar is automatically closed
*/
if (selection.selection.length === 0) {
handleClose();
} else if (selection.selection.length === 1 && selection.selection[0] !== node) {
handleClose();
}
});
return () => toDispose.dispose();
}, [selection, handleClose, node]);
/**
* Close when node disposed
*/
useEffect(() => {
if (node) {
const toDispose = node.onDispose(() => {
setNodeId(undefined);
});
return () => toDispose.dispose();
}
return () => {};
}, [node]);
const visible = useMemo(() => {
if (!node) {
return false;
}
const { sidebarDisabled = false } = node.getNodeMeta<FlowNodeMeta>();
return !sidebarDisabled;
}, [node]);
if (playground.config.readonly) {
return null;
}
/**
* Add "key" to rerender the sidebar when the node changes
*/
const content =
node && visible ? (
<PlaygroundEntityContext.Provider key={node.id} value={node}>
<SidebarNodeRenderer node={node} />
</PlaygroundEntityContext.Provider>
) : null;
return (
<SideSheet
mask={false}
visible={visible}
onCancel={handleClose}
closable={false}
motion={false}
width={500}
headerStyle={{
display: 'none',
}}
bodyStyle={{
padding: 0,
}}
style={{
background: 'none',
boxShadow: 'none',
}}
>
<IsSidebarContext.Provider value={true}>{content}</IsSidebarContext.Provider>
</SideSheet>
);
};

View File

@ -0,0 +1,8 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export { useFields } from './use-fields';
export { useFormMeta } from './use-form-meta';
export { useSyncDefault } from './use-sync-default';

View File

@ -0,0 +1,50 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { TestRunFormField, TestRunFormMeta } from '../testrun-form/type';
export const useFields = (params: {
formMeta: TestRunFormMeta;
values: Record<string, unknown>;
setValues: (values: Record<string, unknown>) => void;
}): TestRunFormField[] => {
const { formMeta, values, setValues } = params;
// Convert each meta item to a form field with value and onChange handler
const fields: TestRunFormField[] = formMeta.map((meta) => {
// Handle object type specially - serialize object to JSON string for display
const getCurrentValue = (): unknown => {
const rawValue = values[meta.name] ?? meta.defaultValue;
if ((meta.type === 'object' || meta.type === 'array') && rawValue !== null) {
return JSON.stringify(rawValue, null, 2);
}
return rawValue;
};
const currentValue = getCurrentValue();
const handleChange = (newValue: unknown): void => {
if (meta.type === 'object' || meta.type === 'array') {
setValues({
...values,
[meta.name]: JSON.parse((newValue ?? '{}') as string),
});
} else {
setValues({
...values,
[meta.name]: newValue,
});
}
};
return {
...meta,
value: currentValue,
onChange: handleChange,
};
});
return fields;
};

View File

@ -0,0 +1,62 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useMemo } from 'react';
import {
FlowNodeFormData,
FormModelV2,
useService,
WorkflowDocument,
} from '@flowgram.ai/free-layout-editor';
import { IJsonSchema, JsonSchemaBasicType } from '@flowgram.ai/form-materials';
import { TestRunFormMetaItem } from '../testrun-form/type';
import { WorkflowNodeType } from '../../../nodes';
const getWorkflowInputsDeclare = (document: WorkflowDocument): IJsonSchema => {
const defaultDeclare = {
type: 'object',
properties: {},
};
const startNode = document.root.blocks.find(
(node) => node.flowNodeType === WorkflowNodeType.Start
);
if (!startNode) {
return defaultDeclare;
}
const startFormModel = startNode.getData(FlowNodeFormData).getFormModel<FormModelV2>();
const declare = startFormModel.getValueIn<IJsonSchema>('outputs');
if (!declare) {
return defaultDeclare;
}
return declare;
};
export const useFormMeta = (): TestRunFormMetaItem[] => {
const document = useService(WorkflowDocument);
// Add state for form values
const formMeta = useMemo(() => {
const formFields: TestRunFormMetaItem[] = [];
const workflowInputs = getWorkflowInputsDeclare(document);
Object.entries(workflowInputs.properties!).forEach(([name, property]) => {
formFields.push({
type: property.type as JsonSchemaBasicType,
name,
defaultValue: property.default,
required: workflowInputs.required?.includes(name) ?? false,
itemsType: property.items?.type as JsonSchemaBasicType,
});
});
return formFields;
}, [document]);
return formMeta;
};

View File

@ -0,0 +1,30 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useEffect } from 'react';
import { TestRunFormMeta } from '../testrun-form/type';
export const useSyncDefault = (params: {
formMeta: TestRunFormMeta;
values: Record<string, unknown>;
setValues: (values: Record<string, unknown>) => void;
}) => {
const { formMeta, values, setValues } = params;
useEffect(() => {
let formMetaValues: Record<string, unknown> = {};
formMeta.map((meta) => {
// If there is no value in values but there is a default value, trigger onChange once
if (!(meta.name in values) && meta.defaultValue !== undefined) {
formMetaValues = { ...formMetaValues, [meta.name]: meta.defaultValue };
}
});
setValues({
...values,
...formMetaValues,
});
}, [formMeta]);
};

View File

@ -0,0 +1,30 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
.node-status-group {
padding: 6px;
font-weight: 500;
color: #333;
font-size: 15px;
display: flex;
align-items: center;
&-icon {
transform: rotate(-90deg);
transition: transform 0.2s;
cursor: pointer;
margin-right: 4px;
opacity: 0;
&-expanded {
transform: rotate(0deg);
opacity: 1;
}
}
&-tag {
margin-left: 4px;
}
}

View File

@ -0,0 +1,61 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FC, useState } from 'react';
import classNames from 'classnames';
import { Tag } from '@douyinfe/semi-ui';
import { IconSmallTriangleDown } from '@douyinfe/semi-icons';
import { DataStructureViewer } from '../viewer';
import styles from './index.module.less';
interface NodeStatusGroupProps {
title: string;
data: unknown;
optional?: boolean;
disableCollapse?: boolean;
}
const isObjectHasContent = (obj: any = {}): boolean => obj && Object.keys(obj).length > 0;
export const NodeStatusGroup: FC<NodeStatusGroupProps> = ({
title,
data,
optional = false,
disableCollapse = false,
}) => {
const hasContent = isObjectHasContent(data);
const [isExpanded, setIsExpanded] = useState(true);
if (optional && !hasContent) {
return null;
}
return (
<>
<div
className={styles['node-status-group']}
onClick={() => hasContent && !disableCollapse && setIsExpanded(!isExpanded)}
>
{!disableCollapse && (
<IconSmallTriangleDown
className={classNames(styles['node-status-group-icon'], {
[styles['node-status-group-icon-expanded']]: isExpanded && hasContent,
})}
/>
)}
<span>{title}:</span>
{!hasContent && (
<Tag size="small" className={styles['node-status-group-tag']}>
null
</Tag>
)}
</div>
{hasContent && isExpanded ? <DataStructureViewer data={data} /> : null}
</>
);
};

View File

@ -0,0 +1,57 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
.node-status-header {
border: 1px solid rgba(68, 83, 130, 0.25);
border-radius: 8px;
background-color: #fff;
position: absolute;
top: calc(100% + 8px);
left: 0;
width: 100%;
min-width: 360px;
&-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px;
&-opened {
padding-bottom: 0;
}
.status-title {
height: 24px;
display: flex;
align-items: center;
column-gap: 8px;
min-width: 0;
:global(.coz-tag) {
height: 20px;
}
:global(.semi-tag-content) {
font-weight: 500;
line-height: 16px;
font-size: 12px;
}
:global(.semi-tag-suffix-icon > div) {
font-size: 14px;
}
}
.status-btns {
height: 24px;
display: flex;
align-items: center;
column-gap: 4px;
.is-show-detail {
transform: rotate(180deg);
}
}
}
}

View File

@ -0,0 +1,67 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import React, { useState } from 'react';
import classNames from 'classnames';
import { IconChevronDown } from '@douyinfe/semi-icons';
import { useNodeRenderContext } from '../../../../hooks';
import styles from './index.module.less';
interface NodeStatusBarProps {
header?: React.ReactNode;
defaultShowDetail?: boolean;
extraBtns?: React.ReactNode[];
}
export const NodeStatusHeader: React.FC<React.PropsWithChildren<NodeStatusBarProps>> = ({
header,
defaultShowDetail,
children,
extraBtns = [],
}) => {
const [showDetail, setShowDetail] = useState(defaultShowDetail);
const { selectNode } = useNodeRenderContext();
const handleToggleShowDetail = (e: React.MouseEvent) => {
e.stopPropagation();
selectNode(e);
setShowDetail(!showDetail);
};
return (
<div
className={styles['node-status-header']}
// 必须要禁止 down 冒泡,防止判定圈选和 node hover不支持多边形
onMouseDown={(e) => e.stopPropagation()}
>
<div
className={classNames(
styles['node-status-header-content'],
showDetail && styles['node-status-header-content-opened']
)}
// 必须要禁止 down 冒泡,防止判定圈选和 node hover不支持多边形
onMouseDown={(e) => e.stopPropagation()}
// 其他事件统一走点击事件,且也需要阻止冒泡
onClick={handleToggleShowDetail}
>
<div className={styles['status-title']}>
{header}
{extraBtns.length > 0 ? extraBtns : null}
</div>
<div className={styles['status-btns']}>
<IconChevronDown
className={classNames({
[styles['is-show-detail']]: showDetail,
})}
/>
</div>
</div>
{showDetail ? children : null}
</div>
);
};

View File

@ -0,0 +1,47 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useEffect, useState } from 'react';
import { NodeReport } from '@flowgram.ai/runtime-interface';
import { useCurrentEntity, useService } from '@flowgram.ai/free-layout-editor';
import { WorkflowRuntimeService } from '../../../plugins/runtime-plugin/runtime-service';
import { NodeStatusRender } from './render';
const useNodeReport = () => {
const node = useCurrentEntity();
const [report, setReport] = useState<NodeReport>();
const runtimeService = useService(WorkflowRuntimeService);
useEffect(() => {
const reportDisposer = runtimeService.onNodeReportChange((nodeReport) => {
if (nodeReport.id !== node.id) {
return;
}
setReport(nodeReport);
});
const resetDisposer = runtimeService.onReset(() => {
setReport(undefined);
});
return () => {
reportDisposer.dispose();
resetDisposer.dispose();
};
}, []);
return report;
};
export const NodeStatusBar = () => {
const report = useNodeReport();
if (!report) {
return null;
}
return <NodeStatusRender report={report} />;
};

View File

@ -0,0 +1,94 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
.nodeStatus {
&Succeed {
background-color: rgba(105, 209, 140, 0.3);
color: #00b42a;
}
&Processing {
background-color: rgba(153, 187, 255, 0.3);
color: #4d53e8;
}
&Failed {
background-color: rgba(255, 163, 171, 0.3);
color: #f53f3f;
}
}
.icon {
&.processing {
color: rgba(77, 83, 232, 1);
}
}
.round {
border-radius: 50%;
}
.desc {
margin: 0;
}
.count {
font-weight: 500;
color: #333;
font-size: 15px;
margin-left: 12px;
}
.snapshotNavigation {
margin: 12px;
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.snapshotButton {
min-width: 32px;
height: 32px;
padding: 0;
border-radius: 4px;
font-size: 12px;
border: 1px solid;
font-weight: 500;
&.active {
border-color: #4d53e8;
font-weight: 800;
}
&.inactive {
border-color: rgba(29, 28, 35, 0.08);
}
}
.snapshotSelect {
width: 90px;
height: 32px;
border: 1px solid;
&.active {
border-color: #4d53e8;
}
&.inactive {
border-color: rgba(29, 28, 35, 0.08);
}
}
.container {
width: 100%;
height: 100%;
padding: 4px 2px 4px 2px;
}
.error {
padding: 12px;
color: red;
}

View File

@ -0,0 +1,194 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FC, useMemo, useState } from 'react';
import classnames from 'classnames';
import { NodeReport, WorkflowStatus } from '@flowgram.ai/runtime-interface';
import { Tag, Button, Select } from '@douyinfe/semi-ui';
import { IconSpin } from '@douyinfe/semi-icons';
import { I18n } from '@flowgram.ai/free-layout-editor';
import { NodeStatusHeader } from '../header';
import { NodeStatusGroup } from '../group';
import { IconWarningFill } from '../../../../assets/icon-warning';
import { IconSuccessFill } from '../../../../assets/icon-success';
import styles from './index.module.less';
interface NodeStatusRenderProps {
report: NodeReport;
}
const msToSeconds = (ms: number): string => (ms / 1000).toFixed(2) + 's';
const displayCount = 6;
export const NodeStatusRender: FC<NodeStatusRenderProps> = ({ report }) => {
const { status: nodeStatus } = report;
const [currentSnapshotIndex, setCurrentSnapshotIndex] = useState(0);
const snapshots = report.snapshots || [];
const currentSnapshot = snapshots[currentSnapshotIndex] || snapshots[0];
// 节点 5 个状态
const isNodePending = nodeStatus === WorkflowStatus.Pending;
const isNodeProcessing = nodeStatus === WorkflowStatus.Processing;
const isNodeFailed = nodeStatus === WorkflowStatus.Failed;
const isNodeSucceed = nodeStatus === WorkflowStatus.Succeeded;
const isNodeCanceled = nodeStatus === WorkflowStatus.Canceled;
const tagColor = useMemo(() => {
if (isNodeSucceed) {
return styles.nodeStatusSucceed;
}
if (isNodeFailed) {
return styles.nodeStatusFailed;
}
if (isNodeProcessing) {
return styles.nodeStatusProcessing;
}
}, [isNodeSucceed, isNodeFailed, isNodeProcessing]);
const renderIcon = () => {
if (isNodeProcessing) {
return <IconSpin spin className={classnames(styles.icon, styles.processing)} />;
}
if (isNodeSucceed) {
return <IconSuccessFill />;
}
return <IconWarningFill className={classnames(tagColor, styles.round)} />;
};
const renderDesc = () => {
const getDesc = () => {
if (isNodeProcessing) {
return I18n.t('Running');
} else if (isNodePending) {
return I18n.t('Run terminated');
} else if (isNodeSucceed) {
return I18n.t('Succeed');
} else if (isNodeFailed) {
return I18n.t('Failed');
} else if (isNodeCanceled) {
return I18n.t('Canceled');
}
};
const desc = getDesc();
return desc ? <p className={styles.desc}>{desc}</p> : null;
};
const renderCost = () => (
<Tag size="small" className={tagColor}>
{msToSeconds(report.timeCost)}
</Tag>
);
const renderSnapshotNavigation = () => {
if (snapshots.length <= 1) {
return null;
}
const count = (
<p className={styles.count}>
{I18n.t('Total')}: {snapshots.length}
</p>
);
if (snapshots.length <= displayCount) {
return (
<>
{count}
<div className={styles.snapshotNavigation}>
{snapshots.map((_, index) => (
<Button
key={index}
size="small"
type={currentSnapshotIndex === index ? 'primary' : 'tertiary'}
onClick={() => setCurrentSnapshotIndex(index)}
className={classnames(styles.snapshotButton, {
[styles.active]: currentSnapshotIndex === index,
[styles.inactive]: currentSnapshotIndex !== index,
})}
>
{index + 1}
</Button>
))}
</div>
</>
);
}
// 超过5个时前5个显示为按钮剩余的放在下拉选择中
return (
<>
{count}
<div className={styles.snapshotNavigation}>
{snapshots.slice(0, displayCount).map((_, index) => (
<Button
key={index}
size="small"
type="tertiary"
onClick={() => setCurrentSnapshotIndex(index)}
className={classnames(styles.snapshotButton, {
[styles.active]: currentSnapshotIndex === index,
[styles.inactive]: currentSnapshotIndex !== index,
})}
>
{index + 1}
</Button>
))}
<Select
value={currentSnapshotIndex >= displayCount ? currentSnapshotIndex : undefined}
onChange={(value) => setCurrentSnapshotIndex(value as number)}
className={classnames(styles.snapshotSelect, {
[styles.active]: currentSnapshotIndex >= displayCount,
[styles.inactive]: currentSnapshotIndex < displayCount,
})}
size="small"
placeholder={I18n.t('Select')}
>
{snapshots.slice(displayCount).map((_, index) => {
const actualIndex = index + displayCount;
return (
<Select.Option key={actualIndex} value={actualIndex}>
{actualIndex + 1}
</Select.Option>
);
})}
</Select>
</div>
</>
);
};
if (!report) {
return null;
}
return (
<NodeStatusHeader
header={
<>
{renderIcon()}
{renderDesc()}
{renderCost()}
</>
}
>
<div className={styles.container}>
{isNodeFailed && currentSnapshot?.error && (
<div className={styles.error}>{currentSnapshot.error}</div>
)}
{renderSnapshotNavigation()}
<NodeStatusGroup title={I18n.t('Inputs')} data={currentSnapshot?.inputs} />
<NodeStatusGroup title={I18n.t('Outputs')} data={currentSnapshot?.outputs} />
<NodeStatusGroup title={I18n.t('Branch')} data={currentSnapshot?.branch} optional />
<NodeStatusGroup title={I18n.t('Data')} data={currentSnapshot?.data} optional />
{/* i18n titles */}
{/* Titles replaced above with I18n.t */}
</div>
</NodeStatusHeader>
);
};

View File

@ -0,0 +1,142 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
.dataStructureViewer {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 14px;
line-height: 1.5;
color: #333;
background: #fafafa;
border-radius: 6px;
padding: 12px 12px 12px 0;
margin: 12px;
border: 1px solid #e1e4e8;
overflow: hidden;
.treeNode {
margin: 2px 0;
&Header {
display: flex;
align-items: flex-start;
gap: 4px;
min-height: 20px;
padding: 2px 0;
border-radius: 3px;
transition: background-color 0.15s ease;
&:hover {
background-color: rgba(0, 0, 0, 0.04);
}
}
&Children {
margin-left: 8px;
padding-left: 8px;
position: relative;
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 1px;
background: #e1e4e8;
}
}
}
.expandButton {
background: none;
border: none;
cursor: pointer;
font-size: 10px;
color: #666;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 2px;
transition: all 0.15s ease;
padding: 0;
margin: 0;
&:hover {
background-color: rgba(0, 0, 0, 0.1);
color: #333;
}
&.expanded {
transform: rotate(90deg);
}
&.collapsed {
transform: rotate(0deg);
}
}
.expandPlaceholder {
width: 16px;
height: 16px;
display: inline-block;
flex-shrink: 0;
}
.nodeLabel {
color: #0969da;
font-weight: 500;
cursor: pointer;
user-select: auto;
margin-right: 4px;
&:hover {
text-decoration: underline;
}
}
.nodeValue {
margin-left: 4px;
}
.primitiveValue {
cursor: pointer;
user-select: all;
padding: 1px 3px;
border-radius: 3px;
transition: background-color 0.15s ease;
&:hover {
background-color: rgba(0, 0, 0, 0.05);
}
&Quote {
color: #8f8f8f;
}
&.string {
color: #032f62;
background-color: rgba(3, 47, 98, 0.05);
}
&.number {
color: #005cc5;
background-color: rgba(0, 92, 197, 0.05);
}
&.boolean {
color: #e36209;
background-color: rgba(227, 98, 9, 0.05);
}
&.null,
&.undefined {
color: #6a737d;
font-style: italic;
background-color: rgba(106, 115, 125, 0.05);
}
}
}

View File

@ -0,0 +1,190 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import React, { useState } from 'react';
import classnames from 'classnames';
import { Toast } from '@douyinfe/semi-ui';
import { I18n } from '@flowgram.ai/free-layout-editor';
import styles from './index.module.less';
interface DataStructureViewerProps {
data: any;
level?: number;
}
interface TreeNodeProps {
label: string;
value: any;
level: number;
isLast?: boolean;
}
const TreeNode: React.FC<TreeNodeProps> = ({ label, value, level, isLast = false }) => {
const [isExpanded, setIsExpanded] = useState(true);
const handleCopy = (text: string) => {
navigator.clipboard.writeText(text);
Toast.success(I18n.t('Copied'));
};
const isExpandable = (val: any) =>
val !== null &&
typeof val === 'object' &&
((Array.isArray(val) && val.length > 0) ||
(!Array.isArray(val) && Object.keys(val).length > 0));
const renderPrimitiveValue = (val: any) => {
if (val === null)
return <span className={classnames(styles.primitiveValue, styles.null)}>null</span>;
if (val === undefined)
return <span className={classnames(styles.primitiveValue, styles.undefined)}>undefined</span>;
switch (typeof val) {
case 'string':
return (
<span>
<span className={styles.primitiveValueQuote}>{'"'}</span>
<span
className={classnames(styles.primitiveValue, styles.string)}
onDoubleClick={() => handleCopy(val)}
>
{val}
</span>
<span className={styles.primitiveValueQuote}>{'"'}</span>
</span>
);
case 'number':
return (
<span
className={classnames(styles.primitiveValue, styles.number)}
onDoubleClick={() => handleCopy(String(val))}
>
{val}
</span>
);
case 'boolean':
return (
<span
className={classnames(styles.primitiveValue, styles.boolean)}
onDoubleClick={() => handleCopy(val.toString())}
>
{val.toString()}
</span>
);
case 'object':
// Handle empty objects and arrays
if (Array.isArray(val)) {
return (
<span className={styles.primitiveValue} onDoubleClick={() => handleCopy('[]')}>
[]
</span>
);
} else {
return (
<span className={styles.primitiveValue} onDoubleClick={() => handleCopy('{}')}>
{'{}'}
</span>
);
}
default:
return (
<span className={styles.primitiveValue} onDoubleClick={() => handleCopy(String(val))}>
{String(val)}
</span>
);
}
};
const renderChildren = () => {
if (Array.isArray(value)) {
return value.map((item, index) => (
<TreeNode
key={index}
label={`${index + 1}.`}
value={item}
level={level + 1}
isLast={index === value.length - 1}
/>
));
} else {
const entries = Object.entries(value);
return entries.map(([key, val], index) => (
<TreeNode
key={key}
label={`${key}:`}
value={val}
level={level + 1}
isLast={index === entries.length - 1}
/>
));
}
};
return (
<div className={styles.treeNode}>
<div className={styles.treeNodeHeader}>
{isExpandable(value) ? (
<button
className={classnames(
styles.expandButton,
isExpanded ? styles.expanded : styles.collapsed
)}
onClick={() => setIsExpanded(!isExpanded)}
>
</button>
) : (
<span className={styles.expandPlaceholder}></span>
)}
<span
className={styles.nodeLabel}
onClick={() =>
handleCopy(
JSON.stringify({
[label]: value,
})
)
}
>
{label}
</span>
{!isExpandable(value) && (
<span className={styles.nodeValue}>{renderPrimitiveValue(value)}</span>
)}
</div>
{isExpandable(value) && isExpanded && (
<div className={styles.treeNodeChildren}>{renderChildren()}</div>
)}
</div>
);
};
export const DataStructureViewer: React.FC<DataStructureViewerProps> = ({ data, level = 0 }) => {
if (data === null || data === undefined || typeof data !== 'object') {
return (
<div className={styles.dataStructureViewer}>
<TreeNode label="value" value={data} level={0} />
</div>
);
}
const entries = Object.entries(data);
return (
<div className={styles.dataStructureViewer}>
{entries.map(([key, value], index) => (
<TreeNode
key={key}
label={`${key}:`}
value={value}
level={0}
isLast={index === entries.length - 1}
/>
))}
</div>
);
};

View File

@ -0,0 +1,16 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
.testrun-success-button {
background-color: rgba(0, 178, 60, 1) !important; // override semi style
border-radius: 8px;
color: #fff !important; // override semi style
}
.testrun-error-button {
background-color: rgba(255, 115, 0, 1) !important; // override semi style
border-radius: 8px;
color: #fff !important; // override semi style
}

View File

@ -0,0 +1,85 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useState, useEffect, useCallback } from 'react';
import { useClientContext, getNodeForm, FlowNodeEntity, I18n } from '@flowgram.ai/free-layout-editor';
import { Button, Badge } from '@douyinfe/semi-ui';
import { IconPlay } from '@douyinfe/semi-icons';
import { TestRunSidePanel } from '../testrun-panel';
import styles from './index.module.less';
export function TestRunButton(props: { disabled: boolean }) {
const [errorCount, setErrorCount] = useState(0);
const clientContext = useClientContext();
const [visible, setVisible] = useState(false);
const updateValidateData = useCallback(() => {
const allForms = clientContext.document.getAllNodes().map((node) => getNodeForm(node));
const count = allForms.filter((form) => form?.state.invalid).length;
setErrorCount(count);
}, [clientContext]);
/**
* Validate all node and Save
*/
const onTestRun = useCallback(async () => {
const allForms = clientContext.document.getAllNodes().map((node) => getNodeForm(node));
await Promise.all(allForms.map(async (form) => form?.validate()));
console.log('>>>>> save data: ', clientContext.document.toJSON());
setVisible(true);
}, [clientContext]);
/**
* Listen single node validate
*/
useEffect(() => {
const listenSingleNodeValidate = (node: FlowNodeEntity) => {
const form = getNodeForm(node);
if (form) {
const formValidateDispose = form.onValidate(() => updateValidateData());
node.onDispose(() => formValidateDispose.dispose());
}
};
clientContext.document.getAllNodes().map((node) => listenSingleNodeValidate(node));
const dispose = clientContext.document.onNodeCreate(({ node }) =>
listenSingleNodeValidate(node)
);
return () => dispose.dispose();
}, [clientContext]);
const button =
errorCount === 0 ? (
<Button
disabled={props.disabled}
onClick={onTestRun}
icon={<IconPlay size="small" />}
className={styles.testrunSuccessButton}
>
{I18n.t('Test Run')}
</Button>
) : (
<Badge count={errorCount} position="rightTop" type="danger">
<Button
type="danger"
disabled={props.disabled}
onClick={onTestRun}
icon={<IconPlay size="small" />}
className={styles.testrunErrorButton}
>
  {I18n.t('Test Run')}
</Button>
</Badge>
);
return (
<>
{button}
<TestRunSidePanel visible={visible} onCancel={() => setVisible(false)} />
</>
);
}

View File

@ -0,0 +1,128 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
.formContainer {
margin: 8px 0;
}
.formTitle {
font-size: 20px;
font-weight: 600;
color: #1a1a1a;
margin-bottom: 24px;
text-align: center;
}
.fieldGroup {
margin-bottom: 8px;
width: 358px;
&:last-child {
margin-bottom: 0;
}
}
.fieldLabel {
display: block;
font-size: 14px;
font-weight: 500;
color: #333333;
margin-bottom: 8px;
line-height: 1.4;
}
.fieldInput {
width: 100%;
:global(.semi-input) {
&:hover {
border-color: #4096ff;
}
&:focus {
border-color: #4096ff;
box-shadow: 0 0 0 2px rgba(64, 150, 255, 0.1);
}
}
:global(.semi-input-number) {
width: 100%;
&:hover {
border-color: #4096ff;
}
&:focus-within {
border-color: #4096ff;
box-shadow: 0 0 0 2px rgba(64, 150, 255, 0.1);
}
}
}
.codeEditorWrapper {
min-height: 100px;
max-height: 200px;
background: #fff;
padding: 8px 8px 8px 4px;
border-radius: 8px;
border: 1px solid #7f92cd40;
width: 348px;
:global(.cm-editor) {
height: 100% !important;
overflow: auto !important;
}
:global(.cm-scroller) {
min-height: 100px !important;
max-height: 200px !important;
}
:global(.cm-content) {
min-height: 100px !important;
max-height: 200px !important;
}
:global(.cm-activeLine) {
background-color: #efefef78;
}
:global(.cm-activeLineGutter) {
background-color: #efefef78;
}
:global(.cm-gutters) {
background-color: #fff;
color: #000A298A;
border-right-color: transparent;
border-right-width: 0px;
}
}
.fieldTypeIndicator {
display: inline-block;
padding: 2px 8px;
font-size: 12px;
font-weight: 500;
border-radius: 4px;
}
.emptyState {
text-align: center;
padding: 20px 20px;
color: #999999;
font-size: 14px;
.emptyText {
font-weight: 500;
}
}
.requiredIndicator {
color: #ff4d4f;
margin-left: 4px;
font-weight: 500;
}

View File

@ -0,0 +1,138 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FC } from 'react';
import classNames from 'classnames';
import { CodeEditor, DisplaySchemaTag } from '@flowgram.ai/form-materials';
import { Input, Switch, InputNumber } from '@douyinfe/semi-ui';
import { I18n } from '@flowgram.ai/free-layout-editor';
import { useFormMeta } from '../hooks/use-form-meta';
import { useFields } from '../hooks/use-fields';
import { useSyncDefault } from '../hooks';
import styles from './index.module.less';
interface TestRunFormProps {
values: Record<string, unknown>;
setValues: (values: Record<string, unknown>) => void;
}
export const TestRunForm: FC<TestRunFormProps> = ({ values, setValues }) => {
const formMeta = useFormMeta();
const fields = useFields({
formMeta,
values,
setValues,
});
useSyncDefault({
formMeta,
values,
setValues,
});
const renderField = (field: any) => {
switch (field.type) {
case 'boolean':
return (
<div className={styles.fieldInput}>
<Switch checked={field.value} onChange={(checked) => field.onChange(checked)} />
</div>
);
case 'integer':
return (
<div className={styles.fieldInput}>
<InputNumber
precision={0}
value={field.value}
onChange={(value) => field.onChange(value)}
placeholder={I18n.t('Please input integer')}
/>
</div>
);
case 'number':
return (
<div className={styles.fieldInput}>
<InputNumber
value={field.value}
onChange={(value) => field.onChange(value)}
placeholder={I18n.t('Please input number')}
/>
</div>
);
case 'object':
return (
<div className={classNames(styles.fieldInput, styles.codeEditorWrapper)}>
<CodeEditor
languageId="json"
value={field.value}
onChange={(value) => field.onChange(value)}
/>
</div>
);
case 'array':
return (
<div className={classNames(styles.fieldInput, styles.codeEditorWrapper)}>
<CodeEditor
languageId="json"
value={field.value}
onChange={(value) => field.onChange(value)}
/>
</div>
);
default:
return (
<div className={styles.fieldInput}>
<Input
value={field.value}
onChange={(value) => field.onChange(value)}
placeholder={I18n.t('Please input text')}
/>
</div>
);
}
};
// Show empty state if no fields
if (fields.length === 0) {
return (
<div className={styles.formContainer}>
<div className={styles.emptyState}>
<div className={styles.emptyText}>{I18n.t('Empty')}</div>
<div className={styles.emptyText}>{I18n.t('No inputs found in start node')}</div>
</div>
</div>
);
}
return (
<div className={styles.formContainer}>
{fields.map((field) => (
<div key={field.name} className={styles.fieldGroup}>
<label htmlFor={field.name} className={styles.fieldLabel}>
{field.name}
{field.required && <span className={styles.requiredIndicator}>*</span>}
<span className={styles.fieldTypeIndicator}>
<DisplaySchemaTag
value={{
type: field.type,
items: field.itemsType
? {
type: field.itemsType,
}
: undefined,
}}
/>
</span>
</label>
{renderField(field)}
</div>
))}
</div>
);
};

View File

@ -0,0 +1,21 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import type { JsonSchemaBasicType } from '@flowgram.ai/form-materials';
export interface TestRunFormMetaItem {
type: JsonSchemaBasicType;
name: string;
defaultValue: unknown;
required: boolean;
itemsType?: JsonSchemaBasicType;
}
export type TestRunFormMeta = TestRunFormMetaItem[];
export interface TestRunFormField extends TestRunFormMetaItem {
value: unknown;
onChange: (value: unknown) => void;
}

View File

@ -0,0 +1,44 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
.testrun-json-input {
min-height: 300px;
max-height: 400px;
background: #fff;
padding: 8px 8px 8px 4px;
border-radius: 8px;
border: 1px solid #7f92cd40;
width: 348px;
:global(.cm-editor) {
height: 100% !important;
overflow: auto !important;
}
:global(.cm-scroller) {
min-height: 300px !important;
max-height: 400px !important;
}
:global(.cm-content) {
min-height: 300px !important;
max-height: 400px !important;
}
:global(.cm-activeLine) {
background-color: #efefef78;
}
:global(.cm-activeLineGutter) {
background-color: #efefef78;
}
:global(.cm-gutters) {
background-color: #fff;
color: #000A298A;
border-right-color: transparent;
border-right-width: 0px;
}
}

View File

@ -0,0 +1,37 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FC } from 'react';
import { CodeEditor } from '@flowgram.ai/form-materials';
import { useFormMeta, useSyncDefault } from '../hooks';
import styles from './index.module.less';
interface TestRunJsonInputProps {
values: Record<string, unknown>;
setValues: (values: Record<string, unknown>) => void;
}
export const TestRunJsonInput: FC<TestRunJsonInputProps> = ({ values, setValues }) => {
const formMeta = useFormMeta();
useSyncDefault({
formMeta,
values,
setValues,
});
return (
<div className={styles['testrun-json-input']}>
<CodeEditor
languageId="json"
value={JSON.stringify(values, null, 2)}
onChange={(value) => setValues(JSON.parse(value))}
/>
</div>
);
};

View File

@ -0,0 +1,139 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
.testrun-panel-form {
.testrun-panel-input {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin: 0 12px 8px 0;
.title {
font-size: 15px;
font-weight: 500;
color: #333;
flex: 1;
}
}
.error {
color: red;
font-size: 14px;
}
.code-editor-container {
min-height: 200px;
max-height: 400px;
background: #fff;
padding: 8px 8px 8px 4px;
border-radius: 4px;
border: 1px solid #52649a0f;
:global(.cm-editor) {
height: 100% !important;
overflow: auto !important;
}
:global(.cm-scroller) {
min-height: 200px !important;
max-height: 400px !important;
}
:global(.cm-content) {
min-height: 200px !important;
max-height: 400px !important;
}
}
}
.testrun-panel-running {
width: 100%;
height: 80%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 16px;
.text {
font-size: 18px;
}
}
.button {
border-radius: 8px;
width: 358px;
height: 40px;
margin: 16px;
&.running {
background-color: rgba(87, 104, 161, 0.08) !important; // override semi style
color: rgba(15, 21, 40, 0.82);
}
&.default {
background-color: rgba(0, 178, 60, 1) !important; // override semi style
color: #fff;
}
}
.testrun-panel-container {
background: rgb(255, 255, 255);
margin: 8px 8px 8px 0;
height: calc(100vh - 40px);
border-radius: 8px;
border: 1px solid rgba(82, 100, 154, 0.13);
padding: 8px 0 8px 0;
display: flex;
flex-direction: column;
overflow: hidden;
.testrun-panel-header {
background: var(#fcfcff);
border-bottom: 1px solid rgba(82, 100, 154, 0.13);
border-top-left-radius: 8px;
border-top-right-radius: 8px;
display: flex;
height: 40px;
justify-content: space-between;
min-height: 40px;
width: 100%;
align-items: center;
.testrun-panel-title {
font-size: 16px;
font-weight: 500;
margin: 8px 8px 8px 16px;
}
.testrun-panel-close {
margin: 8px 16px 8px 8px;
}
}
.testrun-panel-content {
height: calc(100% - 40px);
margin: 8px 8px 8px 16px;
display: flex;
flex-direction: column;
gap: 8px;
overflow: auto;
margin-bottom: 72px;
}
.testrun-panel-footer {
border-top: 1px solid rgba(82, 100, 154, 0.13);
height: 40px;
position: fixed;
background: #fbfbfb;
height: 72px;
bottom: 16px;
border-radius: 0 0 8px 8px;
}
}

View File

@ -0,0 +1,211 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FC, useContext, useEffect, useState } from 'react';
import classnames from 'classnames';
import { useService, I18n } from '@flowgram.ai/free-layout-editor';
import { Button, SideSheet, Switch, Tag } from '@douyinfe/semi-ui';
import { IconClose, IconPlay, IconSpin } from '@douyinfe/semi-icons';
import { TestRunJsonInput } from '../testrun-json-input';
import { TestRunForm } from '../testrun-form';
import { NodeStatusGroup } from '../node-status-bar/group';
// 改为使用后端运行服务
import { CustomService } from '../../../services';
import { SidebarContext } from '../../../context';
import { IconCancel } from '../../../assets/icon-cancel';
import styles from './index.module.less';
interface TestRunSidePanelProps {
visible: boolean;
onCancel: () => void;
}
export const TestRunSidePanel: FC<TestRunSidePanelProps> = ({ visible, onCancel }) => {
const customService = useService(CustomService);
const { nodeId: sidebarNodeId, setNodeId } = useContext(SidebarContext);
const [isRunning, setRunning] = useState(false);
const [values, setValues] = useState<Record<string, unknown>>({});
const [errors, setErrors] = useState<string[]>();
const [result, setResult] = useState<
| {
ok?: boolean;
ctx: any;
logs: string[];
}
| undefined
>();
// en - Use localStorage to persist the JSON mode state
const [inputJSONMode, _setInputJSONMode] = useState(() => {
const savedMode = localStorage.getItem('testrun-input-json-mode');
return savedMode ? JSON.parse(savedMode) : false;
});
const setInputJSONMode = (checked: boolean) => {
_setInputJSONMode(checked);
localStorage.setItem('testrun-input-json-mode', JSON.stringify(checked));
};
const extractErrorMsg = (logs: string[] | undefined): string | undefined => {
if (!logs || logs.length === 0) return undefined;
const patterns = [/failed/i, /error/i, /panic/i];
for (const line of logs) {
if (patterns.some((p) => p.test(line))) return line;
}
return logs[logs.length - 1];
};
const onTestRun = async () => {
if (isRunning) {
// 后端运行不可取消,这里直接忽略重复点击
return;
}
setResult(undefined);
setErrors(undefined);
setRunning(true);
try {
// 运行前保存(静默),确保后端 YAML 与编辑器一致;若保存失败则不继续运行
const saved = await customService.save({ silent: true });
if (!saved) {
setErrors([I18n.t('Save failed, cannot run')]);
return;
}
const runRes = await customService.run(values);
if (runRes) {
// 若后端返回 ok=false则视为失败并展示失败信息与日志
if ((runRes as any).ok === false) {
setResult(runRes as any);
const err = extractErrorMsg((runRes as any).logs) || I18n.t('Run failed');
setErrors([err]);
} else {
setResult(runRes as any);
}
} else {
setErrors([I18n.t('Run failed')]);
}
} catch (e: any) {
setErrors([e?.message || I18n.t('Run failed')]);
} finally {
setRunning(false);
}
};
const onClose = async () => {
setValues({});
setRunning(false);
onCancel();
};
// sidebar effect
useEffect(() => {
setNodeId(undefined);
}, []);
useEffect(() => {
if (sidebarNodeId) {
onCancel();
}
}, [sidebarNodeId]);
const renderRunning = (
<div className={styles['testrun-panel-running']}>
<IconSpin spin size="large" />
<div className={styles.text}>{I18n.t('Running...')}</div>
</div>
);
const renderStatus = (
<div style={{ marginBottom: 8 }}>
{result?.ok === true && <Tag color="green">{I18n.t('Success')}</Tag>}
{(errors?.length || result?.ok === false) && (
<Tag color="red">{I18n.t('Failed')}</Tag>
)}
</div>
);
const renderForm = (
<div className={styles['testrun-panel-form']}>
<div className={styles['testrun-panel-input']}>
<div className={styles.title}>{I18n.t('Input Form')}</div>
<div>{I18n.t('JSON Mode')}</div>
<Switch
checked={inputJSONMode}
onChange={(checked: boolean) => setInputJSONMode(checked)}
size="small"
/>
</div>
{renderStatus}
{errors?.map((e) => (
<div className={styles.error} key={e}>
{e}
</div>
))}
{inputJSONMode ? (
<TestRunJsonInput values={values} setValues={setValues} />
) : (
<TestRunForm values={values} setValues={setValues} />
)}
{/* 展示后端返回的执行信息 */}
<NodeStatusGroup title={I18n.t('Context')} data={result?.ctx} optional disableCollapse />
<NodeStatusGroup title={I18n.t('Logs')} data={result?.logs} optional disableCollapse />
</div>
);
const renderButton = (
<Button
onClick={onTestRun}
icon={isRunning ? <IconCancel /> : <IconPlay size="small" />}
className={classnames(styles.button, {
[styles.running]: isRunning,
[styles.default]: !isRunning,
})}
>
{isRunning ? I18n.t('Running...') : I18n.t('Test Run')}
</Button>
);
return (
<SideSheet
title={I18n.t('Test Run')}
visible={visible}
mask={false}
motion={false}
onCancel={onClose}
width={400}
headerStyle={{
display: 'none',
}}
bodyStyle={{
padding: 0,
}}
style={{
background: 'none',
boxShadow: 'none',
}}
>
<div className={styles['testrun-panel-container']}>
<div className={styles['testrun-panel-header']}>
<div className={styles['testrun-panel-title']}>{I18n.t('Test Run')}</div>
<Button
className={styles['testrun-panel-title']}
type="tertiary"
icon={<IconClose />}
size="small"
theme="borderless"
onClick={onClose}
/>
</div>
<div className={styles['testrun-panel-content']}>
{isRunning ? renderRunning : renderForm}
</div>
<div className={styles['testrun-panel-footer']}>{renderButton}</div>
</div>
</SideSheet>
);
};

View File

@ -0,0 +1,32 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useCallback } from 'react';
import { usePlayground, usePlaygroundTools } from '@flowgram.ai/free-layout-editor';
import { IconButton, Tooltip } from '@douyinfe/semi-ui';
import { I18n } from '@flowgram.ai/free-layout-editor';
import { IconAutoLayout } from '../../assets/icon-auto-layout';
export const AutoLayout = () => {
const tools = usePlaygroundTools();
const playground = usePlayground();
const autoLayout = useCallback(async () => {
await tools.autoLayout();
}, [tools]);
return (
<Tooltip content={I18n.t('Auto Layout')}>
<IconButton
disabled={playground.config.readonly}
type="tertiary"
theme="borderless"
onClick={autoLayout}
icon={IconAutoLayout}
/>
</Tooltip>
);
};

View File

@ -0,0 +1,85 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useState, useCallback } from 'react';
import {
delay,
usePlayground,
useService,
WorkflowDocument,
WorkflowDragService,
WorkflowSelectService,
} from '@flowgram.ai/free-layout-editor';
import { IconButton, Tooltip } from '@douyinfe/semi-ui';
import { I18n } from '@flowgram.ai/free-layout-editor';
import { WorkflowNodeType } from '../../nodes';
import { IconComment } from '../../assets/icon-comment';
export const Comment = () => {
const playground = usePlayground();
const document = useService(WorkflowDocument);
const selectService = useService(WorkflowSelectService);
const dragService = useService(WorkflowDragService);
const [tooltipVisible, setTooltipVisible] = useState(false);
const calcNodePosition = useCallback(
(mouseEvent: React.MouseEvent<HTMLButtonElement>) => {
const mousePosition = playground.config.getPosFromMouseEvent(mouseEvent);
return {
x: mousePosition.x,
y: mousePosition.y - 75,
};
},
[playground]
);
const createComment = useCallback(
async (mouseEvent: React.MouseEvent<HTMLButtonElement>) => {
setTooltipVisible(false);
const canvasPosition = calcNodePosition(mouseEvent);
// create comment node - 创建节点
const node = document.createWorkflowNodeByType(WorkflowNodeType.Comment, canvasPosition);
// wait comment node render - 等待节点渲染
await delay(16);
// select comment node - 选中节点
selectService.selectNode(node);
// maybe touch event - 可能是触摸事件
if (mouseEvent.detail !== 0) {
// start drag -开始拖拽
dragService.startDragSelectedNodes(mouseEvent);
}
},
[selectService, calcNodePosition, document, dragService]
);
return (
<Tooltip
trigger="custom"
visible={tooltipVisible}
onVisibleChange={setTooltipVisible}
content={I18n.t('Comment')}
>
<IconButton
disabled={playground.config.readonly}
icon={
<IconComment
style={{
width: 16,
height: 16,
}}
/>
}
type="tertiary"
theme="borderless"
onClick={createComment}
onMouseEnter={() => setTooltipVisible(true)}
onMouseLeave={() => setTooltipVisible(false)}
/>
</Tooltip>
);
};

View File

@ -0,0 +1,23 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { usePlaygroundTools } from '@flowgram.ai/free-layout-editor';
import { IconButton, Tooltip } from '@douyinfe/semi-ui';
import { I18n } from '@flowgram.ai/free-layout-editor';
import { IconExpand } from '@douyinfe/semi-icons';
export const FitView = () => {
const tools = usePlaygroundTools();
return (
<Tooltip content={I18n.t('FitView')}>
<IconButton
icon={<IconExpand />}
type="tertiary"
theme="borderless"
onClick={() => tools.fitView()}
/>
</Tooltip>
);
};

View File

@ -0,0 +1,211 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useState, useEffect } from 'react';
import { useRefresh } from '@flowgram.ai/free-layout-editor';
import { useClientContext } from '@flowgram.ai/free-layout-editor';
import { Tooltip, IconButton, Divider } from '@douyinfe/semi-ui';
import { IconUndo, IconRedo, IconChevronLeft, IconEdit } from '@douyinfe/semi-icons';
import { TestRunButton } from '../testrun/testrun-button';
import { AddNode } from '../add-node';
import { ZoomSelect } from './zoom-select';
import { SwitchLine } from './switch-line';
import { ToolContainer, ToolSection } from './styles';
import { Readonly } from './readonly';
import { MinimapSwitch } from './minimap-switch';
import { Minimap } from './minimap';
import { Interactive } from './interactive';
import { FitView } from './fit-view';
import { Comment } from './comment';
import { AutoLayout } from './auto-layout';
import { useService, I18n } from '@flowgram.ai/free-layout-editor';
import { CustomService } from '../../services';
import { useNavigate } from 'react-router-dom';
import { Save } from './save';
// 基础信息编辑:对齐新建流程弹窗,使用 Antd 的 Modal/Form
import { Modal as AModal, Form as AForm, Input as AInput, message } from 'antd'
import api from '../../../utils/axios'
// 兼容 BrowserRouter 与 HashRouter优先从 search 获取,若无则从 hash 的查询串中获取
function getFlowIdFromUrl(): string {
const searchId = new URLSearchParams(window.location.search).get('id');
if (searchId) return searchId;
const hash = window.location.hash || '';
const qIndex = hash.indexOf('?');
if (qIndex >= 0) {
const qs = hash.substring(qIndex + 1);
const hashId = new URLSearchParams(qs).get('id');
if (hashId) return hashId;
}
return '';
}
export const FlowTools = () => {
const { history, playground } = useClientContext();
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const [minimapVisible, setMinimapVisible] = useState(true);
const customService = useService(CustomService);
const navigate = useNavigate();
useEffect(() => {
const disposable = history.undoRedoService.onChange(() => {
setCanUndo(history.canUndo());
setCanRedo(history.canRedo());
});
return () => disposable.dispose();
}, [history]);
const refresh = useRefresh();
useEffect(() => {
const disposable = playground.config.onReadonlyOrDisabledChange(() => refresh());
return () => disposable.dispose();
}, [playground]);
// 基础信息弹窗状态
const [baseOpen, setBaseOpen] = useState(false)
const [baseLoading, setBaseLoading] = useState(false)
const [baseForm] = AForm.useForm()
const openBaseInfo = async () => {
setBaseOpen(true)
// 预填现有数据
baseForm.resetFields()
const id = getFlowIdFromUrl()
if (!id) return
try{
setBaseLoading(true)
const { data } = await api.get(`/flows/${id}`)
const detail: any = data?.data || {}
const patch: any = {}
if (detail?.name) patch.name = detail.name
if (detail?.code) patch.code = detail.code
if (detail?.remark) patch.remark = detail.remark
// 若后端未返回,也至少设置一个空值,避免受控报错
baseForm.setFieldsValue({ name: patch.name ?? '', code: patch.code, remark: patch.remark })
}catch(e:any){
// 静默失败,允许用户直接填写
}finally{
setBaseLoading(false)
}
}
const handleBaseOk = async () => {
const id = getFlowIdFromUrl()
if (!id) {
message.error('缺少流程 ID')
return
}
try{
const values = await baseForm.validateFields()
const payload: any = {
name: (values.name || '').trim(),
code: values.code ? String(values.code).trim() : undefined,
remark: values.remark ? String(values.remark).trim() : undefined,
}
setBaseLoading(true)
const { data } = await api.put(`/flows/${id}`, payload)
if (data?.code === 0){
message.success('已保存')
setBaseOpen(false)
}else{
throw new Error(data?.message || '保存失败')
}
}catch(e:any){
if (e?.errorFields) return // 表单校验错误
message.error(e?.message || '保存失败')
}finally{
setBaseLoading(false)
}
}
return (
<>
<ToolContainer className="flow-tools">
<ToolSection>
{/* 返回列表 */}
<Tooltip content={I18n.t('Back to List')}>
<IconButton
type="tertiary"
theme="borderless"
icon={<IconChevronLeft />}
onClick={() => navigate('/flows')}
/>
</Tooltip>
<Divider layout="vertical" style={{ height: '16px' }} margin={3} />
{/* 基础信息(名称/编号/备注) */}
<Tooltip content={I18n.t('Edit Base Info')}>
<IconButton
type="tertiary"
theme="borderless"
icon={<IconEdit />}
onClick={openBaseInfo}
/>
</Tooltip>
<Divider layout="vertical" style={{ height: '16px' }} margin={3} />
<Interactive />
<AutoLayout />
<SwitchLine />
<ZoomSelect />
<FitView />
<MinimapSwitch minimapVisible={minimapVisible} setMinimapVisible={setMinimapVisible} />
<Minimap visible={minimapVisible} />
<Readonly />
<Comment />
<Tooltip content={I18n.t('Undo')}>
<IconButton
type="tertiary"
theme="borderless"
icon={<IconUndo />}
disabled={!canUndo || playground.config.readonly}
onClick={() => history.undo()}
/>
</Tooltip>
<Tooltip content={I18n.t('Redo')}>
<IconButton
type="tertiary"
theme="borderless"
icon={<IconRedo />}
disabled={!canRedo || playground.config.readonly}
onClick={() => history.redo()}
/>
</Tooltip>
<Divider layout="vertical" style={{ height: '16px' }} margin={3} />
<AddNode disabled={playground.config.readonly} />
<Divider layout="vertical" style={{ height: '16px' }} margin={3} />
{/* 保存 */}
<Save disabled={playground.config.readonly} />
<Divider layout="vertical" style={{ height: '16px' }} margin={3} />
<TestRunButton disabled={playground.config.readonly} />
</ToolSection>
</ToolContainer>
{/* 基础信息弹窗(对齐新建流程表单) */}
<AModal
title={`${I18n.t('Edit Base Info')}${baseForm.getFieldValue('name') ? ' - ' + baseForm.getFieldValue('name') : ''}`}
open={baseOpen}
onOk={handleBaseOk}
confirmLoading={baseLoading}
onCancel={() => setBaseOpen(false)}
okText={I18n.t('Save')}
maskClosable={false}
destroyOnClose
>
<AForm form={baseForm} layout="vertical" preserve={false}>
<AForm.Item name="name" label={I18n.t('Flow Name')} rules={[{ required: true, message: I18n.t('Please input flow name') }, { max: 50, message: I18n.t('Max 50 characters') }]}>
<AInput placeholder={I18n.t('Please input flow name')} allowClear />
</AForm.Item>
<AForm.Item name="code" label={I18n.t('Flow Code')} rules={[{ required: true, message: I18n.t('Please input flow code') }, { max: 50, message: I18n.t('Max 50 characters') }]}>
<AInput placeholder={I18n.t('Required, recommend letters/numbers/-/_')} allowClear />
</AForm.Item>
<AForm.Item name="remark" label={I18n.t('Remark')} rules={[{ max: 255, message: I18n.t('Max 255 characters') }]}>
<AInput.TextArea rows={3} placeholder={I18n.t('Optional, remark info')} allowClear />
</AForm.Item>
</AForm>
</AModal>
</>
);
};

View File

@ -0,0 +1,102 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useEffect, useState } from 'react';
import {
usePlaygroundTools,
type InteractiveType as IdeInteractiveType,
I18n,
} from '@flowgram.ai/free-layout-editor';
import { Tooltip, Popover } from '@douyinfe/semi-ui';
import { MousePadSelector } from './mouse-pad-selector';
export const CACHE_KEY = 'workflow_prefer_interactive_type';
export const SHOW_KEY = 'show_workflow_interactive_type_guide';
export const IS_MAC_OS = /(Macintosh|MacIntel|MacPPC|Mac68K|iPad)/.test(navigator.userAgent);
export const getPreferInteractiveType = () => {
const data = localStorage.getItem(CACHE_KEY) as string;
if (data && [InteractiveType.Mouse, InteractiveType.Pad].includes(data as InteractiveType)) {
return data;
}
return IS_MAC_OS ? InteractiveType.Pad : InteractiveType.Mouse;
};
export const setPreferInteractiveType = (type: InteractiveType) => {
localStorage.setItem(CACHE_KEY, type);
};
export enum InteractiveType {
Mouse = 'MOUSE',
Pad = 'PAD',
}
export const Interactive = () => {
const tools = usePlaygroundTools();
const [visible, setVisible] = useState(false);
const [interactiveType, setInteractiveType] = useState<InteractiveType>(
() => getPreferInteractiveType() as InteractiveType
);
const [showInteractivePanel, setShowInteractivePanel] = useState(false);
const mousePadTooltip =
interactiveType === InteractiveType.Mouse ? I18n.t('Mouse-Friendly') : I18n.t('Touchpad-Friendly');
useEffect(() => {
tools.setMouseScrollDelta((zoom) => zoom / 20);
// read from localStorage
const preferInteractiveType = getPreferInteractiveType();
tools.setInteractiveType(preferInteractiveType as IdeInteractiveType);
}, []);
const handleClose = () => {
setVisible(false);
};
return (
<Popover trigger="custom" position="top" visible={visible} onClickOutSide={handleClose}>
<Tooltip
content={mousePadTooltip}
style={{ display: showInteractivePanel ? 'none' : 'block' }}
>
<div className="workflow-toolbar-interactive">
<MousePadSelector
value={interactiveType}
onChange={(value) => {
setInteractiveType(value);
setPreferInteractiveType(value);
tools.setInteractiveType(value as unknown as IdeInteractiveType);
}}
onPopupVisibleChange={setShowInteractivePanel}
containerStyle={{
border: 'none',
height: '32px',
width: '32px',
justifyContent: 'center',
alignItems: 'center',
gap: '2px',
padding: '4px',
borderRadius: 'var(--small, 6px)',
}}
iconStyle={{
margin: '0',
width: '16px',
height: '16px',
}}
arrowStyle={{
width: '12px',
height: '12px',
}}
/>
</div>
</Tooltip>
</Popover>
);
};

View File

@ -0,0 +1,27 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { Tooltip, IconButton } from '@douyinfe/semi-ui';
import { I18n } from '@flowgram.ai/free-layout-editor';
import { UIIconMinimap } from './styles';
export const MinimapSwitch = (props: {
minimapVisible: boolean;
setMinimapVisible: (visible: boolean) => void;
}) => {
const { minimapVisible, setMinimapVisible } = props;
return (
<Tooltip content={I18n.t('Minimap')}>
<IconButton
type="tertiary"
theme="borderless"
icon={<UIIconMinimap visible={minimapVisible} />}
onClick={() => setMinimapVisible(!minimapVisible)}
/>
</Tooltip>
);
};

View File

@ -0,0 +1,38 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FlowMinimapService, MinimapRender } from '@flowgram.ai/minimap-plugin';
import { useService } from '@flowgram.ai/free-layout-editor';
import { MinimapContainer } from './styles';
export const Minimap = ({ visible }: { visible?: boolean }) => {
const minimapService = useService(FlowMinimapService);
if (!visible) {
return <></>;
}
return (
<MinimapContainer>
<MinimapRender
service={minimapService}
panelStyles={{}}
containerStyles={{
pointerEvents: 'auto',
position: 'relative',
top: 'unset',
right: 'unset',
bottom: 'unset',
left: 'unset',
}}
inactiveStyle={{
opacity: 1,
scale: 1,
translateX: 0,
translateY: 0,
}}
/>
</MinimapContainer>
);
};

View File

@ -0,0 +1,117 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
/* stylelint-disable no-descending-specificity */
/* stylelint-disable selector-class-pattern */
.ui-mouse-pad-selector {
position: relative;
display: flex;
align-items: center;
box-sizing: border-box;
width: 68px;
height: 32px;
padding: 8px 12px;
border: 1px solid rgba(29, 28, 35, 8%);
border-radius: 8px;
&-icon {
height: 20px;
margin-right: 12px;
}
&-arrow {
height: 16px;
font-size: 12px;
}
&-popover {
padding: 16px;
&-options {
display: flex;
gap: 12px;
margin-top: 12px;
}
.mouse-pad-option {
box-sizing: border-box;
width: 220px;
padding-bottom: 20px;
text-align: center;
background: var(--coz-mg-card, #FFF);
border: 1px solid var(--coz-stroke-plus, rgba(6, 7, 9, 15%));
border-radius: var(--default, 8px);
&-icon {
padding-top: 26px;
}
&-title {
padding-top: 8px;
}
&-subTitle {
padding: 4px 12px 0;
}
&-icon-selected {
color: rgb(19 0 221);
}
&-title-selected {
color: var(--coz-fg-hglt, #4E40E5);
}
&-subTitle-selected {
color: var(--coz-fg-hglt, #4E40E5);
}
&-selected {
cursor: pointer;
background-color: var(--coz-mg-hglt, rgba(186, 192, 255, 20%));
border: 1px solid var(--coz-stroke-hglt, #4E40E5);
border-radius: var(--default, 8px);
}
&:hover:not(&-selected) {
cursor: pointer;
background-color: var(--coz-mg-card-hovered, #FFF);
border: 1px solid var(--coz-stroke-plus, rgba(6, 7, 9, 15%));
border-radius: var(--default, 8px);
box-shadow: 0 8px 24px 0 rgba(0, 0, 0, 16%), 0 16px 48px 0 rgba(0, 0, 0, 8%);
}
&:active:not(&-selected) {
background-color: rgba(46, 46, 56, 12%);
}
&:last-of-type {
padding-top: 13px;
}
}
}
&:hover {
cursor: pointer;
background-color: rgba(46, 46, 56, 8%);
border-color: rgba(77, 83, 232, 100%);
}
&:active,
&:focus {
background-color: rgba(46, 46, 56, 12%);
border-color: rgba(77, 83, 232, 100%);
}
&-active {
border-color: rgba(77, 83, 232, 100%);
}
}

View File

@ -0,0 +1,122 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import React, { type CSSProperties, useState } from 'react';
import { Popover, Typography } from '@douyinfe/semi-ui';
import { IconPad, IconPadTool } from '../../assets/icon-pad';
import { IconMouse, IconMouseTool } from '../../assets/icon-mouse';
import './mouse-pad-selector.less';
const { Title, Paragraph } = Typography;
export enum InteractiveType {
Mouse = 'MOUSE',
Pad = 'PAD',
}
export interface MousePadSelectorProps {
value: InteractiveType;
onChange: (value: InteractiveType) => void;
onPopupVisibleChange?: (visible: boolean) => void;
containerStyle?: CSSProperties;
iconStyle?: CSSProperties;
arrowStyle?: CSSProperties;
}
const InteractiveItem: React.FC<{
title: string;
subTitle: string;
icon: React.ReactNode;
value: InteractiveType;
selected: boolean;
onChange: (value: InteractiveType) => void;
}> = ({ title, subTitle, icon, onChange, value, selected }) => (
<div
className={`mouse-pad-option ${selected ? 'mouse-pad-option-selected' : ''}`}
onClick={() => onChange(value)}
>
<div className={`mouse-pad-option-icon ${selected ? 'mouse-pad-option-icon-selected' : ''}`}>
{icon}
</div>
<Title
heading={6}
className={`mouse-pad-option-title ${selected ? 'mouse-pad-option-title-selected' : ''}`}
>
{title}
</Title>
<Paragraph
type="tertiary"
className={`mouse-pad-option-subTitle ${
selected ? 'mouse-pad-option-subTitle-selected' : ''
}`}
>
{subTitle}
</Paragraph>
</div>
);
export const MousePadSelector: React.FC<
MousePadSelectorProps & React.RefAttributes<HTMLDivElement>
> = ({ value, onChange, onPopupVisibleChange, containerStyle, iconStyle, arrowStyle }) => {
const isMouse = value === InteractiveType.Mouse;
const [visible, setVisible] = useState(false);
return (
<Popover
trigger="custom"
position="topLeft"
closeOnEsc
visible={visible}
onVisibleChange={(v) => {
onPopupVisibleChange?.(v);
}}
onClickOutSide={() => {
setVisible(false);
}}
spacing={20}
content={
<div className={'ui-mouse-pad-selector-popover'}>
<Typography.Title heading={4}>{'Interaction mode'}</Typography.Title>
<div className={'ui-mouse-pad-selector-popover-options'}>
<InteractiveItem
title={'Mouse-Friendly'}
subTitle={'Drag the canvas with the left mouse button, zoom with the scroll wheel.'}
value={InteractiveType.Mouse}
selected={value === InteractiveType.Mouse}
icon={<IconMouse />}
onChange={onChange}
/>
<InteractiveItem
title={'Touchpad-Friendly'}
subTitle={
'Drag with two fingers moving in the same direction, zoom by pinching or spreading two fingers.'
}
value={InteractiveType.Pad}
selected={value === InteractiveType.Pad}
icon={<IconPad />}
onChange={onChange}
/>
</div>
</div>
}
>
<div
className={`ui-mouse-pad-selector ${visible ? 'ui-mouse-pad-selector-active' : ''}`}
onClick={() => {
setVisible(!visible);
}}
style={containerStyle}
>
<div className={'ui-mouse-pad-selector-icon'} style={iconStyle}>
{isMouse ? <IconMouseTool /> : <IconPadTool />}
</div>
</div>
</Popover>
);
};

View File

@ -0,0 +1,37 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useCallback } from 'react';
import { usePlayground } from '@flowgram.ai/free-layout-editor';
import { IconButton, Tooltip } from '@douyinfe/semi-ui';
import { I18n } from '@flowgram.ai/free-layout-editor';
import { IconUnlock, IconLock } from '@douyinfe/semi-icons';
export const Readonly = () => {
const playground = usePlayground();
const toggleReadonly = useCallback(() => {
playground.config.readonly = !playground.config.readonly;
}, [playground]);
return playground.config.readonly ? (
<Tooltip content={I18n.t('Editable')}>
<IconButton
theme="borderless"
type="tertiary"
icon={<IconLock size="default" />}
onClick={toggleReadonly}
/>
</Tooltip>
) : (
<Tooltip content={I18n.t('Readonly')}>
<IconButton
theme="borderless"
type="tertiary"
icon={<IconUnlock size="default" />}
onClick={toggleReadonly}
/>
</Tooltip>
);
};

View File

@ -0,0 +1,74 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useState, useEffect, useCallback } from 'react';
import { useClientContext, getNodeForm, FlowNodeEntity, useService, I18n } from '@flowgram.ai/free-layout-editor';
import { Button, Badge } from '@douyinfe/semi-ui';
import { CustomService } from '../../services';
export function Save(props: { disabled: boolean }) {
const [errorCount, setErrorCount] = useState(0);
const clientContext = useClientContext();
const customService = useService(CustomService);
const updateValidateData = useCallback(() => {
const allForms = clientContext.document.getAllNodes().map((node) => getNodeForm(node));
const count = allForms.filter((form) => form?.state.invalid).length;
setErrorCount(count);
}, [clientContext]);
/**
* Validate all node and Save
*/
const onSave = useCallback(async () => {
const allForms = clientContext.document.getAllNodes().map((node) => getNodeForm(node));
await Promise.all(allForms.map(async (form) => form?.validate()));
await customService.save();
}, [clientContext, customService]);
/**
* Listen single node validate
*/
useEffect(() => {
const listenSingleNodeValidate = (node: FlowNodeEntity) => {
const form = getNodeForm(node);
if (form) {
const formValidateDispose = form.onValidate(() => updateValidateData());
node.onDispose(() => formValidateDispose.dispose());
}
};
clientContext.document.getAllNodes().map((node) => listenSingleNodeValidate(node));
const dispose = clientContext.document.onNodeCreate(({ node }) =>
listenSingleNodeValidate(node)
);
return () => dispose.dispose();
}, [clientContext]);
if (errorCount === 0) {
return (
<Button
disabled={props.disabled}
onClick={onSave}
style={{ backgroundColor: 'rgba(171,181,255,0.3)', borderRadius: '8px' }}
>
{I18n.t('Save')}
</Button>
);
}
return (
<Badge count={errorCount} position="rightTop" type="danger">
<Button
type="danger"
disabled={props.disabled}
onClick={onSave}
style={{ backgroundColor: 'rgba(255, 179, 171, 0.3)', borderRadius: '8px' }}
>
  {I18n.t('Save')}
</Button>
</Badge>
);
}

View File

@ -0,0 +1,52 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import styled from 'styled-components';
import { IconMinimap } from '../../assets/icon-minimap';
export const ToolContainer = styled.div`
position: absolute;
bottom: 16px;
display: flex;
justify-content: left;
min-width: 360px;
pointer-events: none;
gap: 8px;
z-index: 99;
`;
export const ToolSection = styled.div`
display: flex;
align-items: center;
background-color: #fff;
border: 1px solid rgba(68, 83, 130, 0.25);
border-radius: 10px;
box-shadow: rgba(0, 0, 0, 0.04) 0px 2px 6px 0px, rgba(0, 0, 0, 0.02) 0px 4px 12px 0px;
column-gap: 2px;
height: 40px;
padding: 0 4px;
pointer-events: auto;
`;
export const SelectZoom = styled.span`
padding: 4px;
border-radius: 8px;
border: 1px solid rgba(68, 83, 130, 0.25);
font-size: 12px;
width: 50px;
cursor: pointer;
`;
export const MinimapContainer = styled.div`
position: absolute;
bottom: 60px;
width: 198px;
`;
export const UIIconMinimap = styled(IconMinimap)<{ visible: boolean }>`
color: ${(props) => (props.visible ? undefined : '#060709cc')};
`;

View File

@ -0,0 +1,25 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useCallback } from 'react';
import { useService, WorkflowLinesManager } from '@flowgram.ai/free-layout-editor';
import { IconButton, Tooltip } from '@douyinfe/semi-ui';
import { I18n } from '@flowgram.ai/free-layout-editor';
import { IconSwitchLine } from '../../assets/icon-switch-line';
export const SwitchLine = () => {
const linesManager = useService(WorkflowLinesManager);
const switchLine = useCallback(() => {
linesManager.switchLineType();
}, [linesManager]);
return (
<Tooltip content={I18n.t('Switch Line')}>
<IconButton type="tertiary" theme="borderless" onClick={switchLine} icon={IconSwitchLine} />
</Tooltip>
);
};

View File

@ -0,0 +1,46 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useState } from 'react';
import { usePlayground, usePlaygroundTools } from '@flowgram.ai/free-layout-editor';
import { Divider, Dropdown } from '@douyinfe/semi-ui';
import { SelectZoom } from './styles';
export const ZoomSelect = () => {
const tools = usePlaygroundTools({ maxZoom: 2, minZoom: 0.25 });
const playground = usePlayground();
const [dropDownVisible, openDropDown] = useState(false);
return (
<Dropdown
position="top"
trigger="custom"
visible={dropDownVisible}
onClickOutSide={() => openDropDown(false)}
render={
<Dropdown.Menu>
<Dropdown.Item onClick={() => tools.zoomin()}>Zoom in</Dropdown.Item>
<Dropdown.Item onClick={() => tools.zoomout()}>Zoom out</Dropdown.Item>
<Divider layout="horizontal" />
<Dropdown.Item onClick={() => playground.config.updateZoom(0.5)}>
Zoom to 50%
</Dropdown.Item>
<Dropdown.Item onClick={() => playground.config.updateZoom(1)}>
Zoom to 100%
</Dropdown.Item>
<Dropdown.Item onClick={() => playground.config.updateZoom(1.5)}>
Zoom to 150%
</Dropdown.Item>
<Dropdown.Item onClick={() => playground.config.updateZoom(2.0)}>
Zoom to 200%
</Dropdown.Item>
</Dropdown.Menu>
}
>
<SelectZoom onClick={() => openDropDown(true)}>{Math.floor(tools.zoom * 100)}%</SelectZoom>
</Dropdown>
);
};