feat: manually mirror opencoze's code from bytedance

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

View File

@@ -0,0 +1,44 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { LINE_OFFSET } from '../../../constants/lines';
export default function ArrowRenderer({
id,
pos,
strokeWidth,
}: {
id: string;
strokeWidth: number;
pos: {
x: number;
y: number;
};
}) {
return (
<path
d={`M ${pos.x - LINE_OFFSET},${pos.y - LINE_OFFSET} L ${pos.x},${
pos.y
} L ${pos.x - LINE_OFFSET},${pos.y + LINE_OFFSET}`}
strokeLinecap="round"
stroke={`url(#${id})`}
fill="none"
strokeWidth={strokeWidth}
/>
);
}

View File

@@ -0,0 +1,122 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import {
type WorkflowLineEntity,
POINT_RADIUS,
WorkflowLineRenderData,
} from '@flowgram-adapter/free-layout-editor';
import { type IPoint } from '@flowgram-adapter/common';
import WithPopover from '../popover/with-popover';
import styles from '../index.module.less';
import ArrowRenderer from '../arrow';
import { STROKE_WIDTH_SLECTED, STROKE_WIDTH } from '../../../constants/points';
// import { AddPoint } from '@/components/add-point';
const PADDING = 12;
export interface BezierLineProps {
fromColor?: string;
toColor?: string;
color?: string; // 高亮颜色,优先级最高
selected?: boolean;
showControlPoints?: boolean;
line: WorkflowLineEntity;
version: string; // 用于控制 memo 刷新
}
export const BezierLineRender = React.memo(
WithPopover((props: BezierLineProps) => {
const { line, color, fromColor, toColor, selected } = props;
const renderData = line.getData(WorkflowLineRenderData);
const { bounds: bbox } = renderData;
const { position } = line;
// 相对位置
const toRelative = (p: IPoint) => ({
x: p.x - bbox.x + PADDING,
y: p.y - bbox.y + PADDING,
});
const fromPos = toRelative(position.from);
const toPos = toRelative(position.to);
// 真正连接线需要到的点的位置
const arrowToPos = {
x: toPos.x - POINT_RADIUS,
y: toPos.y,
};
const linearStartColor = fromPos.x < arrowToPos.x ? fromColor : toColor;
const linerarEndColor = fromPos.x < arrowToPos.x ? toColor : fromColor;
const strokeWidth = selected ? STROKE_WIDTH_SLECTED : STROKE_WIDTH;
const path = (
<path
d={renderData.path}
fill="none"
stroke={`url(#${line.id})`}
strokeWidth={strokeWidth}
className={line.processing ? styles.processingLine : ''}
/>
);
// const cls = clx('gedit-mindmap-line', {
// hovered,
// drawing,
// processing: props.processing
// });
return (
<>
<div
className="gedit-flow-activity-edge"
style={{
left: bbox.x - PADDING,
top: bbox.y - PADDING,
position: 'absolute',
}}
>
<svg
width={bbox.width + PADDING * 2}
height={bbox.height + PADDING * 2}
>
<defs>
<linearGradient
x1="0%"
y1="100%"
x2="100%"
y2="100%"
id={line.id}
gradientUnits="userSpaceOnUse"
>
<stop stopColor={color || linearStartColor} offset="0%" />
<stop stopColor={color || linerarEndColor} offset="100%" />
</linearGradient>
</defs>
<g>
{path}
<ArrowRenderer
id={line.id}
pos={arrowToPos}
strokeWidth={strokeWidth}
/>
</g>
</svg>
</div>
</>
);
}),
);

View File

@@ -0,0 +1,117 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import {
POINT_RADIUS,
WorkflowLineRenderData,
} from '@flowgram-adapter/free-layout-editor';
import WithPopover from '../popover/with-popover';
import styles from '../index.module.less';
import { type BezierLineProps } from '../bezier-line';
import ArrowRenderer from '../arrow';
import { STROKE_WIDTH, STROKE_WIDTH_SLECTED } from '../../../constants/points';
/**
* 折叠线
*/
export const FoldLineRender = React.memo(
WithPopover((props: BezierLineProps) => {
const { selected, color, line } = props;
const { to } = line.position;
const strokeWidth = selected ? STROKE_WIDTH_SLECTED : STROKE_WIDTH;
// 真正连接线需要到的点的位置
const arrowToPos = {
x: to.x - POINT_RADIUS,
y: to.y,
};
const renderData = line.getData(WorkflowLineRenderData);
// const bounds = line.bezier.foldBounds
// const points = line.bezier.foldPoints
//
// const debug = (
// <>
// <div
// style={{
// left: bounds.left,
// top: bounds.top,
// width: bounds.width,
// height: bounds.height,
// position: 'absolute',
// background: 'red',
// zIndex: 1000,
// opacity: 0.3,
// }}
// />
// {points.map((p, i) => (
// <div
// key={i}
// style={{
// left: p.x,
// top: p.y,
// width: 10,
// height: 10,
// marginTop: -5,
// marginBottom: -5,
// position: 'absolute',
// background: 'blue',
// zIndex: 1000,
// }}
// />
// ))}
// </>
// )
return (
<div
className="gedit-flow-activity-edge"
style={{ position: 'absolute' }}
>
<svg overflow="visible">
<defs>
<linearGradient
x1="0%"
y1="100%"
x2="100%"
y2="100%"
id={line.id}
gradientUnits="userSpaceOnUse"
>
<stop stopColor={color} offset="0%" />
<stop stopColor={color} offset="100%" />
</linearGradient>
</defs>
<g>
<path
d={renderData.path}
fill="none"
strokeLinecap="round"
stroke={color}
strokeWidth={strokeWidth}
className={line.processing ? styles.processingLine : ''}
/>
<ArrowRenderer
id={line.id}
pos={arrowToPos}
strokeWidth={strokeWidth}
/>
</g>
</svg>
</div>
);
}),
);

View File

