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:
29
frontend/src/flows/components/add-node/index.tsx
Normal file
29
frontend/src/flows/components/add-node/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
115
frontend/src/flows/components/add-node/use-add-node.ts
Normal file
115
frontend/src/flows/components/add-node/use-add-node.ts
Normal 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]
|
||||
);
|
||||
};
|
||||
45
frontend/src/flows/components/base-node/index.tsx
Normal file
45
frontend/src/flows/components/base-node/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
81
frontend/src/flows/components/base-node/node-wrapper.tsx
Normal file
81
frontend/src/flows/components/base-node/node-wrapper.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
};
|
||||
39
frontend/src/flows/components/base-node/styles.tsx
Normal file
39
frontend/src/flows/components/base-node/styles.tsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
23
frontend/src/flows/components/base-node/utils.ts
Normal file
23
frontend/src/flows/components/base-node/utils.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
120
frontend/src/flows/components/comment/components/border-area.tsx
Normal file
120
frontend/src/flows/components/comment/components/border-area.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
65
frontend/src/flows/components/comment/components/editor.tsx
Normal file
65
frontend/src/flows/components/comment/components/editor.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
108
frontend/src/flows/components/comment/components/index.css
Normal file
108
frontend/src/flows/components/comment/components/index.css
Normal 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);
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
import './index.css';
|
||||
|
||||
export { CommentRender } from './render';
|
||||
@ -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>
|
||||
);
|
||||
83
frontend/src/flows/components/comment/components/render.tsx
Normal file
83
frontend/src/flows/components/comment/components/render.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
25
frontend/src/flows/components/comment/constant.ts
Normal file
25
frontend/src/flows/components/comment/constant.ts
Normal 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 = '';
|
||||
6
frontend/src/flows/components/comment/hooks/index.ts
Normal file
6
frontend/src/flows/components/comment/hooks/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export { useSize } from './use-size';
|
||||
55
frontend/src/flows/components/comment/hooks/use-model.ts
Normal file
55
frontend/src/flows/components/comment/hooks/use-model.ts
Normal 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;
|
||||
};
|
||||
50
frontend/src/flows/components/comment/hooks/use-overflow.ts
Normal file
50
frontend/src/flows/components/comment/hooks/use-overflow.ts
Normal 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 };
|
||||
};
|
||||
168
frontend/src/flows/components/comment/hooks/use-size.ts
Normal file
168
frontend/src/flows/components/comment/hooks/use-size.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
6
frontend/src/flows/components/comment/index.ts
Normal file
6
frontend/src/flows/components/comment/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
export { CommentRender } from './components';
|
||||
111
frontend/src/flows/components/comment/model.ts
Normal file
111
frontend/src/flows/components/comment/model.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
29
frontend/src/flows/components/comment/type.ts
Normal file
29
frontend/src/flows/components/comment/type.ts
Normal 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;
|
||||
105
frontend/src/flows/components/group/color.ts
Normal file
105
frontend/src/flows/components/group/color.ts
Normal 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',
|
||||
},
|
||||
};
|
||||
@ -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'}`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
50
frontend/src/flows/components/group/components/color.tsx
Normal file
50
frontend/src/flows/components/group/components/color.tsx
Normal 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>
|
||||
);
|
||||
41
frontend/src/flows/components/group/components/header.tsx
Normal file
41
frontend/src/flows/components/group/components/header.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
7
frontend/src/flows/components/group/components/index.ts
Normal file
7
frontend/src/flows/components/group/components/index.ts
Normal 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';
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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);
|
||||
79
frontend/src/flows/components/group/components/tips/style.ts
Normal file
79
frontend/src/flows/components/group/components/tips/style.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
38
frontend/src/flows/components/group/components/title.tsx
Normal file
38
frontend/src/flows/components/group/components/title.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
19
frontend/src/flows/components/group/components/tools.tsx
Normal file
19
frontend/src/flows/components/group/components/tools.tsx
Normal 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>
|
||||
);
|
||||
36
frontend/src/flows/components/group/components/ungroup.tsx
Normal file
36
frontend/src/flows/components/group/components/ungroup.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
12
frontend/src/flows/components/group/constant.ts
Normal file
12
frontend/src/flows/components/group/constant.ts
Normal 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',
|
||||
}
|
||||
117
frontend/src/flows/components/group/index.css
Normal file
117
frontend/src/flows/components/group/index.css
Normal 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;
|
||||
}
|
||||
9
frontend/src/flows/components/group/index.ts
Normal file
9
frontend/src/flows/components/group/index.ts
Normal 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';
|
||||
10
frontend/src/flows/components/index.ts
Normal file
10
frontend/src/flows/components/index.ts
Normal 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';
|
||||
31
frontend/src/flows/components/line-add-button/button.tsx
Normal file
31
frontend/src/flows/components/line-add-button/button.tsx
Normal 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>
|
||||
);
|
||||
13
frontend/src/flows/components/line-add-button/index.less
Normal file
13
frontend/src/flows/components/line-add-button/index.less
Normal 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;
|
||||
}
|
||||
128
frontend/src/flows/components/line-add-button/index.tsx
Normal file
128
frontend/src/flows/components/line-add-button/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
28
frontend/src/flows/components/line-add-button/use-visible.ts
Normal file
28
frontend/src/flows/components/line-add-button/use-visible.ts
Normal 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;
|
||||
};
|
||||
116
frontend/src/flows/components/node-menu/index.tsx
Normal file
116
frontend/src/flows/components/node-menu/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
60
frontend/src/flows/components/node-panel/index.less
Normal file
60
frontend/src/flows/components/node-panel/index.less
Normal 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;
|
||||
}
|
||||
54
frontend/src/flows/components/node-panel/index.tsx
Normal file
54
frontend/src/flows/components/node-panel/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
102
frontend/src/flows/components/node-panel/node-list.tsx
Normal file
102
frontend/src/flows/components/node-panel/node-list.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
104
frontend/src/flows/components/selector-box-popover/index.tsx
Normal file
104
frontend/src/flows/components/selector-box-popover/index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
7
frontend/src/flows/components/sidebar/index.tsx
Normal file
7
frontend/src/flows/components/sidebar/index.tsx
Normal 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';
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
17
frontend/src/flows/components/sidebar/sidebar-provider.tsx
Normal file
17
frontend/src/flows/components/sidebar/sidebar-provider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
113
frontend/src/flows/components/sidebar/sidebar-renderer.tsx
Normal file
113
frontend/src/flows/components/sidebar/sidebar-renderer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
8
frontend/src/flows/components/testrun/hooks/index.ts
Normal file
8
frontend/src/flows/components/testrun/hooks/index.ts
Normal 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';
|
||||
50
frontend/src/flows/components/testrun/hooks/use-fields.ts
Normal file
50
frontend/src/flows/components/testrun/hooks/use-fields.ts
Normal 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;
|
||||
};
|
||||
62
frontend/src/flows/components/testrun/hooks/use-form-meta.ts
Normal file
62
frontend/src/flows/components/testrun/hooks/use-form-meta.ts
Normal 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;
|
||||
};
|
||||
@ -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]);
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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} />;
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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
|
||||
}
|
||||
@ -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)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
138
frontend/src/flows/components/testrun/testrun-form/index.tsx
Normal file
138
frontend/src/flows/components/testrun/testrun-form/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
21
frontend/src/flows/components/testrun/testrun-form/type.ts
Normal file
21
frontend/src/flows/components/testrun/testrun-form/type.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
211
frontend/src/flows/components/testrun/testrun-panel/index.tsx
Normal file
211
frontend/src/flows/components/testrun/testrun-panel/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
32
frontend/src/flows/components/tools/auto-layout.tsx
Normal file
32
frontend/src/flows/components/tools/auto-layout.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
85
frontend/src/flows/components/tools/comment.tsx
Normal file
85
frontend/src/flows/components/tools/comment.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
23
frontend/src/flows/components/tools/fit-view.tsx
Normal file
23
frontend/src/flows/components/tools/fit-view.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
211
frontend/src/flows/components/tools/index.tsx
Normal file
211
frontend/src/flows/components/tools/index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
102
frontend/src/flows/components/tools/interactive.tsx
Normal file
102
frontend/src/flows/components/tools/interactive.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
27
frontend/src/flows/components/tools/minimap-switch.tsx
Normal file
27
frontend/src/flows/components/tools/minimap-switch.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
38
frontend/src/flows/components/tools/minimap.tsx
Normal file
38
frontend/src/flows/components/tools/minimap.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
117
frontend/src/flows/components/tools/mouse-pad-selector.less
Normal file
117
frontend/src/flows/components/tools/mouse-pad-selector.less
Normal 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%);
|
||||
}
|
||||
}
|
||||
122
frontend/src/flows/components/tools/mouse-pad-selector.tsx
Normal file
122
frontend/src/flows/components/tools/mouse-pad-selector.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
37
frontend/src/flows/components/tools/readonly.tsx
Normal file
37
frontend/src/flows/components/tools/readonly.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
74
frontend/src/flows/components/tools/save.tsx
Normal file
74
frontend/src/flows/components/tools/save.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
frontend/src/flows/components/tools/styles.tsx
Normal file
52
frontend/src/flows/components/tools/styles.tsx
Normal 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')};
|
||||
`;
|
||||
25
frontend/src/flows/components/tools/switch-line.tsx
Normal file
25
frontend/src/flows/components/tools/switch-line.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
46
frontend/src/flows/components/tools/zoom-select.tsx
Normal file
46
frontend/src/flows/components/tools/zoom-select.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user