@@ -0,0 +1,10 @@
@keyframes dashdraw {
from {
stroke-dashoffset: 10;
}
}
.processing-line {
stroke-dasharray: 5;
animation: dashdraw 0.5s linear infinite;
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { FlowRendererRegistry } from '@flowgram-adapter/free-layout-editor';
import {
useService,
WorkflowHoverService,
} from '@flowgram-adapter/free-layout-editor';
const LINE_POPOVER = 'line-popover';
export default function WithPopover(Component) {
return function WrappedComponent(props) {
const hoverService = useService<WorkflowHoverService>(WorkflowHoverService);
const renderRegistry =
useService<FlowRendererRegistry>(FlowRendererRegistry);
const Popover =
renderRegistry.tryToGetRendererComponent(LINE_POPOVER)?.renderer;
const { line } = props;
const isHovered = hoverService.isHovered(line._id);
return (
<>
<Component {...props} />
{Popover ? <Popover line={line} isHovered={isHovered} /> : null}
</>
);
};
}

View File

@@ -0,0 +1,17 @@
.selector-bounds-background {
cursor: move;
display: none;
// background: rgba(255, 255, 255, 0);
}
.selector-bounds-forground {
cursor: move;
position: absolute;
left: 0;
top: 0;
width: 0;
height: 0;
outline: 1px solid var(--g-playground-selectBox-outline);
// 在节点的上边
z-index: 33;
background-color: var(--g-playground-selectBox-background);
}

View File

@@ -0,0 +1,52 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
// import { domUtils } from '@flowgram-adapter/common';
import { type FlowSelectorBoundsLayerOptions } from '@flowgram-adapter/free-layout-editor';
import { SelectionService, useService } from '@flowgram-adapter/free-layout-editor';
import { getSelectionBounds } from '../../utils/selection-utils';
import styles from './index.module.less';
/**
* 选择框
* @param props
* @constructor
*/
export const SelectorBounds: React.FC<
FlowSelectorBoundsLayerOptions
> = props => {
const selectService = useService<SelectionService>(SelectionService);
const bounds = getSelectionBounds(selectService, true);
if (bounds.width === 0 || bounds.height === 0) {
// domUtils.setStyle(domNode, {
// display: 'none',
// });
return <></>;
}
const style = {
display: 'block',
left: bounds.left,
top: bounds.top,
width: bounds.width,
height: bounds.height,
};
// domUtils.setStyle(domNode, style);
return <div className={styles.selectorBoundsForground} style={style} />;
};

View File

@@ -0,0 +1,28 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import styles from './index.module.less';
// demo 环境自绘 cross-hair正式环境使用 IconAdd
export default function CrossHair(): JSX.Element {
return (
<div className={styles.symbol}>
<div className={styles.crossHair} />
</div>
);
}

View File

@@ -0,0 +1,157 @@
/* stylelint-disable selector-class-pattern */
// 背景的白色圆圈
.workflow-point {
position: absolute;
top: 50%;
left: 50%;
width: 20px;
height: 20px;
margin-top: -10px;
margin-left: -10px;
// 非 hover 状态下的样式
border: none;
border-radius: 50%;
& > .symbol {
opacity: 0;
}
.bg-circle {
position: absolute;
transform: scale(0.5);
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
background-color: #fff;
border-radius: 50%;
transition: all 0.2s linear 0s;
}
.bg {
position: relative;
transform: scale(0.4, 0.4);
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
background: #9197f1;
border-radius: 50%;
transition: all 0.2s linear 0s;
&.hasError {
background: red;
}
.symbol {
pointer-events: none;
position: absolute;
width: 14px;
height: 14px;
color: #fff;
opacity: 0;
transition: opacity 0.2s linear 0s;
& > svg {
width: 14px;
height: 14px;
}
}
.focus-circle {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
width: 8px;
height: 8px;
opacity: 0;
background: #9197f1;
border-radius: 50%;
transition: opacity 0.2s linear 0s;
}
}
&.linked .bg:not(.hasError) {
background: #4d53e8;
}
&.hovered .bg:not(.hasError) {
cursor: crosshair;
transform: scale(1, 1);
background: #4d53e8;
border: none;
& > .symbol {
opacity: 1;
}
}
&:hover .bg:is(.hasError) {
transform: scale(1, 1);
background: red;
border: none;
& > .symbol {
opacity: 1;
}
}
}
.cross-hair {
position: relative;
top: 2px;
left: 2px;
&::after,
&::before {
content: '';
position: absolute;
background: #fff;
border-radius: 2px;
}
&::after {
left: 4px;
width: 2px;
height: 6px;
box-shadow: 0 4px #fff;
}
&::before {
top: 4px;
width: 6px;
height: 2px;
box-shadow: 4px 0 #fff;
}
}
.warning {
display: flex;
align-items: center;
justify-content: center;
}
.tooltip {
transform: translateY(-8px);
}

View File

@@ -0,0 +1,167 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import ReactDOM from 'react-dom';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import classNames from 'classnames';
import { Tooltip } from '@coze-arch/coze-design';
import { useService } from '@flowgram-adapter/free-layout-editor';
import {
usePlaygroundReadonlyState,
WorkflowDragService,
WorkflowHoverService,
WorkflowLinesManager,
type WorkflowPortEntity,
} from '@flowgram-adapter/free-layout-editor';
import { PORT_BG_CLASS_NAME } from '../../constants/points';
import { Warning } from './warning';
import CrossHair from './cross-hair';
import styles from './index.module.less';
export interface WorkflowPortRenderProps {
entity: WorkflowPortEntity;
onClick?: (event: React.MouseEvent, port: WorkflowPortEntity) => void;
}
export const WorkflowPortRender: React.FC<WorkflowPortRenderProps> =
React.memo<WorkflowPortRenderProps>(props => {
const dragService = useService<WorkflowDragService>(WorkflowDragService);
const hoverService = useService<WorkflowHoverService>(WorkflowHoverService);
const linesManager = useService<WorkflowLinesManager>(WorkflowLinesManager);
const { entity, onClick } = props;
const { portType, portID, relativePosition, disabled, errorMessage } =
entity as WorkflowPortEntity & {
errorMessage?: string;
};
const [targetElement, setTargetElement] = useState(entity.targetElement);
const [posX, updatePosX] = useState(relativePosition.x);
const [posY, updatePosY] = useState(relativePosition.y);
const [hovered, setHovered] = useState(false);
const [linked, setLinked] = useState(Boolean(entity?.lines?.length));
const [hasError, setHasError] = useState(props.entity.hasError);
const readonly = usePlaygroundReadonlyState();
const onMouseDown = useCallback(
(e: React.MouseEvent) => {
const isMouseCenterButton = e.button === 1;
if (portType === 'input' || disabled || isMouseCenterButton) {
return;
}
e.stopPropagation();
e.preventDefault();
dragService.startDrawingLine(entity, e);
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- custom
[dragService, portType, portID],
);
useEffect(() => {
// useEffect 时序问题可能导致 port.hasError 非最新,需重新触发一次 validate
props.entity.validate();
setHasError(props.entity.hasError);
const dispose = props.entity.onEntityChange(() => {
// 如果有挂载的节点,不需要更新位置信息
if (entity.targetElement) {
if (entity.targetElement !== targetElement) {
setTargetElement(entity.targetElement);
}
return;
}
const newPos = props.entity.relativePosition;
// 加上 round 避免点位抖动
updatePosX(Math.round(newPos.x));
updatePosY(Math.round(newPos.y));
});
const dispose2 = hoverService.onHoveredChange(id => {
setHovered(hoverService.isHovered(entity.id));
});
const dispose3 = props.entity.onErrorChanged(() => {
setHasError(props.entity.hasError);
});
const dispose4 = linesManager.onAvailableLinesChange(() => {
setTimeout(() => {
setLinked(Boolean(entity?.lines?.length));
}, 0);
});
return () => {
dispose.dispose();
dispose2.dispose();
dispose3.dispose();
dispose4.dispose();
};
}, [props.entity, hoverService, entity, targetElement, linesManager]);
// 监听变化
const className = classNames(styles.workflowPoint, {
[styles.hovered]:
!readonly && hovered && !disabled && portType !== 'input',
// 有线条链接的时候深蓝色小圆点
[styles.linked]: linked,
});
const icon = useMemo(() => {
const iconComp = (
<div
className={classNames({
[styles.bg]: true,
[PORT_BG_CLASS_NAME]: true,
'workflow-point-bg': true,
[styles.hasError]: hasError,
})}
>
{hasError ? <Warning /> : <CrossHair />}
</div>
);
if (hasError && errorMessage) {
return (
<Tooltip
className={styles.tooltip}
content={errorMessage}
trigger="hover"
position="top"
>
{iconComp}
</Tooltip>
);
}
return iconComp;
}, [hasError, errorMessage]);
const content = (
<div
className={className}
style={targetElement ? undefined : { left: posX, top: posY }}
onClick={e => onClick?.(e, entity)}
onMouseDown={onMouseDown}
data-port-entity-id={entity.id}
data-testid="bot-edit-multi-agent-flow-node-add-button"
>
<div
className={classNames(styles.bgCircle, 'workflow-bg-circle')}
></div>
{icon}
<div className={styles['focus-circle']} />
</div>
);
if (targetElement) {
return ReactDOM.createPortal(content, targetElement);
}
return content;
});

View File

@@ -0,0 +1,46 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import classNames from 'classnames';
import styles from './index.module.less';
export const Warning = () => (
<div className={classNames(styles.symbol, styles.warning)}>
<svg
style={{ width: 10, height: 10 }}
width="24"
height="24"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
id="path1"
fill="#ffffff"
stroke="none"
d="M 12 0 C 10.674479 0 9.6 1.074528 9.6 2.4 L 9.6 14.4 C 9.6 15.725521 10.674479 16.799999 12 16.799999 C 13.325521 16.799999 14.4 15.725521 14.4 14.4 L 14.4 2.4 C 14.4 1.074528 13.325521 0 12 0 Z"
/>
<path
id="path2"
fill="#ffffff"
stroke="none"
d="M 12 19.200001 C 10.674479 19.200001 9.6 20.274479 9.6 21.6 C 9.6 22.925518 10.674479 24 12 24 C 13.325521 24 14.4 22.925518 14.4 21.6 C 14.4 20.274479 13.325521 19.200001 12 19.200001 Z"
/>
</svg>
</div>
);

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,224 @@
/* stylelint-disable block-no-empty */
/* stylelint-disable plugin/disallow-first-level-global */
.playground-load {}
:root {
--g-selection-background: #4D53E8;
--g-editor-background: #f2f3f5;
--g-playground-select: var(--g-selection-background);
--g-playground-hover: var(--g-selection-background);
--g-playground-line: var(--g-selection-background);
--g-playground-blur: #999;
--g-playground-selectBox-outline: var(--g-selection-background);
--g-playground-selectBox-background: rgba(141, 144, 231, 10%);
--g-playground-select-hover-background: rgba(77, 83, 232, 10%);
--g-playground-select-control-size: 12px;
}
:global {
.gedit-playground {
user-select: none;
position: absolute;
z-index: 10;
top: 0;
left: 0;
overflow: hidden;
box-sizing: border-box;
width: 100%;
height: 100%;
background-color: var(--g-editor-background);
outline: none;
}
.gedit-playground-scroll-right {
position: absolute;
z-index: 10;
right: 2px;
width: 7px;
height: 100vh;
}
.gedit-playground-scroll-bottom {
position: absolute;
z-index: 10;
bottom: 2px;
width: 100vw;
height: 7px;
}
.gedit-playground-scroll-right-block {
position: absolute;
opacity: 0.3;
border-radius: 3.5px;
}
.gedit-playground-scroll-right-block:hover {
opacity: 0.3;
}
.gedit-playground-scroll-bottom-block {
position: absolute;
opacity: 0.3;
border-radius: 3.5px;
}
.gedit-playground-scroll-bottom-block:hover {
opacity: 0.3;
}
.gedit-playground-scroll-hidden {
opacity: 0;
}
.gedit-playground * {
box-sizing: border-box;
}
.gedit-playground-loading {
position: absolute;
z-index: 100;
top: 50%;
left: 50%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: white;
text-align: center;
opacity: 0.8;
transition: opacity 0.8s;
}
.gedit-hidden {
display: none;
}
.gedit-playground-pipeline {
position: absolute;
top: 0;
left: 0;
overflow: visible;
width: 100%;
height: 100%;
}
.gedit-playground-pipeline::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 1px;
height: 100%;
}
.gedit-playground-layer {
position: absolute;
overflow: visible;
}
.gedit-selector-box {
position: absolute;
z-index: 33;
top: 0;
left: 0;
width: 0;
height: 0;
background-color: var(--g-playground-selectBox-background);
outline: 1px solid var(--g-playground-selectBox-outline);
}
.gedit-selector-box-block {
position: absolute;
z-index: 9999;
top: 0;
left: 0;
display: none;
width: 0;
height: 0;
background-color: rgba(0, 0, 0, 0%);
}
.gedit-selector-bounds-background {
position: absolute;
top: 0;
left: 0;
width: 0;
height: 0;
background-color: #f0f4ff;
outline: 1px solid var(--g-playground-selectBox-outline);
}
.gedit-selector-bounds-foreground {
position: absolute;
z-index: 33;
top: 0;
left: 0;
width: 0;
height: 0;
background: rgba(255, 255, 255, 0%);
}
.gedit-flow-activity-node {
position: absolute;
&:hover {
:global {
.workflow-point-bg {
transform: scale(0.7, 0.7);
background: #4d53e8;
}
.workflow-bg-circle {
transform: scale(1, 1);
}
}
}
&:active {
:global {
.workflow-point-bg {
transform: scale(0.7, 0.7);
background: #4d53e8;
}
.workflow-bg-circle {
transform: scale(1, 1);
}
}
}
}
.gedit-grid-svg {
position: absolute;
top: 20px;
left: 20px;
display: block;
width: 0;
height: 0;
}
}

View File

@@ -0,0 +1,27 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import 'reflect-metadata';
export * from './workflow-render-provider';
export * from './workflow-render-contribution';
export * from './components/workflow-port-render';
export * from './workflow-shorcuts-contribution';
export {
FlowRendererKey,
FlowRendererRegistry,
FlowRendererContribution,
} from '@flowgram-adapter/free-layout-editor';
export * from './constants/lines';

View File

@@ -0,0 +1,161 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { nanoid } from 'nanoid';
import {
Layer,
observeEntity,
PlaygroundConfigEntity,
SCALE_WIDTH,
} from '@flowgram-adapter/free-layout-editor';
import { domUtils } from '@flowgram-adapter/common';
interface BackgroundScaleUnit {
realSize: number;
renderSize: number;
zoom: number;
}
const PATTERN_ID = 'grid-dot-pattern';
const RENDER_SIZE = 20;
const DOT_SIZE = 1;
/**
* dot 网格背景
*/
export class BackgroundLayer extends Layer {
static type = 'WorkflowBackgroundLayer';
@observeEntity(PlaygroundConfigEntity)
protected playgroundConfigEntity: PlaygroundConfigEntity;
protected patternId = `${PATTERN_ID}${nanoid()}`;
node = domUtils.createDivWithClass('gedit-flow-background-layer');
grid: HTMLElement = document.createElement('div');
/**
* 当前缩放比
*/
get zoom(): number {
return this.config.finalScale;
}
onReady() {
const { firstChild } = this.pipelineNode;
// 背景插入到最下边
this.pipelineNode.insertBefore(this.node, firstChild);
// 初始化设置最大 200% 最小 10% 缩放
this.playgroundConfigEntity.updateConfig({
minZoom: 0.1,
maxZoom: 2,
});
// 确保点的位置在线条的下方
this.grid.style.zIndex = '-1';
this.grid.style.position = 'relative';
this.node.appendChild(this.grid);
this.grid.className = 'gedit-grid-svg';
}
/**
* 最小单元格大小
*/
getScaleUnit(): BackgroundScaleUnit {
const { zoom } = this;
return {
realSize: RENDER_SIZE, // 一个单元格代表的真实大小
renderSize: Math.round(RENDER_SIZE * zoom * 100) / 100, // 一个单元格渲染的大小值
zoom, // 缩放比
};
}
/**
* 绘制
*/
autorun(): void {
const playgroundConfig = this.playgroundConfigEntity.config;
const scaleUnit = this.getScaleUnit();
const mod = scaleUnit.renderSize * 10;
const viewBoxWidth = playgroundConfig.width + mod * 2;
const viewBoxHeight = playgroundConfig.height + mod * 2;
const { scrollX } = playgroundConfig;
const { scrollY } = playgroundConfig;
const scrollXDelta = this.getScrollDelta(scrollX, mod);
const scrollYDelta = this.getScrollDelta(scrollY, mod);
domUtils.setStyle(this.node, {
left: scrollX - SCALE_WIDTH,
top: scrollY - SCALE_WIDTH,
});
this.drawGrid(scaleUnit);
// 设置网格
this.setSVGStyle(this.grid, {
width: viewBoxWidth,
height: viewBoxHeight,
left: SCALE_WIDTH - scrollXDelta - mod,
top: SCALE_WIDTH - scrollYDelta - mod,
});
}
/**
* 绘制网格
*/
protected drawGrid(unit: BackgroundScaleUnit): void {
const minor = unit.renderSize;
if (!this.grid) {
return;
}
const patternSize = DOT_SIZE * this.zoom;
const newContent = `
<svg width="100%" height="100%">
<pattern id="${this.patternId}" width="${minor}" height="${minor}" patternUnits="userSpaceOnUse">
<circle
cx="${patternSize}"
cy="${patternSize}"
r="${patternSize}"
stroke="#eceeef"
fill-opacity="0.5"
/>
</pattern>
<rect width="100%" height="100%" fill="url(#${this.patternId})"/>
</svg>`;
this.grid.innerHTML = newContent;
}
protected setSVGStyle(
svgElement: HTMLElement | undefined,
style: { width: number; height: number; left: number; top: number },
): void {
if (!svgElement) {
return;
}
svgElement.style.width = `${style.width}px`;
svgElement.style.height = `${style.height}px`;
svgElement.style.left = `${style.left}px`;
svgElement.style.top = `${style.top}px`;
}
/**
* 获取相对滚动距离
* @param realScroll
* @param mod
*/
protected getScrollDelta(realScroll: number, mod: number): number {
// 正向滚动不用补差
if (realScroll >= 0) {
return realScroll % mod;
}
return mod - (Math.abs(realScroll) % mod);
}
}

View File

@@ -0,0 +1,309 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable complexity */
import { inject, injectable } from 'inversify';
import { SelectorBoxConfigEntity } from '@flowgram-adapter/free-layout-editor';
import { FlowNodeTransformData } from '@flowgram-adapter/free-layout-editor';
import {
EditorState,
EditorStateConfigEntity,
Layer,
PlaygroundConfigEntity,
observeEntities,
observeEntity,
observeEntityDatas,
type LayerOptions,
} from '@flowgram-adapter/free-layout-editor';
import {
WorkflowDocument,
WorkflowDragService,
WorkflowHoverService,
WorkflowLineEntity,
WorkflowLinesManager,
WorkflowNodeEntity,
WorkflowPortEntity,
WorkflowSelectService,
} from '@flowgram-adapter/free-layout-editor';
import { type IPoint } from '@flowgram-adapter/common';
import { getSelectionBounds } from '../utils/selection-utils';
import { PORT_BG_CLASS_NAME } from '../constants/points';
export interface HoverLayerOptions extends LayerOptions {
canHovered?: (e: MouseEvent, service: WorkflowHoverService) => boolean;
}
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace HoverLayerOptions {
export const DEFAULT: HoverLayerOptions = {
canHovered: () => true,
};
}
const LINE_CLASS_NAME = '.gedit-flow-activity-line';
const NODE_CLASS_NAME = '.gedit-flow-activity-node';
@injectable()
export class HoverLayer extends Layer<HoverLayerOptions> {
static type = 'HoverLayer';
@inject(WorkflowDocument) document: WorkflowDocument;
@inject(WorkflowSelectService) selectionService: WorkflowSelectService;
@inject(WorkflowDragService) dragService: WorkflowDragService;
@inject(WorkflowHoverService) hoverService: WorkflowHoverService;
@inject(WorkflowLinesManager)
linesManager: WorkflowLinesManager;
@observeEntity(EditorStateConfigEntity)
protected editorStateConfig: EditorStateConfigEntity;
@observeEntity(SelectorBoxConfigEntity)
protected selectorBoxConfigEntity: SelectorBoxConfigEntity;
@inject(PlaygroundConfigEntity) configEntity: PlaygroundConfigEntity;
/**
* 监听节点 transform
*/
@observeEntityDatas(WorkflowNodeEntity, FlowNodeTransformData)
protected readonly nodeTransforms: FlowNodeTransformData[];
/**
* 按选中排序
* @private
*/
protected nodeTransformsWithSort: FlowNodeTransformData[] = [];
autorun(): void {
const { activatedNode } = this.selectionService;
this.nodeTransformsWithSort = this.nodeTransforms
.filter(n => n.entity.id !== 'root')
.reverse() // 后创建的排在前面
.sort(n1 => (n1.entity === activatedNode ? -1 : 0));
}
/**
* 监听线条
*/
@observeEntities(WorkflowLineEntity)
protected readonly lines: WorkflowLineEntity[];
/**
* 是否正在调整线条
* @protected
*/
get isDrawing(): boolean {
return this.linesManager.isDrawing;
}
onReady(): void {
this.options = {
...HoverLayerOptions.DEFAULT,
...this.options,
};
this.toDispose.pushAll([
// 监听画布鼠标移动事件
this.listenPlaygroundEvent('mousemove', (e: MouseEvent) => {
this.hoverService.hoveredPos = this.config.getPosFromMouseEvent(e);
if (!this.isEnabled()) {
return;
}
// @ts-expect-error -- linter-disable-autofix
if (!this.options.canHovered(e, this.hoverService)) {
return;
}
const mousePos = this.config.getPosFromMouseEvent(e);
// 更新 hover 状态
this.updateHoveredState(mousePos, e?.target as HTMLElement);
}),
this.selectionService.onSelectionChanged(() => this.autorun()),
// 控制选中逻辑
this.listenPlaygroundEvent(
'mousedown',
(e: MouseEvent): boolean | undefined => {
if (!this.isEnabled() || this.isDrawing) {
return undefined;
}
const { hoveredNode } = this.hoverService;
// 重置线条
if (hoveredNode && hoveredNode instanceof WorkflowLineEntity) {
this.dragService.resetLine(hoveredNode, e);
return true;
}
if (
hoveredNode &&
hoveredNode instanceof WorkflowPortEntity &&
hoveredNode.portType !== 'input' &&
!hoveredNode.disabled &&
e.button !== 1
) {
e.stopPropagation();
e.preventDefault();
this.selectionService.clear();
this.dragService.startDrawingLine(hoveredNode, e);
return true;
}
const mousePos = this.config.getPosFromMouseEvent(e);
const selectionBounds = getSelectionBounds(
this.selectionService,
// 这里只考虑多选模式,单选模式已经下沉到 use-node-render 中
true,
);
if (
selectionBounds.width > 0 &&
selectionBounds.contains(mousePos.x, mousePos.y)
) {
/**
* 拖拽选择框
*/
this.dragService.startDragSelectedNodes(e).then(dragSuccess => {
if (!dragSuccess) {
// 拖拽没有成功触发了点击
if (hoveredNode && hoveredNode instanceof WorkflowNodeEntity) {
if (e.metaKey || e.shiftKey || e.ctrlKey) {
this.selectionService.toggleSelect(hoveredNode);
} else {
this.selectionService.selectNode(hoveredNode);
}
} else {
this.selectionService.clear();
}
}
});
// 这里会组织触发 selector box
return true;
} else {
if (!hoveredNode) {
this.selectionService.clear();
}
}
return undefined;
},
),
]);
}
/**
* 更新 hoverd
* @param mousePos
*/
updateHoveredState(mousePos: IPoint, target?: HTMLElement): void {
const nodeTransforms = this.nodeTransformsWithSort;
// // 判断连接点是否 hover
const portHovered = this.linesManager.getPortFromMousePos(mousePos);
const lineDomNodes = this.playgroundNode.querySelectorAll(LINE_CLASS_NAME);
const checkTargetFromLine = [...lineDomNodes].some(lineDom =>
lineDom.contains(target as HTMLElement),
);
// 默认 只有 output 点位可以 hover
if (portHovered) {
// 输出点可以直接选中
if (portHovered.portType === 'output') {
this.updateHoveredKey(portHovered.id);
} else if (
checkTargetFromLine ||
target?.className?.includes?.(PORT_BG_CLASS_NAME)
) {
// 输入点采用获取最接近的线条
const lineHovered =
this.linesManager.getCloseInLineFromMousePos(mousePos);
if (lineHovered) {
this.updateHoveredKey(lineHovered.id);
}
}
return;
}
// Drawing 情况,不能选中节点和线条
if (this.isDrawing) {
return;
}
const nodeHovered = nodeTransforms.find((trans: FlowNodeTransformData) =>
trans.bounds.contains(mousePos.x, mousePos.y),
)?.entity as WorkflowNodeEntity;
// 判断当前鼠标位置所在元素是否在节点内部
const nodeDomNodes = this.playgroundNode.querySelectorAll(NODE_CLASS_NAME);
const checkTargetFromNode = [...nodeDomNodes].some(nodeDom =>
nodeDom.contains(target as HTMLElement),
);
if (nodeHovered || checkTargetFromNode) {
if (nodeHovered?.id) {
this.updateHoveredKey(nodeHovered.id);
}
}
const nodeInContainer = !!(
nodeHovered?.parent && nodeHovered.parent.flowNodeType !== 'root'
);
// 获取最接近的线条
// 线条会相交需要获取最接近点位的线条,不能删除的线条不能被选中
const lineHovered = checkTargetFromLine
? this.linesManager.getCloseInLineFromMousePos(mousePos)
: undefined;
const lineInContainer = !!lineHovered?.inContainer;
// 判断容器内节点是否 hover
if (nodeHovered && nodeInContainer) {
this.updateHoveredKey(nodeHovered.id);
return;
}
// 判断容器内线条是否 hover
if (lineHovered && lineInContainer) {
this.updateHoveredKey(lineHovered.id);
return;
}
// 判断节点是否 hover
if (nodeHovered) {
this.updateHoveredKey(nodeHovered.id);
return;
}
// 判断线条是否 hover
if (lineHovered) {
this.hoverService.updateHoveredKey(lineHovered.id);
return;
}
// 上述逻辑都未命中 则清空 hoverd
this.hoverService.clearHovered();
const currentState = this.editorStateConfig.getCurrentState();
const isMouseFriendly =
currentState === EditorState.STATE_MOUSE_FRIENDLY_SELECT;
// 鼠标优先,并且不是按住 shift 键,更新为小手
if (isMouseFriendly && !this.editorStateConfig.isPressingShift) {
this.configEntity.updateCursor('grab');
}
}
updateHoveredKey(key: string): void {
// 鼠标优先交互模式,如果是 hover需要将鼠标的小手去掉还原鼠标原有样式
this.configEntity.updateCursor('default');
this.hoverService.updateHoveredKey(key);
}
/**
* 判断是否能够 hover
* @returns 是否能 hover
*/
isEnabled(): boolean {
const currentState = this.editorStateConfig.getCurrentState();
// 选择框情况禁止 hover
return (
(currentState === EditorState.STATE_SELECT ||
currentState === EditorState.STATE_MOUSE_FRIENDLY_SELECT) &&
!this.selectorBoxConfigEntity.isStart &&
!this.dragService.isDragging
);
}
}

View File

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

View File

@@ -0,0 +1,233 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import ReactDOM from 'react-dom';
import React from 'react';
import { inject, injectable } from 'inversify';
import {
Layer,
observeEntities,
observeEntityDatas,
TransformData,
} from '@flowgram-adapter/free-layout-editor';
import {
LineColors,
LineType,
WorkflowDocument,
WorkflowHoverService,
WorkflowLineEntity,
WorkflowLineRenderData,
WorkflowNodeEntity,
WorkflowPortEntity,
WorkflowSelectService,
} from '@flowgram-adapter/free-layout-editor';
import { domUtils } from '@flowgram-adapter/common';
import { FoldLineRender } from '../components/lines/fold-line';
import { BezierLineRender } from '../components/lines/bezier-line';
const errorActiveColor = '#FF5DC8';
@injectable()
export class LinesLayer extends Layer {
static type = 'WorkflowLinesLayer';
@inject(WorkflowHoverService) hoverService: WorkflowHoverService;
@inject(WorkflowSelectService) selectService: WorkflowSelectService;
// @observeEntity(FlowDocumentTransformerEntity)
// readonly documentTransformer: FlowDocumentTransformerEntity
@observeEntities(WorkflowLineEntity) readonly lines: WorkflowLineEntity[];
@observeEntities(WorkflowPortEntity) readonly ports: WorkflowPortEntity[];
@observeEntityDatas(WorkflowNodeEntity, TransformData)
readonly trans: TransformData[];
@inject(WorkflowDocument) protected workflowDocument: WorkflowDocument;
private _frontLineEntities: WorkflowLineEntity[] = [];
private _backLineEntities: WorkflowLineEntity[] = [];
private _version = 0;
/**
* 节点下边的线条
*/
protected backLines = domUtils.createDivWithClass(
'gedit-playground-layer gedit-flow-lines-layer back',
);
/**
* 节点前面的线条
*/
protected frontLines = domUtils.createDivWithClass(
'gedit-playground-layer gedit-flow-lines-layer front',
);
onZoom(scale: number): void {
this.backLines.style.transform = `scale(${scale})`;
this.frontLines.style.transform = `scale(${scale})`;
}
// 用来绕过 memo
private bumpVersion() {
this._version = this._version + 1;
if (this._version === Number.MAX_SAFE_INTEGER) {
this._version = 0;
}
}
onReady() {
this.pipelineNode.appendChild(this.backLines);
this.pipelineNode.appendChild(this.frontLines);
this.frontLines.style.zIndex = '20';
this.toDispose.pushAll([
this.selectService.onSelectionChanged(() => this.render()),
this.hoverService.onHoveredChange(() => this.render()),
this.workflowDocument.linesManager.onForceUpdate(() => {
this.bumpVersion();
this.render();
}),
]);
}
getLineColor(line: WorkflowLineEntity): string {
// 隐藏的优先级比 hasError 高
if (line.isHidden) {
return line.highlightColor;
}
if (line.hasError) {
if (
(this.selectService.isSelected(line.id) ||
this.hoverService.isHovered(line.id)) &&
!this.config.readonly
) {
return errorActiveColor;
}
return LineColors.ERROR;
}
if (line.highlightColor) {
return line.highlightColor;
}
if (line.drawingTo) {
return LineColors.DRAWING;
}
if (
(this.selectService.isSelected(line.id) ||
this.hoverService.isHovered(line.id)) &&
!this.config.readonly
) {
return LineColors.HOVER;
}
return LineColors.DEFUALT;
}
renderLines(lines: WorkflowLineEntity[]) {
const { lineType } = this.workflowDocument.linesManager;
// const isViewportVisible = this.config.isViewportVisible.bind(this.config);
return (
<>
{lines
.map(line => {
const color = this.getLineColor(line);
const selected = this.config.readonly
? false
: this.selectService.isSelected(line.id);
const renderData = line.getData(WorkflowLineRenderData);
const version = `${this._version}:${line.version}:${renderData.renderVersion}`;
// 正在绘制中的线条使用贝塞尔曲线
if (lineType === LineType.LINE_CHART) {
return (
<FoldLineRender
key={line.id}
color={color}
selected={selected}
line={line}
version={version}
/>
);
}
return (
<BezierLineRender
key={line.id}
color={color}
selected={selected}
line={line}
version={version}
/>
);
})
.filter(l => l)}
</>
);
}
protected isFrontLine(line: WorkflowLineEntity): boolean {
if (
this.hoverService.isHovered(line.id) ||
this.selectService.isSelected(line.id) ||
line.isDrawing
) {
return true;
}
// const { activatedNode } = this.selectService
// // 将选中的节点的连接线放置到前面
// if (activatedNode) {
// const { inputLines, outputLines } = activatedNode.getData<WorkflowNodeLinesData>(WorkflowNodeLinesData)!
// if (inputLines.includes(line) || outputLines.includes(line)) {
// return true
// }
// }
return false;
}
renderBackLines(): React.ReactNode {
return ReactDOM.createPortal(
this.renderLines(this._backLineEntities),
this.backLines,
);
}
renderFrontLines(): React.ReactNode {
return ReactDOM.createPortal(
this.renderLines(this._frontLineEntities),
this.frontLines,
);
}
// onViewportChange = throttle(() => {
// this.render();
// }, 100);
/**
* 对线条进行分组
*/
groupLines(): void {
this._backLineEntities = [];
this._frontLineEntities = [];
this.lines.forEach(line => {
if (this.isFrontLine(line)) {
this._frontLineEntities.push(line);
} else {
this._backLineEntities.push(line);
}
});
}
render(): JSX.Element {
// const isViewportVisible = this.config.isViewportVisible.bind(this.config);
// 对线条进行分组
this.groupLines();
return (
<>
{this.renderBackLines()}
{this.renderFrontLines()}
</>
);
}
}

View File

@@ -0,0 +1,117 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { inject, injectable } from 'inversify';
import { Layer, SelectionService } from '@flowgram-adapter/free-layout-editor';
import {
WorkflowCommands,
WorkflowDocument,
WorkflowHoverService,
WorkflowLineEntity,
WorkflowLinesManager,
WorkflowNodeEntity,
type WorkflowNodeMeta,
} from '@flowgram-adapter/free-layout-editor';
import { WorkflowShortcutsRegistry } from '../workflow-shorcuts-contribution';
import { isShortcutsMatch } from '../utils/shortcuts-utils';
@injectable()
export class ShortcutsLayer extends Layer<object> {
static type = 'ShortcutsLayer';
@inject(WorkflowShortcutsRegistry) shortcuts: WorkflowShortcutsRegistry;
@inject(SelectionService) selection: SelectionService;
@inject(WorkflowHoverService) hoverService: WorkflowHoverService;
@inject(WorkflowDocument) document: WorkflowDocument;
@inject(WorkflowLinesManager) linesManager: WorkflowLinesManager;
onReady(): void {
this.shortcuts.addHandlersIfNotFound(
/**
* 删除
*/
{
commandId: WorkflowCommands.DELETE_NODES,
shortcuts: ['backspace', 'delete'],
isEnabled: () =>
this.selection.selection.length > 0 &&
!this.config.disabled &&
!this.config.readonly,
execute: () => {
this.selection.selection.forEach(entity => {
if (entity instanceof WorkflowNodeEntity) {
if (!this.document.canRemove(entity)) {
return;
}
const nodeMeta = entity.getNodeMeta<WorkflowNodeMeta>();
const subCanvas = nodeMeta.subCanvas?.(entity);
if (subCanvas?.isCanvas) {
subCanvas.parentNode.dispose();
return;
}
} else if (
entity instanceof WorkflowLineEntity &&
!this.linesManager.canRemove(entity)
) {
return;
}
entity.dispose();
});
this.selection.selection = this.selection.selection.filter(
s => !s.disposed,
);
},
},
/**
* 放大
*/
{
commandId: WorkflowCommands.ZOOM_IN,
shortcuts: ['meta =', 'ctrl ='],
execute: () => {
this.config.zoomin();
},
},
/**
* 缩小
*/
{
commandId: WorkflowCommands.ZOOM_OUT,
shortcuts: ['meta -', 'ctrl -'],
execute: () => {
this.config.zoomout();
},
},
);
this.toDispose.pushAll([
// 监听画布鼠标移动事件
this.listenPlaygroundEvent('keydown', (e: KeyboardEvent) => {
if (!this.isFocused || e.target !== this.playgroundNode) {
return;
}
this.shortcuts.shortcutsHandlers.some(shortcutsHandler => {
if (
isShortcutsMatch(e, shortcutsHandler.shortcuts) &&
(!shortcutsHandler.isEnabled || shortcutsHandler.isEnabled(e))
) {
shortcutsHandler.execute(e);
e.preventDefault();
return true;
}
});
}),
]);
}
}

View File

@@ -0,0 +1,40 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { FlowNodeTransformData } from '@flowgram-adapter/free-layout-editor';
import { type SelectionService } from '@flowgram-adapter/free-layout-editor';
import {
WorkflowNodeEntity,
type WorkflowSelectService,
} from '@flowgram-adapter/free-layout-editor';
import { Rectangle } from '@flowgram-adapter/common';
const BOUNDS_PADDING = 2;
export function getSelectionBounds(
selectionService: SelectionService | WorkflowSelectService,
ignoreOneSelect?: boolean, // 忽略单选
): Rectangle {
const selectedNodes = selectionService.selection.filter(
node => node instanceof WorkflowNodeEntity,
);
// 选中单个的时候不显示
return selectedNodes.length > (ignoreOneSelect ? 1 : 0)
? Rectangle.enlarge(
selectedNodes.map(n => n.getData(FlowNodeTransformData)!.bounds),
).pad(BOUNDS_PADDING)
: Rectangle.EMPTY;
}

View File

@@ -0,0 +1,209 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const isAppleDevice = /(mac|iphone|ipod|ipad)/i.test(
typeof navigator !== 'undefined' ? navigator?.platform : '',
);
// 键盘事件 keyCode 别名
const aliasKeyCodeMap = {
'0': 48,
'1': 49,
'2': 50,
'3': 51,
'4': 52,
'5': 53,
'6': 54,
'7': 55,
'8': 56,
'9': 57,
backspace: 8,
tab: 9,
enter: 13,
shift: 16,
ctrl: 17,
alt: 18,
pausebreak: 19,
capslock: 20,
esc: 27,
space: 32,
pageup: 33,
pagedown: 34,
end: 35,
home: 36,
leftarrow: 37,
uparrow: 38,
rightarrow: 39,
downarrow: 40,
insert: 45,
delete: 46,
a: 65,
b: 66,
c: 67,
d: 68,
e: 69,
f: 70,
g: 71,
h: 72,
i: 73,
j: 74,
k: 75,
l: 76,
m: 77,
n: 78,
o: 79,
p: 80,
q: 81,
r: 82,
s: 83,
t: 84,
u: 85,
v: 86,
w: 87,
x: 88,
y: 89,
z: 90,
leftwindowkey: 91,
rightwindowkey: 92,
meta: isAppleDevice ? [91, 93] : [91, 92],
selectkey: 93,
numpad0: 96,
numpad1: 97,
numpad2: 98,
numpad3: 99,
numpad4: 100,
numpad5: 101,
numpad6: 102,
numpad7: 103,
numpad8: 104,
numpad9: 105,
multiply: 106,
add: 107,
subtract: 109,
decimalpoint: 110,
divide: 111,
f1: 112,
f2: 113,
f3: 114,
f4: 115,
f5: 116,
f6: 117,
f7: 118,
f8: 119,
f9: 120,
f10: 121,
f11: 122,
f12: 123,
numlock: 144,
scrolllock: 145,
semicolon: 186,
equalsign: 187,
'=': 187,
comma: 188,
dash: 189,
'-': 189,
period: 190,
forwardslash: 191,
graveaccent: 192,
openbracket: 219,
backslash: 220,
closebracket: 221,
singlequote: 222,
};
const modifierKey = {
ctrl: (event: KeyboardEvent) => event.ctrlKey,
shift: (event: KeyboardEvent) => event.shiftKey,
alt: (event: KeyboardEvent) => event.altKey,
meta: (event: KeyboardEvent) => {
if (event.type === 'keyup') {
return aliasKeyCodeMap.meta.includes(event.keyCode);
}
return event.metaKey;
},
};
// 根据 event 计算激活键数量
function countKeyByEvent(event: KeyboardEvent): number {
const countOfModifier = Object.keys(modifierKey).reduce((total, key) => {
if (modifierKey[key](event)) {
return total + 1;
}
return total;
}, 0);
// 16 17 18 91 92 是修饰键的 keyCode如果 keyCode 是修饰键,那么激活数量就是修饰键的数量,如果不是,那么就需要 +1
return [16, 17, 18, 91, 92].includes(event.keyCode)
? countOfModifier
: countOfModifier + 1;
}
/**
*
* @param event
* @param keyString 'ctrl.s' 'meta.s'
* @param exactMatch
*/
function isKeyStringMatch(
event: KeyboardEvent,
keyString: string,
exactMatch = true,
): boolean {
// 浏览器自动补全 input 的时候,会触发 keyDown、keyUp 事件,但此时 event.key 等为空
if (!event.key || !keyString) {
return false;
}
// 字符串依次判断是否有组合键
const genArr = keyString.split(/\s+/);
let genLen = 0;
for (const key of genArr) {
// 组合键
const genModifier = modifierKey[key];
// keyCode 别名
const aliasKeyCode: number | number[] = aliasKeyCodeMap[key.toLowerCase()];
if (
(genModifier && genModifier(event)) ||
(aliasKeyCode && aliasKeyCode === event.keyCode)
) {
genLen++;
}
}
/**
* 需要判断触发的键位和监听的键位完全一致,判断方法就是触发的键位里有且等于监听的键位
* genLen === genArr.length 能判断出来触发的键位里有监听的键位
* countKeyByEvent(event) === genArr.length 判断出来触发的键位数量里有且等于监听的键位数量
* 主要用来防止按组合键其子集也会触发的情况,例如监听 ctrl+a 会触发监听 ctrl 和 a 两个键的事件。
*/
if (exactMatch) {
return genLen === genArr.length && countKeyByEvent(event) === genArr.length;
}
return genLen === genArr.length;
}
/**
* 匹配指定的快捷键
* @param event
* @param shortcuts
*/
export function isShortcutsMatch(
event: KeyboardEvent,
shortcuts: string[],
): boolean {
return shortcuts.some(keyString => isKeyStringMatch(event, keyString));
}

View File

@@ -0,0 +1,52 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useLayoutEffect, useMemo, useEffect } from 'react';
import { reportTti } from '@coze-arch/report-tti/custom-perf-metric';
import { FlowRendererRegistry } from '@flowgram-adapter/free-layout-editor';
import { LoggerEvent, LoggerService, useService } from '@flowgram-adapter/free-layout-editor';
import { WorkflowDocument } from '@flowgram-adapter/free-layout-editor';
import styles from './index.module.less';
export const WorkflowLoader: React.FC = () => {
const doc = useService<WorkflowDocument>(WorkflowDocument);
const renderRegistry = useService<FlowRendererRegistry>(FlowRendererRegistry);
const loggerService = useService<LoggerService>(LoggerService);
useMemo(() => renderRegistry.init(), [renderRegistry]);
useLayoutEffect(() => {
// 加载数据
doc.load();
// 销毁数据
return () => doc.dispose();
}, [doc]);
useEffect(() => {
const disposable = loggerService.onLogger(({ event }) => {
if (event === LoggerEvent.CANVAS_TTI) {
// 上报到 coze
reportTti();
}
});
return () => {
disposable?.dispose();
};
}, []);
return <div className={styles.playgroundLoad} />;
};

View File

@@ -0,0 +1,31 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ContainerModule } from 'inversify';
import { FlowRendererContribution } from '@flowgram-adapter/free-layout-editor';
import { PlaygroundContribution } from '@flowgram-adapter/free-layout-editor';
import { bindContributions } from '@flowgram-adapter/common';
import { WorkflowShortcutsRegistry } from './workflow-shorcuts-contribution';
import { WorkflowRenderContribution } from './workflow-render-contribution';
export const WorkflowRenderContainerModule = new ContainerModule(bind => {
bindContributions(bind, WorkflowRenderContribution, [
PlaygroundContribution,
FlowRendererContribution,
]);
bind(WorkflowShortcutsRegistry).toSelf().inSingletonScope();
});

View File

@@ -0,0 +1,149 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { inject, injectable } from 'inversify';
import { Gesture } from '@use-gesture/vanilla';
import {
FlowDebugLayer,
FlowNodesContentLayer,
FlowNodesTransformLayer,
FlowScrollBarLayer,
FlowSelectorBoundsLayer,
FlowSelectorBoxLayer,
type FlowRendererContribution,
type FlowRendererRegistry,
} from '@flowgram-adapter/free-layout-editor';
import { StackingContextManager } from '@flowgram-adapter/free-layout-editor';
import {
WorkflowBezierLineContribution,
WorkflowFoldLineContribution,
WorkflowLinesLayer,
} from '@flowgram-adapter/free-layout-editor';
import {
PlaygroundLayer,
type PlaygroundContribution,
} from '@flowgram-adapter/free-layout-editor';
import {
WorkflowHoverService,
WorkflowLinesManager,
} from '@flowgram-adapter/free-layout-editor';
import { BackgroundLayer, HoverLayer, ShortcutsLayer } from './layer';
import { SelectorBounds } from './components/selector-bounds';
@injectable()
export class WorkflowRenderContribution
implements FlowRendererContribution, PlaygroundContribution
{
@inject(WorkflowHoverService) protected hoverService: WorkflowHoverService;
@inject(StackingContextManager)
protected stackingContext: StackingContextManager;
@inject(WorkflowLinesManager)
protected linesManager: WorkflowLinesManager;
registerRenderer(registry: FlowRendererRegistry): void {
// 画布基础层,提供缩放、手势等能力
registry.registerLayer(PlaygroundLayer, {
hoverService: this.hoverService,
});
registry.registerLayers(
FlowNodesContentLayer,
// FlowScrollLimitLayer, // 控制滚动范围
FlowScrollBarLayer, // 滚动条
HoverLayer, // 控制hover
ShortcutsLayer, // 快捷键配置
);
// 线条
registry.registerLayer(WorkflowLinesLayer, {
renderElement: () => this.stackingContext.node,
});
// 节点位置
registry.registerLayer(FlowNodesTransformLayer, {
renderElement: () => this.stackingContext.node,
});
registry.registerLayer<FlowSelectorBoundsLayer>(FlowSelectorBoundsLayer, {
disableBackground: true,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
CustomBoundsRenderer: SelectorBounds as any,
});
registry.registerLayer<FlowSelectorBoxLayer>(FlowSelectorBoxLayer, {
canSelect: (event, entity) => {
// 需满足以下条件:
// 1. 非左键不能触发框选
if (event.button !== 0) {
return false;
}
const element = event.target as Element;
// 2. 没有元素不能触发框选
if (!element) {
return false;
}
// 3. 如存在自定义配置,以配置为准
if (element) {
if (element.closest('[data-flow-editor-selectable="true"]')) {
return true;
}
if (element.closest('[data-flow-editor-selectable="false"]')) {
return false;
}
}
// 4. hover 到节点或者线条不能触发框选
if (this.hoverService.isSomeHovered()) {
return false;
}
// 5. 未处于画布内不能触发框选
if (
!element.classList.contains('gedit-playground-layer') &&
!element.classList.contains('gedit-flow-background-layer') &&
// 连线的空白区域
!element.closest('.gedit-flow-activity-edge')
) {
return false;
}
return true;
},
});
// 调试画布
if (location.search.match('playground_debug')) {
registry.registerLayers(FlowDebugLayer);
}
// 背景最后插入,因为里边会调整位置
registry.registerLayer(BackgroundLayer);
}
/**
* 这个用于阻止 document.body 的手势缩放
* @private
*/
private _gestureForStopDefault = new Gesture(document.body, {
onPinch: () => {
// Do nothing
},
});
onReady(): void {
if (document.documentElement) {
document.documentElement.style.overscrollBehavior = 'none';
}
document.body.style.overscrollBehavior = 'none';
this.linesManager
.registerContribution(WorkflowBezierLineContribution)
.registerContribution(WorkflowFoldLineContribution);
}
onDispose() {
this._gestureForStopDefault.destroy();
}
}

View File

@@ -0,0 +1,77 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useCallback, useMemo } from 'react';
import { type interfaces } from 'inversify';
import { FlowRendererContainerModule } from '@flowgram-adapter/free-layout-editor';
import { createNodeCorePlugin } from '@flowgram-adapter/free-layout-editor';
import { createFreeStackPlugin } from '@flowgram-adapter/free-layout-editor';
import { createFreeAutoLayoutPlugin } from '@flowgram-adapter/free-layout-editor';
import { FlowDocumentContainerModule } from '@flowgram-adapter/free-layout-editor';
import {
PlaygroundReactProvider,
type Plugin,
} from '@flowgram-adapter/free-layout-editor';
import { WorkflowDocumentContainerModule } from '@flowgram-adapter/free-layout-editor';
import { WorkflowRenderContainerModule } from './workflow-render-container-module';
import { WorkflowLoader } from './workflow-loader';
export interface WorkflowRenderProviderProps {
children: React.ReactElement;
containerModules?: interfaces.ContainerModule[];
preset?: () => Plugin[];
parentContainer?: interfaces.Container;
}
/**
* 画布引擎渲染
*/
export const WorkflowRenderProvider = (props: WorkflowRenderProviderProps) => {
const modules = useMemo(
() => [
FlowDocumentContainerModule, // 默认文档
FlowRendererContainerModule, // 默认渲染
// FlowActivitiesContainerModule, // 这是固定画布的 module目前不需要依赖
WorkflowDocumentContainerModule, // 扩展文档
WorkflowRenderContainerModule, // 扩展渲染
...(props.containerModules || []),
],
[],
);
const preset = useCallback(
() => [
createFreeAutoLayoutPlugin({}),
createFreeStackPlugin({}), // 渲染层级管理
createNodeCorePlugin({}),
...(props.preset?.() || []),
],
[],
);
return (
<PlaygroundReactProvider
containerModules={modules}
plugins={preset}
parentContainer={props.parentContainer}
>
<WorkflowLoader />
{props.children}
</PlaygroundReactProvider>
);
};

View File

@@ -0,0 +1,76 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
inject,
injectable,
multiInject,
optional,
postConstruct,
} from 'inversify';
import { CommandRegistry } from '@flowgram-adapter/free-layout-editor';
interface ShorcutsHandler {
commandId: string;
shortcuts: string[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
isEnabled?: (...args: any[]) => boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
execute: (...args: any[]) => void;
}
export const WorkflowShortcutsContribution = Symbol(
'WorkflowShortcutsContribution',
);
export interface WorkflowShortcutsContribution {
registerShortcuts: (registry: WorkflowShortcutsRegistry) => void;
}
@injectable()
export class WorkflowShortcutsRegistry {
@multiInject(WorkflowShortcutsContribution)
@optional()
protected contribs: WorkflowShortcutsContribution[];
@inject(CommandRegistry) protected commandRegistry: CommandRegistry;
readonly shortcutsHandlers: ShorcutsHandler[] = [];
addHandlers(...handlers: ShorcutsHandler[]): void {
// 注册 command
handlers.forEach(handler => {
this.commandRegistry.registerCommand(
{ id: handler.commandId },
{ execute: handler.execute, isEnabled: handler.isEnabled },
);
});
this.shortcutsHandlers.push(...handlers);
}
addHandlersIfNotFound(...handlers: ShorcutsHandler[]): void {
handlers.forEach(handler => {
if (!this.has(handler.commandId)) {
this.addHandlers(handler);
}
});
}
has(commandId: string): boolean {
return this.shortcutsHandlers.some(
handler => handler.commandId === commandId,
);
}
@postConstruct()
protected init(): void {
this.contribs?.forEach(contrib => contrib.registerShortcuts(this));
}
}