mirror of
https://gitee.com/ByteDance/flowgram.ai.git
synced 2025-07-07 17:43:29 +08:00
feat(free-demo): support create nodes group (#185)
* feat(demo): create group tool * feat(demo): create group shortcut * refactor(core): split group service, controller, utils files * feat(history): free history add group operations * feat(demo): group node render * feat(demo): group node registry * refactor(stack): remove layer computing config * feat(stack): line stackIndex cannot be recalculated by default * feat(demo): group title & color palette acess form * feat(demo): ungroup button & shortcut * feat(demo): create group & ungroup operation register to free history service * refactor(group): group shortcuts move to group-plugin * refactor(group): group node render move to group-plugin * fix(group): undo/redo of create node or ungroup not work * perf(history): free history remove async operation * feat(group): trigger select box inside group * fix(group): container inside group * fix(group): auto layout should not be affected by group node * feat(container): support multi-layer nested containers * fix(group): group css variables overwrite each other * fix(container): node move in or out group shouldn't clear lines * feat(demo): node should follow mouse after move out container button clicked * feat(container): disable group move to non-group container node * fix(container): cross-level node moving causing coord offset * feat(demo): comment node support more button * fix(demo): comment in container fromJSON * feat(container): node into container show move out tips * feat(group): node into group show move out tips * feat(group): delete group when blocks is empty * refactor(group): createFreeGroupPlugin move to container-plugin * refactor(demo): replace disablePorts with defaultPorts * fix(demo): react warning * refactor(group): group plugin built-in GroupNodeRegistry * refactor(group): create free-group-plugin * fix(ci): lock & ts-check & test errors
This commit is contained in:
parent
8c0f007127
commit
19ff04abc7
@ -1,63 +1,64 @@
|
||||
{
|
||||
"name": "@flowgram.ai/demo-free-layout",
|
||||
"version": "0.1.0",
|
||||
"description": "",
|
||||
"keywords": [],
|
||||
"license": "MIT",
|
||||
"main": "./src/index.ts",
|
||||
"files": [
|
||||
"src/",
|
||||
".eslintrc.js",
|
||||
".gitignore",
|
||||
"index.html",
|
||||
"package.json",
|
||||
"rsbuild.config.ts",
|
||||
"tsconfig.json"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "exit 0",
|
||||
"build:fast": "exit 0",
|
||||
"build:watch": "exit 0",
|
||||
"clean": "rimraf dist",
|
||||
"dev": "cross-env MODE=app NODE_ENV=development rsbuild dev --open",
|
||||
"lint": "eslint ./src --cache",
|
||||
"lint:fix": "eslint ./src --fix",
|
||||
"start": "cross-env NODE_ENV=development rsbuild dev --open",
|
||||
"test": "exit",
|
||||
"test:cov": "exit",
|
||||
"watch": "exit 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@douyinfe/semi-icons": "^2.72.3",
|
||||
"@douyinfe/semi-ui": "^2.72.3",
|
||||
"@flowgram.ai/free-layout-editor": "workspace:*",
|
||||
"@flowgram.ai/free-snap-plugin": "workspace:*",
|
||||
"@flowgram.ai/free-lines-plugin": "workspace:*",
|
||||
"@flowgram.ai/free-node-panel-plugin": "workspace:*",
|
||||
"@flowgram.ai/minimap-plugin": "workspace:*",
|
||||
"@flowgram.ai/free-container-plugin": "workspace:*",
|
||||
"lodash-es": "^4.17.21",
|
||||
"nanoid": "^4.0.2",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"styled-components": "^5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@flowgram.ai/ts-config": "workspace:*",
|
||||
"@flowgram.ai/eslint-config": "workspace:*",
|
||||
"@rsbuild/core": "^1.2.16",
|
||||
"@rsbuild/plugin-react": "^1.1.1",
|
||||
"@rsbuild/plugin-less": "^1.1.1",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^18",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/styled-components": "^5",
|
||||
"eslint": "^8.54.0",
|
||||
"cross-env": "~7.0.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
}
|
||||
"name": "@flowgram.ai/demo-free-layout",
|
||||
"version": "0.1.0",
|
||||
"description": "",
|
||||
"keywords": [],
|
||||
"license": "MIT",
|
||||
"main": "./src/index.ts",
|
||||
"files": [
|
||||
"src/",
|
||||
".eslintrc.js",
|
||||
".gitignore",
|
||||
"index.html",
|
||||
"package.json",
|
||||
"rsbuild.config.ts",
|
||||
"tsconfig.json"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "exit 0",
|
||||
"build:fast": "exit 0",
|
||||
"build:watch": "exit 0",
|
||||
"clean": "rimraf dist",
|
||||
"dev": "cross-env MODE=app NODE_ENV=development rsbuild dev --open",
|
||||
"lint": "eslint ./src --cache",
|
||||
"lint:fix": "eslint ./src --fix",
|
||||
"start": "cross-env NODE_ENV=development rsbuild dev --open",
|
||||
"test": "exit",
|
||||
"test:cov": "exit",
|
||||
"watch": "exit 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@douyinfe/semi-icons": "^2.72.3",
|
||||
"@douyinfe/semi-ui": "^2.72.3",
|
||||
"@flowgram.ai/free-layout-editor": "workspace:*",
|
||||
"@flowgram.ai/free-snap-plugin": "workspace:*",
|
||||
"@flowgram.ai/free-lines-plugin": "workspace:*",
|
||||
"@flowgram.ai/free-node-panel-plugin": "workspace:*",
|
||||
"@flowgram.ai/minimap-plugin": "workspace:*",
|
||||
"@flowgram.ai/free-container-plugin": "workspace:*",
|
||||
"@flowgram.ai/free-group-plugin": "workspace:*",
|
||||
"lodash-es": "^4.17.21",
|
||||
"nanoid": "^4.0.2",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"styled-components": "^5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@flowgram.ai/ts-config": "workspace:*",
|
||||
"@flowgram.ai/eslint-config": "workspace:*",
|
||||
"@rsbuild/core": "^1.2.16",
|
||||
"@rsbuild/plugin-react": "^1.1.1",
|
||||
"@rsbuild/plugin-less": "^1.1.1",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^18",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/styled-components": "^5",
|
||||
"eslint": "^8.54.0",
|
||||
"cross-env": "~7.0.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,6 +26,8 @@ export const NodeWrapper: React.FC<NodeWrapperProps> = (props) => {
|
||||
const form = nodeRender.form;
|
||||
const ctx = useClientContext();
|
||||
|
||||
const portsRender = ports.map((p) => <WorkflowPortRender key={p.id} entity={p} />);
|
||||
|
||||
return (
|
||||
<>
|
||||
<NodeWrapperStyle
|
||||
@ -57,9 +59,7 @@ export const NodeWrapper: React.FC<NodeWrapperProps> = (props) => {
|
||||
>
|
||||
{children}
|
||||
</NodeWrapperStyle>
|
||||
{ports.map((p) => (
|
||||
<WorkflowPortRender key={p.id} entity={p} />
|
||||
))}
|
||||
{portsRender}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -14,13 +14,13 @@ export const CommentContainer: FC<ICommentContainer> = (props) => {
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: 'rgb(159 159 158 / 65%) transparent',
|
||||
// 针对 WebKit 浏览器(如 Chrome、Safari)的样式
|
||||
'&::-webkit-scrollbar': {
|
||||
'&:WebkitScrollbar': {
|
||||
width: '4px',
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
'&::WebkitScrollbarTrack': {
|
||||
background: 'transparent',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
'&::WebkitScrollbarThumb': {
|
||||
backgroundColor: 'rgb(159 159 158 / 65%)',
|
||||
borderRadius: '20px',
|
||||
border: '2px solid transparent',
|
||||
|
||||
@ -68,3 +68,37 @@
|
||||
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,21 @@
|
||||
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>
|
||||
);
|
||||
@ -14,6 +14,7 @@ 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';
|
||||
@ -26,7 +27,7 @@ export const CommentRender: FC<{
|
||||
const { node } = props;
|
||||
const model = useModel();
|
||||
|
||||
const { selected: focused, selectNode, nodeRef } = useNodeRender();
|
||||
const { selected: focused, selectNode, nodeRef, deleteNode } = useNodeRender();
|
||||
|
||||
const formModel = node.getData(FlowNodeFormData).getFormModel<FormModelV2>();
|
||||
const formControl = formModel?.formControl;
|
||||
@ -65,6 +66,8 @@ export const CommentRender: FC<{
|
||||
<BlankArea model={model} />
|
||||
{/* 内容拖拽区域(点击后隐藏) */}
|
||||
<ContentDragArea model={model} focused={focused} overflow={overflow} />
|
||||
{/* 更多按钮 */}
|
||||
<MoreButton node={node} focused={focused} deleteNode={deleteNode} />
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
100
apps/demo-free-layout/src/components/group/color.ts
Normal file
100
apps/demo-free-layout/src/components/group/color.ts
Normal file
@ -0,0 +1,100 @@
|
||||
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,49 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export const GroupBackground: FC<GroupBackgroundProps> = ({ node, style }) => {
|
||||
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 ${color['400']};
|
||||
}
|
||||
`;
|
||||
|
||||
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']}29`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,45 @@
|
||||
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>
|
||||
);
|
||||
@ -0,0 +1,40 @@
|
||||
import type { FC, ReactNode, MouseEvent, CSSProperties } from 'react';
|
||||
|
||||
import { useWatch } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { GroupField } from '../constant';
|
||||
import { defaultColor, groupColors } from '../color';
|
||||
|
||||
interface GroupHeaderProps {
|
||||
onMouseDown: (e: MouseEvent) => void;
|
||||
onFocus: () => void;
|
||||
onBlur: () => void;
|
||||
children: ReactNode;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const GroupHeader: FC<GroupHeaderProps> = ({
|
||||
onMouseDown,
|
||||
onFocus,
|
||||
onBlur,
|
||||
children,
|
||||
style,
|
||||
}) => {
|
||||
const colorName = useWatch<string>(GroupField.Color) ?? defaultColor;
|
||||
const color = groupColors[colorName];
|
||||
return (
|
||||
<div
|
||||
className="workflow-group-header"
|
||||
onMouseDown={onMouseDown}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
style={{
|
||||
...style,
|
||||
backgroundColor: color['50'],
|
||||
borderColor: color['300'],
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,47 @@
|
||||
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>
|
||||
);
|
||||
@ -0,0 +1,2 @@
|
||||
export { GroupNodeRender } from './node-render';
|
||||
export { IconGroup } from './icon-group';
|
||||
@ -0,0 +1,66 @@
|
||||
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;
|
||||
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
|
||||
onMouseDown={(e) => {
|
||||
startDrag(e);
|
||||
}}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
style={{
|
||||
height: HEADER_HEIGHT,
|
||||
}}
|
||||
>
|
||||
<GroupTools />
|
||||
</GroupHeader>
|
||||
<GroupTips />
|
||||
<UngroupButton node={node} />
|
||||
<GroupBackground
|
||||
node={node}
|
||||
style={{
|
||||
top: HEADER_HEIGHT + HEADER_PADDING,
|
||||
height: nodeHeight - HEADER_HEIGHT - HEADER_PADDING,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,33 @@
|
||||
/* 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,9 @@
|
||||
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,36 @@
|
||||
import { useControlTips } from './use-control';
|
||||
import { GroupTipsStyle } from './style';
|
||||
import { isMacOS } from './is-mac-os';
|
||||
import { IconClose } from './icon-close';
|
||||
|
||||
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">{`Hold ${isMacOS ? 'Cmd ⌘' : 'Ctrl'} to drag node out`}</p>
|
||||
<div
|
||||
className="space"
|
||||
style={{
|
||||
width: 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<p className="close-forever" onClick={closeForever}>
|
||||
Never Remind
|
||||
</p>
|
||||
<div className="close" onClick={close}>
|
||||
<IconClose />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GroupTipsStyle>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export const isMacOS = /(Macintosh|MacIntel|MacPPC|Mac68K|iPad)/.test(navigator.userAgent);
|
||||
@ -0,0 +1,74 @@
|
||||
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,66 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,33 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,14 @@
|
||||
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>
|
||||
);
|
||||
@ -0,0 +1,31 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
7
apps/demo-free-layout/src/components/group/constant.ts
Normal file
7
apps/demo-free-layout/src/components/group/constant.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export const HEADER_HEIGHT = 30;
|
||||
export const HEADER_PADDING = 5;
|
||||
|
||||
export enum GroupField {
|
||||
Title = 'title',
|
||||
Color = 'color',
|
||||
}
|
||||
109
apps/demo-free-layout/src/components/group/index.css
Normal file
109
apps/demo-free-layout/src/components/group/index.css
Normal file
@ -0,0 +1,109 @@
|
||||
.workflow-group-render {
|
||||
border-radius: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
4
apps/demo-free-layout/src/components/group/index.ts
Normal file
4
apps/demo-free-layout/src/components/group/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import './index.css';
|
||||
|
||||
export { GroupNodeRender } from './components';
|
||||
export { IconGroup } from './components';
|
||||
@ -2,3 +2,4 @@ export * from './base-node';
|
||||
export * from './line-add-button';
|
||||
export * from './node-panel';
|
||||
export * from './comment';
|
||||
export * from './group';
|
||||
|
||||
113
apps/demo-free-layout/src/components/node-menu/index.tsx
Normal file
113
apps/demo-free-layout/src/components/node-menu/index.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
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 { FlowNodeRegistry } from '../../typings';
|
||||
import { PasteShortcut } from '../../shortcuts/paste';
|
||||
import { CopyShortcut } from '../../shortcuts/copy';
|
||||
|
||||
interface NodeMenuProps {
|
||||
node: WorkflowNodeEntity;
|
||||
deleteNode: () => void;
|
||||
}
|
||||
|
||||
export const NodeMenu: FC<NodeMenuProps> = ({ node, deleteNode }) => {
|
||||
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 });
|
||||
// clear invalid lines - 清除非法线条
|
||||
await nodeIntoContainerService.clearInvalidLines({
|
||||
dragNode: node,
|
||||
sourceParent,
|
||||
});
|
||||
rerenderMenu();
|
||||
await delay(16);
|
||||
// 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]
|
||||
);
|
||||
|
||||
if (!visible) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
trigger="hover"
|
||||
position="bottomRight"
|
||||
render={
|
||||
<Dropdown.Menu>
|
||||
{canMoveOut && <Dropdown.Item onClick={handleMoveOut}>Move out</Dropdown.Item>}
|
||||
<Dropdown.Item onClick={handleCopy} disabled={registry.meta!.copyDisable === true}>
|
||||
Create Copy
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
onClick={handleDelete}
|
||||
disabled={!!(registry.canDelete?.(clientContext, node) || registry.meta!.deleteDisable)}
|
||||
>
|
||||
Delete
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
color="secondary"
|
||||
size="small"
|
||||
theme="borderless"
|
||||
icon={<IconMore />}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
@ -1,9 +1,11 @@
|
||||
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 { IconCopy, IconDeleteStroked, IconExpand, IconShrink } from '@douyinfe/semi-icons';
|
||||
|
||||
import { IconGroup } from '../group';
|
||||
import { FlowCommandId } from '../../shortcuts/constants';
|
||||
|
||||
const BUTTON_HEIGHT = 24;
|
||||
@ -54,6 +56,18 @@ export const SelectorBoxPopover: FunctionComponent<SelectorBoxPopoverProps> = ({
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content={'Create Group'}>
|
||||
<Button
|
||||
icon={<IconGroup size={14} />}
|
||||
style={{ height: BUTTON_HEIGHT }}
|
||||
type="primary"
|
||||
theme="solid"
|
||||
onClick={() => {
|
||||
commandRegistry.executeCommand(WorkflowGroupCommand.Group);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content={'Copy'}>
|
||||
<Button
|
||||
icon={<IconCopy />}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useCallback, useContext, useEffect } from 'react';
|
||||
import { useCallback, useContext, useEffect, useMemo } from 'react';
|
||||
|
||||
import {
|
||||
PlaygroundEntityContext,
|
||||
@ -7,6 +7,7 @@ import {
|
||||
} from '@flowgram.ai/free-layout-editor';
|
||||
import { SideSheet } from '@douyinfe/semi-ui';
|
||||
|
||||
import { FlowNodeMeta } from '../../typings';
|
||||
import { SidebarContext, IsSidebarContext, NodeRenderContext } from '../../context';
|
||||
|
||||
export const SidebarRenderer = () => {
|
||||
@ -53,6 +54,14 @@ export const SidebarRenderer = () => {
|
||||
return () => {};
|
||||
}, [nodeRender]);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
if (!nodeRender) {
|
||||
return false;
|
||||
}
|
||||
const { disableSideBar = false } = nodeRender.node.getNodeMeta<FlowNodeMeta>();
|
||||
return !disableSideBar;
|
||||
}, [nodeRender]);
|
||||
|
||||
if (playground.config.readonly) {
|
||||
return null;
|
||||
}
|
||||
@ -68,7 +77,7 @@ export const SidebarRenderer = () => {
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<SideSheet mask={false} visible={!!nodeRender} onCancel={handleClose}>
|
||||
<SideSheet mask={false} visible={visible} onCancel={handleClose}>
|
||||
<IsSidebarContext.Provider value={true}>{content}</IsSidebarContext.Provider>
|
||||
</SideSheet>
|
||||
);
|
||||
|
||||
@ -1,99 +1,17 @@
|
||||
import { useCallback, useState, type MouseEvent } from 'react';
|
||||
|
||||
import {
|
||||
Field,
|
||||
FieldRenderProps,
|
||||
useClientContext,
|
||||
useService,
|
||||
} from '@flowgram.ai/free-layout-editor';
|
||||
import { NodeIntoContainerService } from '@flowgram.ai/free-container-plugin';
|
||||
import { IconButton, Dropdown, Typography, Button } from '@douyinfe/semi-ui';
|
||||
import { IconMore, IconSmallTriangleDown, IconSmallTriangleLeft } from '@douyinfe/semi-icons';
|
||||
import { Field, FieldRenderProps } from '@flowgram.ai/free-layout-editor';
|
||||
import { Typography, Button } from '@douyinfe/semi-ui';
|
||||
import { IconSmallTriangleDown, IconSmallTriangleLeft } from '@douyinfe/semi-icons';
|
||||
|
||||
import { Feedback } from '../feedback';
|
||||
import { FlowNodeRegistry } from '../../typings';
|
||||
import { PasteShortcut } from '../../shortcuts/paste';
|
||||
import { CopyShortcut } from '../../shortcuts/copy';
|
||||
import { useIsSidebar, useNodeRenderContext } from '../../hooks';
|
||||
import { NodeMenu } from '../../components/node-menu';
|
||||
import { getIcon } from './utils';
|
||||
import { Header, Operators, Title } from './styles';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
function DropdownContent() {
|
||||
const [key, setKey] = useState(0);
|
||||
const { node, deleteNode } = useNodeRenderContext();
|
||||
const clientContext = useClientContext();
|
||||
const registry = node.getNodeRegistry<FlowNodeRegistry>();
|
||||
const nodeIntoContainerService = useService<NodeIntoContainerService>(NodeIntoContainerService);
|
||||
const canMoveOut = nodeIntoContainerService.canMoveOutContainer(node);
|
||||
|
||||
const rerenderMenu = useCallback(() => {
|
||||
setKey((prevKey) => prevKey + 1);
|
||||
}, []);
|
||||
|
||||
const handleMoveOut = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
nodeIntoContainerService.moveOutContainer({ node });
|
||||
nodeIntoContainerService.removeNodeLines(node);
|
||||
requestAnimationFrame(rerenderMenu);
|
||||
},
|
||||
[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]
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
trigger="hover"
|
||||
position="bottomRight"
|
||||
onVisibleChange={rerenderMenu}
|
||||
render={
|
||||
<Dropdown.Menu key={key}>
|
||||
{canMoveOut && <Dropdown.Item onClick={handleMoveOut}>Move out</Dropdown.Item>}
|
||||
<Dropdown.Item onClick={handleCopy} disabled={registry.meta!.copyDisable === true}>
|
||||
Create Copy
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
onClick={handleDelete}
|
||||
disabled={!!(registry.canDelete?.(clientContext, node) || registry.meta!.deleteDisable)}
|
||||
>
|
||||
Delete
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
color="secondary"
|
||||
size="small"
|
||||
theme="borderless"
|
||||
icon={<IconMore />}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
export function FormHeader() {
|
||||
const { node, expanded, toggleExpand, readonly } = useNodeRenderContext();
|
||||
const { node, expanded, toggleExpand, readonly, deleteNode } = useNodeRenderContext();
|
||||
const isSidebar = useIsSidebar();
|
||||
const handleExpand = (e: React.MouseEvent) => {
|
||||
toggleExpand();
|
||||
@ -124,7 +42,7 @@ export function FormHeader() {
|
||||
)}
|
||||
{readonly ? undefined : (
|
||||
<Operators>
|
||||
<DropdownContent />
|
||||
<NodeMenu node={node} deleteNode={deleteNode} />
|
||||
</Operators>
|
||||
)}
|
||||
</Header>
|
||||
|
||||
@ -7,6 +7,7 @@ import { createFreeSnapPlugin } from '@flowgram.ai/free-snap-plugin';
|
||||
import { createFreeNodePanelPlugin } from '@flowgram.ai/free-node-panel-plugin';
|
||||
import { createFreeLinesPlugin } from '@flowgram.ai/free-lines-plugin';
|
||||
import { FreeLayoutProps } from '@flowgram.ai/free-layout-editor';
|
||||
import { createFreeGroupPlugin } from '@flowgram.ai/free-group-plugin';
|
||||
import { createContainerNodePlugin } from '@flowgram.ai/free-container-plugin';
|
||||
|
||||
import { onDragLineEnd } from '../utils';
|
||||
@ -17,7 +18,7 @@ import { createSyncVariablePlugin } from '../plugins';
|
||||
import { defaultFormMeta } from '../nodes/default-form-meta';
|
||||
import { WorkflowNodeType } from '../nodes';
|
||||
import { SelectorBoxPopover } from '../components/selector-box-popover';
|
||||
import { BaseNode, CommentRender, LineAddButton, NodePanel } from '../components';
|
||||
import { BaseNode, CommentRender, GroupNodeRender, LineAddButton, NodePanel } from '../components';
|
||||
|
||||
export function useEditorProps(
|
||||
initialData: FlowDocumentJSON,
|
||||
@ -225,6 +226,9 @@ export function useEditorProps(
|
||||
* 这个用于 loop 节点子画布的渲染
|
||||
*/
|
||||
createContainerNodePlugin({}),
|
||||
createFreeGroupPlugin({
|
||||
groupNodeRender: GroupNodeRender,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
[]
|
||||
|
||||
@ -74,51 +74,6 @@ export const initialData: FlowDocumentJSON = {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'llm_0',
|
||||
type: 'llm',
|
||||
meta: {
|
||||
position: {
|
||||
x: 1430,
|
||||
y: 0,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
title: 'LLM_0',
|
||||
inputsValues: {
|
||||
modelType: 'gpt-3.5-turbo',
|
||||
temperature: 0.5,
|
||||
systemPrompt: 'You are an AI assistant.',
|
||||
prompt: '',
|
||||
},
|
||||
inputs: {
|
||||
type: 'object',
|
||||
required: ['modelType', 'temperature', 'prompt'],
|
||||
properties: {
|
||||
modelType: {
|
||||
type: 'string',
|
||||
},
|
||||
temperature: {
|
||||
type: 'number',
|
||||
},
|
||||
systemPrompt: {
|
||||
type: 'string',
|
||||
},
|
||||
prompt: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
outputs: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
result: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'end_0',
|
||||
type: 'end',
|
||||
@ -267,7 +222,7 @@ export const initialData: FlowDocumentJSON = {
|
||||
meta: {
|
||||
position: {
|
||||
x: 640,
|
||||
y: 465.5,
|
||||
y: 478,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
@ -278,6 +233,122 @@ export const initialData: FlowDocumentJSON = {
|
||||
note: 'hi ~\n\nthis is a comment node\n\n- flowgram.ai',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'group_V-_st',
|
||||
type: 'group',
|
||||
meta: {
|
||||
position: {
|
||||
x: -1.5112031149433278,
|
||||
y: 0,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
title: 'LLM_Group',
|
||||
color: 'Violet',
|
||||
},
|
||||
blocks: [
|
||||
{
|
||||
id: 'llm_0',
|
||||
type: 'llm',
|
||||
meta: {
|
||||
position: {
|
||||
x: 1660.1942854301792,
|
||||
y: 1.8635936030104148,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
title: 'LLM_0',
|
||||
inputsValues: {
|
||||
modelType: 'gpt-3.5-turbo',
|
||||
temperature: 0.5,
|
||||
systemPrompt: 'You are an AI assistant.',
|
||||
prompt: '',
|
||||
},
|
||||
inputs: {
|
||||
type: 'object',
|
||||
required: ['modelType', 'temperature', 'prompt'],
|
||||
properties: {
|
||||
modelType: {
|
||||
type: 'string',
|
||||
},
|
||||
temperature: {
|
||||
type: 'number',
|
||||
},
|
||||
systemPrompt: {
|
||||
type: 'string',
|
||||
},
|
||||
prompt: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
outputs: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
result: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'llm_l_TcE',
|
||||
type: 'llm',
|
||||
meta: {
|
||||
position: {
|
||||
x: 1202.8281207997074,
|
||||
y: 1.8635936030104148,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
title: 'LLM_1',
|
||||
inputsValues: {},
|
||||
inputs: {
|
||||
type: 'object',
|
||||
required: ['modelType', 'temperature', 'prompt'],
|
||||
properties: {
|
||||
modelType: {
|
||||
type: 'string',
|
||||
},
|
||||
temperature: {
|
||||
type: 'number',
|
||||
},
|
||||
systemPrompt: {
|
||||
type: 'string',
|
||||
},
|
||||
prompt: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
outputs: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
result: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
sourceNodeID: 'llm_l_TcE',
|
||||
targetNodeID: 'llm_0',
|
||||
},
|
||||
{
|
||||
sourceNodeID: 'llm_0',
|
||||
targetNodeID: 'end_0',
|
||||
},
|
||||
{
|
||||
sourceNodeID: 'condition_0',
|
||||
targetNodeID: 'llm_l_TcE',
|
||||
sourcePortID: 'if_0',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
@ -286,7 +357,7 @@ export const initialData: FlowDocumentJSON = {
|
||||
},
|
||||
{
|
||||
sourceNodeID: 'condition_0',
|
||||
targetNodeID: 'llm_0',
|
||||
targetNodeID: 'llm_l_TcE',
|
||||
sourcePortID: 'if_0',
|
||||
},
|
||||
{
|
||||
|
||||
@ -4,9 +4,8 @@ import { FlowNodeRegistry } from '../../typings';
|
||||
export const CommentNodeRegistry: FlowNodeRegistry = {
|
||||
type: WorkflowNodeType.Comment,
|
||||
meta: {
|
||||
isStart: true,
|
||||
isNodeEnd: true,
|
||||
disableSideSheet: true,
|
||||
disableSideBar: true,
|
||||
defaultPorts: [],
|
||||
renderKey: WorkflowNodeType.Comment,
|
||||
size: {
|
||||
width: 240,
|
||||
|
||||
@ -38,7 +38,7 @@ export const LoopNodeRegistry: FlowNodeRegistry = {
|
||||
* 子画布 padding 设置
|
||||
*/
|
||||
padding: () => ({
|
||||
top: 150,
|
||||
top: 125,
|
||||
bottom: 100,
|
||||
left: 100,
|
||||
right: 100,
|
||||
|
||||
@ -4,6 +4,7 @@ import {
|
||||
FreeLayoutPluginContext,
|
||||
FlowNodeEntity,
|
||||
type WorkflowEdgeJSON,
|
||||
WorkflowNodeMeta,
|
||||
} from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { type JsonSchema } from './json-schema';
|
||||
@ -45,11 +46,20 @@ export interface FlowNodeJSON extends FlowNodeJSONDefault {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* You can customize your own node meta
|
||||
* 你可以自定义节点的meta
|
||||
*/
|
||||
export interface FlowNodeMeta extends WorkflowNodeMeta {
|
||||
disableSideBar?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* You can customize your own node registry
|
||||
* 你可以自定义节点的注册器
|
||||
*/
|
||||
export interface FlowNodeRegistry extends FlowNodeRegistryDefault {
|
||||
meta: FlowNodeMeta;
|
||||
info?: {
|
||||
icon: string;
|
||||
description: string;
|
||||
|
||||
138
common/config/rush/pnpm-lock.yaml
generated
138
common/config/rush/pnpm-lock.yaml
generated
@ -199,6 +199,9 @@ importers:
|
||||
'@flowgram.ai/free-container-plugin':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/plugins/free-container-plugin
|
||||
'@flowgram.ai/free-group-plugin':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/plugins/free-group-plugin
|
||||
'@flowgram.ai/free-layout-editor':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/client/free-layout-editor
|
||||
@ -2215,6 +2218,94 @@ importers:
|
||||
'@flowgram.ai/renderer':
|
||||
specifier: workspace:*
|
||||
version: link:../../canvas-engine/renderer
|
||||
'@flowgram.ai/shortcuts-plugin':
|
||||
specifier: workspace:*
|
||||
version: link:../shortcuts-plugin
|
||||
'@flowgram.ai/utils':
|
||||
specifier: workspace:*
|
||||
version: link:../../common/utils
|
||||
inversify:
|
||||
specifier: ^6.0.1
|
||||
version: 6.2.0(reflect-metadata@0.2.2)
|
||||
lodash:
|
||||
specifier: ^4.17.21
|
||||
version: 4.17.21
|
||||
reflect-metadata:
|
||||
specifier: ~0.2.2
|
||||
version: 0.2.2
|
||||
devDependencies:
|
||||
'@flowgram.ai/eslint-config':
|
||||
specifier: workspace:*
|
||||
version: link:../../../config/eslint-config
|
||||
'@flowgram.ai/ts-config':
|
||||
specifier: workspace:*
|
||||
version: link:../../../config/ts-config
|
||||
'@types/bezier-js':
|
||||
specifier: 4.1.3
|
||||
version: 4.1.3
|
||||
'@types/lodash':
|
||||
specifier: ^4.14.137
|
||||
version: 4.17.13
|
||||
'@types/react':
|
||||
specifier: ^18
|
||||
version: 18.3.16
|
||||
'@types/react-dom':
|
||||
specifier: ^18
|
||||
version: 18.3.5(@types/react@18.3.16)
|
||||
'@types/styled-components':
|
||||
specifier: ^5
|
||||
version: 5.1.34
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^0.32.0
|
||||
version: 0.32.4(vitest@0.34.6)
|
||||
eslint:
|
||||
specifier: ^8.54.0
|
||||
version: 8.57.1
|
||||
react:
|
||||
specifier: ^18
|
||||
version: 18.3.1
|
||||
react-dom:
|
||||
specifier: ^18
|
||||
version: 18.3.1(react@18.3.1)
|
||||
styled-components:
|
||||
specifier: ^5
|
||||
version: 5.3.11(@babel/core@7.26.10)(react-dom@18.3.1)(react-is@18.3.1)(react@18.3.1)
|
||||
tsup:
|
||||
specifier: ^8.0.1
|
||||
version: 8.3.5(typescript@5.0.4)
|
||||
typescript:
|
||||
specifier: ^5.0.4
|
||||
version: 5.0.4
|
||||
vitest:
|
||||
specifier: ^0.34.6
|
||||
version: 0.34.6(jsdom@22.1.0)
|
||||
|
||||
../../packages/plugins/free-group-plugin:
|
||||
dependencies:
|
||||
'@flowgram.ai/core':
|
||||
specifier: workspace:*
|
||||
version: link:../../canvas-engine/core
|
||||
'@flowgram.ai/document':
|
||||
specifier: workspace:*
|
||||
version: link:../../canvas-engine/document
|
||||
'@flowgram.ai/free-container-plugin':
|
||||
specifier: workspace:*
|
||||
version: link:../free-container-plugin
|
||||
'@flowgram.ai/free-history-plugin':
|
||||
specifier: workspace:*
|
||||
version: link:../free-history-plugin
|
||||
'@flowgram.ai/free-layout-core':
|
||||
specifier: workspace:*
|
||||
version: link:../../canvas-engine/free-layout-core
|
||||
'@flowgram.ai/free-lines-plugin':
|
||||
specifier: workspace:*
|
||||
version: link:../free-lines-plugin
|
||||
'@flowgram.ai/renderer':
|
||||
specifier: workspace:*
|
||||
version: link:../../canvas-engine/renderer
|
||||
'@flowgram.ai/shortcuts-plugin':
|
||||
specifier: workspace:*
|
||||
version: link:../shortcuts-plugin
|
||||
'@flowgram.ai/utils':
|
||||
specifier: workspace:*
|
||||
version: link:../../common/utils
|
||||
@ -3544,7 +3635,7 @@ packages:
|
||||
'@babel/helpers': 7.27.0
|
||||
'@babel/parser': 7.27.0
|
||||
'@babel/template': 7.27.0
|
||||
'@babel/traverse': 7.27.0
|
||||
'@babel/traverse': 7.27.0(supports-color@5.5.0)
|
||||
'@babel/types': 7.27.0
|
||||
convert-source-map: 2.0.0
|
||||
debug: 4.4.0(supports-color@5.5.0)
|
||||
@ -3704,6 +3795,15 @@ packages:
|
||||
- supports-color
|
||||
dev: false
|
||||
|
||||
/@babel/helper-module-imports@7.25.9:
|
||||
resolution: {integrity: sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
dependencies:
|
||||
'@babel/traverse': 7.27.0(supports-color@5.5.0)
|
||||
'@babel/types': 7.27.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
/@babel/helper-module-imports@7.25.9(supports-color@5.5.0):
|
||||
resolution: {integrity: sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@ -3720,7 +3820,7 @@ packages:
|
||||
'@babel/core': ^7.0.0
|
||||
dependencies:
|
||||
'@babel/core': 7.26.0
|
||||
'@babel/helper-module-imports': 7.25.9(supports-color@5.5.0)
|
||||
'@babel/helper-module-imports': 7.25.9
|
||||
'@babel/helper-validator-identifier': 7.25.9
|
||||
'@babel/traverse': 7.26.4(supports-color@5.5.0)
|
||||
transitivePeerDependencies:
|
||||
@ -3734,7 +3834,7 @@ packages:
|
||||
'@babel/core': ^7.0.0
|
||||
dependencies:
|
||||
'@babel/core': 7.26.10
|
||||
'@babel/helper-module-imports': 7.25.9(supports-color@5.5.0)
|
||||
'@babel/helper-module-imports': 7.25.9
|
||||
'@babel/helper-validator-identifier': 7.25.9
|
||||
'@babel/traverse': 7.26.4(supports-color@5.5.0)
|
||||
transitivePeerDependencies:
|
||||
@ -4241,7 +4341,7 @@ packages:
|
||||
'@babel/core': ^7.0.0-0
|
||||
dependencies:
|
||||
'@babel/core': 7.26.0
|
||||
'@babel/helper-module-imports': 7.25.9(supports-color@5.5.0)
|
||||
'@babel/helper-module-imports': 7.25.9
|
||||
'@babel/helper-plugin-utils': 7.25.9
|
||||
'@babel/helper-remap-async-to-generator': 7.25.9(@babel/core@7.26.0)
|
||||
transitivePeerDependencies:
|
||||
@ -4535,7 +4635,7 @@ packages:
|
||||
dependencies:
|
||||
'@babel/core': 7.26.0
|
||||
'@babel/helper-annotate-as-pure': 7.25.9
|
||||
'@babel/helper-module-imports': 7.25.9(supports-color@5.5.0)
|
||||
'@babel/helper-module-imports': 7.25.9
|
||||
'@babel/helper-plugin-utils': 7.25.9
|
||||
'@babel/plugin-syntax-jsx': 7.25.9(@babel/core@7.26.0)
|
||||
'@babel/types': 7.26.3
|
||||
@ -4800,7 +4900,7 @@ packages:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
/@babel/traverse@7.27.0:
|
||||
/@babel/traverse@7.27.0(supports-color@5.5.0):
|
||||
resolution: {integrity: sha512-19lYZFzYVQkkHkl4Cy4WrAVcqBkgvV2YM2TU3xG6DIwO7O3ecbDPfW3yM3bjAGcqcQHi+CCtjMR3dIEHxsd6bA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
dependencies:
|
||||
@ -9961,7 +10061,7 @@ packages:
|
||||
/estree-util-attach-comments@3.0.0:
|
||||
resolution: {integrity: sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==}
|
||||
dependencies:
|
||||
'@types/estree': 1.0.6
|
||||
'@types/estree': 1.0.7
|
||||
dev: false
|
||||
|
||||
/estree-util-build-jsx@2.2.2:
|
||||
@ -9990,7 +10090,7 @@ packages:
|
||||
/estree-util-scope@1.0.0:
|
||||
resolution: {integrity: sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ==}
|
||||
dependencies:
|
||||
'@types/estree': 1.0.6
|
||||
'@types/estree': 1.0.7
|
||||
devlop: 1.1.0
|
||||
dev: false
|
||||
|
||||
@ -10664,7 +10764,7 @@ packages:
|
||||
/hast-util-to-estree@3.1.1:
|
||||
resolution: {integrity: sha512-IWtwwmPskfSmma9RpzCappDUitC8t5jhAynHhc1m2+5trOgsrp7txscUSavc5Ic8PATyAjfrCK1wgtxh2cICVQ==}
|
||||
dependencies:
|
||||
'@types/estree': 1.0.6
|
||||
'@types/estree': 1.0.7
|
||||
'@types/estree-jsx': 1.0.5
|
||||
'@types/hast': 3.0.4
|
||||
comma-separated-tokens: 2.0.3
|
||||
@ -10687,7 +10787,7 @@ packages:
|
||||
/hast-util-to-jsx-runtime@2.3.2:
|
||||
resolution: {integrity: sha512-1ngXYb+V9UT5h+PxNRa1O1FYguZK/XL+gkeqvp7EdHlB9oHUG0eYRo/vY5inBdcqo3RkPMC58/H94HvkbfGdyg==}
|
||||
dependencies:
|
||||
'@types/estree': 1.0.6
|
||||
'@types/estree': 1.0.7
|
||||
'@types/hast': 3.0.4
|
||||
'@types/unist': 3.0.3
|
||||
comma-separated-tokens: 2.0.3
|
||||
@ -12364,7 +12464,7 @@ packages:
|
||||
/micromark-extension-mdx-expression@3.0.0:
|
||||
resolution: {integrity: sha512-sI0nwhUDz97xyzqJAbHQhp5TfaxEvZZZ2JDqUo+7NvyIYG6BZ5CPPqj2ogUoPJlmXHBnyZUzISg9+oUmU6tUjQ==}
|
||||
dependencies:
|
||||
'@types/estree': 1.0.6
|
||||
'@types/estree': 1.0.7
|
||||
devlop: 1.1.0
|
||||
micromark-factory-mdx-expression: 2.0.2
|
||||
micromark-factory-space: 2.0.1
|
||||
@ -12392,7 +12492,7 @@ packages:
|
||||
resolution: {integrity: sha512-vNuFb9czP8QCtAQcEJn0UJQJZA8Dk6DXKBqx+bg/w0WGuSxDxNr7hErW89tHUY31dUW4NqEOWwmEUNhjTFmHkg==}
|
||||
dependencies:
|
||||
'@types/acorn': 4.0.6
|
||||
'@types/estree': 1.0.6
|
||||
'@types/estree': 1.0.7
|
||||
devlop: 1.1.0
|
||||
estree-util-is-identifier-name: 3.0.0
|
||||
micromark-factory-mdx-expression: 2.0.2
|
||||
@ -12431,7 +12531,7 @@ packages:
|
||||
/micromark-extension-mdxjs-esm@3.0.0:
|
||||
resolution: {integrity: sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==}
|
||||
dependencies:
|
||||
'@types/estree': 1.0.6
|
||||
'@types/estree': 1.0.7
|
||||
devlop: 1.1.0
|
||||
micromark-core-commonmark: 2.0.2
|
||||
micromark-util-character: 2.1.1
|
||||
@ -12514,7 +12614,7 @@ packages:
|
||||
/micromark-factory-mdx-expression@2.0.2:
|
||||
resolution: {integrity: sha512-5E5I2pFzJyg2CtemqAbcyCktpHXuJbABnsb32wX2U8IQKhhVFBqkcZR5LRm1WVoFqa4kTueZK4abep7wdo9nrw==}
|
||||
dependencies:
|
||||
'@types/estree': 1.0.6
|
||||
'@types/estree': 1.0.7
|
||||
devlop: 1.1.0
|
||||
micromark-factory-space: 2.0.1
|
||||
micromark-util-character: 2.1.1
|
||||
@ -12675,7 +12775,7 @@ packages:
|
||||
resolution: {integrity: sha512-Fk+xmBrOv9QZnEDguL9OI9/NQQp6Hz4FuQ4YmCb/5V7+9eAh1s6AYSvL20kHkD67YIg7EpE54TiSlcsf3vyZgA==}
|
||||
dependencies:
|
||||
'@types/acorn': 4.0.6
|
||||
'@types/estree': 1.0.6
|
||||
'@types/estree': 1.0.7
|
||||
'@types/unist': 3.0.3
|
||||
devlop: 1.1.0
|
||||
estree-util-visit: 2.0.0
|
||||
@ -13723,7 +13823,7 @@ packages:
|
||||
/recma-build-jsx@1.0.0:
|
||||
resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==}
|
||||
dependencies:
|
||||
'@types/estree': 1.0.6
|
||||
'@types/estree': 1.0.7
|
||||
estree-util-build-jsx: 3.0.1
|
||||
vfile: 6.0.3
|
||||
dev: false
|
||||
@ -13743,7 +13843,7 @@ packages:
|
||||
/recma-parse@1.0.0:
|
||||
resolution: {integrity: sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==}
|
||||
dependencies:
|
||||
'@types/estree': 1.0.6
|
||||
'@types/estree': 1.0.7
|
||||
esast-util-from-js: 2.0.1
|
||||
unified: 11.0.5
|
||||
vfile: 6.0.3
|
||||
@ -13752,7 +13852,7 @@ packages:
|
||||
/recma-stringify@1.0.0:
|
||||
resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==}
|
||||
dependencies:
|
||||
'@types/estree': 1.0.6
|
||||
'@types/estree': 1.0.7
|
||||
estree-util-to-js: 2.0.0
|
||||
unified: 11.0.5
|
||||
vfile: 6.0.3
|
||||
@ -13854,7 +13954,7 @@ packages:
|
||||
/rehype-recma@1.0.0:
|
||||
resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==}
|
||||
dependencies:
|
||||
'@types/estree': 1.0.6
|
||||
'@types/estree': 1.0.7
|
||||
'@types/hast': 3.0.4
|
||||
hast-util-to-estree: 3.1.1
|
||||
transitivePeerDependencies:
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
"douyinfe",
|
||||
"flowgram",
|
||||
"flowgram.ai",
|
||||
"Hoverable",
|
||||
"openbracket",
|
||||
"rsbuild",
|
||||
"rspack",
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { inject, injectable } from 'inversify';
|
||||
import { EntityManager } from '@flowgram.ai/core';
|
||||
import { Emitter } from '@flowgram.ai/utils';
|
||||
import { EntityManager } from '@flowgram.ai/core';
|
||||
|
||||
import { FlowGroupUtils } from './flow-group-service/flow-group-utils';
|
||||
import { FlowNodeBaseType, FlowOperationBaseService, LABEL_SIDE_TYPE } from '../typings';
|
||||
import { FlowGroupController } from '../services';
|
||||
import { FlowDocument } from '../flow-document';
|
||||
import { FlowNodeEntity, FlowRendererStateEntity } from '../entities';
|
||||
import { FlowNodeRenderData } from '../datas';
|
||||
@ -126,9 +126,11 @@ export class FlowDragService {
|
||||
}
|
||||
|
||||
// 分组节点不能嵌套
|
||||
const hasGroupNode = this.dragNodes.some(node => node.flowNodeType === FlowNodeBaseType.GROUP);
|
||||
const hasGroupNode = this.dragNodes.some(
|
||||
(node) => node.flowNodeType === FlowNodeBaseType.GROUP
|
||||
);
|
||||
if (hasGroupNode) {
|
||||
const group = FlowGroupController.getNodeRecursionGroupController(node);
|
||||
const group = FlowGroupUtils.getNodeRecursionGroupController(node);
|
||||
if (group) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -1,305 +0,0 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
import { inject, injectable } from 'inversify';
|
||||
import { Rectangle } from '@flowgram.ai/utils';
|
||||
import { EntityManager } from '@flowgram.ai/core';
|
||||
|
||||
import { FlowNodeBaseType, FlowOperationBaseService, OperationType } from '../typings';
|
||||
import { FlowNodeEntity } from '../entities';
|
||||
import { FlowNodeRenderData, FlowNodeTransformData } from '../datas';
|
||||
|
||||
@injectable()
|
||||
/** 分组服务 */
|
||||
export class FlowGroupService {
|
||||
@inject(EntityManager) public readonly entityManager: EntityManager;
|
||||
|
||||
@inject(FlowOperationBaseService)
|
||||
public readonly operationService: FlowOperationBaseService;
|
||||
|
||||
/** 创建分组节点 */
|
||||
public createGroup(nodes: FlowNodeEntity[]): FlowNodeEntity | undefined {
|
||||
if (!nodes || !Array.isArray(nodes) || nodes.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (!FlowGroupController.validate(nodes)) {
|
||||
return;
|
||||
}
|
||||
const sortedNodes: FlowNodeEntity[] = nodes.sort((a, b) => a.index - b.index);
|
||||
const fromNode = sortedNodes[0];
|
||||
const groupId = `group_${nanoid(5)}`;
|
||||
this.operationService.apply({
|
||||
type: OperationType.createGroup,
|
||||
value: {
|
||||
targetId: fromNode.id,
|
||||
groupId,
|
||||
nodeIds: nodes.map((node) => node.id),
|
||||
},
|
||||
});
|
||||
const groupNode = this.entityManager.getEntityById<FlowNodeEntity>(groupId);
|
||||
if (!groupNode) {
|
||||
return;
|
||||
}
|
||||
const group = this.groupController(groupNode);
|
||||
if (!group) {
|
||||
return;
|
||||
}
|
||||
group.expand();
|
||||
return groupNode;
|
||||
}
|
||||
|
||||
/** 删除分组 */
|
||||
public deleteGroup(groupNode: FlowNodeEntity): void {
|
||||
const json = groupNode.toJSON();
|
||||
if (!groupNode.pre || !json) {
|
||||
return;
|
||||
}
|
||||
this.operationService.apply({
|
||||
type: OperationType.deleteNodes,
|
||||
value: {
|
||||
fromId: groupNode.pre.id,
|
||||
nodes: [json],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 取消分组 */
|
||||
public ungroup(groupNode: FlowNodeEntity): void {
|
||||
const group = this.groupController(groupNode);
|
||||
if (!group) {
|
||||
return;
|
||||
}
|
||||
const nodes = group.nodes;
|
||||
if (!groupNode.pre) {
|
||||
return;
|
||||
}
|
||||
group.collapse();
|
||||
this.operationService.apply({
|
||||
type: OperationType.ungroup,
|
||||
value: {
|
||||
groupId: groupNode.id,
|
||||
targetId: groupNode.pre.id,
|
||||
nodeIds: nodes.map((node) => node.id),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 返回所有分组节点 */
|
||||
public getAllGroups(): FlowGroupController[] {
|
||||
const allNodes = this.entityManager.getEntities<FlowNodeEntity>(FlowNodeEntity);
|
||||
const groupNodes = allNodes.filter((node) => node.flowNodeType === FlowNodeBaseType.GROUP);
|
||||
return groupNodes
|
||||
.map((node) => this.groupController(node))
|
||||
.filter(Boolean) as FlowGroupController[];
|
||||
}
|
||||
|
||||
/** 获取分组控制器*/
|
||||
public groupController(group: FlowNodeEntity): FlowGroupController | undefined {
|
||||
return FlowGroupController.create(group);
|
||||
}
|
||||
|
||||
public static validate(nodes: FlowNodeEntity[]): boolean {
|
||||
return FlowGroupController.validate(nodes);
|
||||
}
|
||||
}
|
||||
|
||||
/** 分组控制器 */
|
||||
export class FlowGroupController {
|
||||
private constructor(public readonly groupNode: FlowNodeEntity) {}
|
||||
|
||||
public get nodes(): FlowNodeEntity[] {
|
||||
return this.groupNode.collapsedChildren || [];
|
||||
}
|
||||
|
||||
public get collapsed(): boolean {
|
||||
const groupTransformData = this.groupNode.getData<FlowNodeTransformData>(FlowNodeTransformData);
|
||||
return groupTransformData.collapsed;
|
||||
}
|
||||
|
||||
public collapse(): void {
|
||||
this.collapsed = true;
|
||||
}
|
||||
|
||||
public expand(): void {
|
||||
this.collapsed = false;
|
||||
}
|
||||
|
||||
/** 获取分组外围的最大边框 */
|
||||
public get bounds(): Rectangle {
|
||||
const groupNodeBounds =
|
||||
this.groupNode.getData<FlowNodeTransformData>(FlowNodeTransformData).bounds;
|
||||
return groupNodeBounds;
|
||||
}
|
||||
|
||||
/** 是否是开始节点 */
|
||||
public isStartNode(node?: FlowNodeEntity): boolean {
|
||||
if (!node) {
|
||||
return false;
|
||||
}
|
||||
const nodes = this.nodes;
|
||||
if (!nodes[0]) {
|
||||
return false;
|
||||
}
|
||||
return node.id === nodes[0].id;
|
||||
}
|
||||
|
||||
/** 是否是结束节点 */
|
||||
public isEndNode(node?: FlowNodeEntity): boolean {
|
||||
if (!node) {
|
||||
return false;
|
||||
}
|
||||
const nodes = this.nodes;
|
||||
if (!nodes[nodes.length - 1]) {
|
||||
return false;
|
||||
}
|
||||
return node.id === nodes[nodes.length - 1].id;
|
||||
}
|
||||
|
||||
public set note(note: string) {
|
||||
this.groupNode.getNodeMeta().note = note;
|
||||
}
|
||||
|
||||
public get note(): string {
|
||||
return this.groupNode.getNodeMeta().note || '';
|
||||
}
|
||||
|
||||
public set noteHeight(height: number) {
|
||||
this.groupNode.getNodeMeta().noteHeight = height;
|
||||
}
|
||||
|
||||
public get noteHeight(): number {
|
||||
return this.groupNode.getNodeMeta().noteHeight || 0;
|
||||
}
|
||||
|
||||
public get positionConfig(): Record<string, number> {
|
||||
return this.groupNode.getNodeMeta().positionConfig;
|
||||
}
|
||||
|
||||
private set collapsed(collapsed: boolean) {
|
||||
const groupTransformData = this.groupNode.getData<FlowNodeTransformData>(FlowNodeTransformData);
|
||||
groupTransformData.collapsed = collapsed;
|
||||
groupTransformData.localDirty = true;
|
||||
if (groupTransformData.parent) groupTransformData.parent.localDirty = true;
|
||||
if (groupTransformData.parent?.firstChild)
|
||||
groupTransformData.parent.firstChild.localDirty = true;
|
||||
}
|
||||
|
||||
public set hovered(hovered: boolean) {
|
||||
const groupRenderData = this.groupNode.getData<FlowNodeRenderData>(FlowNodeRenderData);
|
||||
if (hovered) {
|
||||
groupRenderData.toggleMouseEnter();
|
||||
} else {
|
||||
groupRenderData.toggleMouseLeave();
|
||||
}
|
||||
if (groupRenderData.hovered === hovered) {
|
||||
return;
|
||||
}
|
||||
groupRenderData.hovered = hovered;
|
||||
}
|
||||
|
||||
public get hovered(): boolean {
|
||||
const groupRenderData = this.groupNode.getData<FlowNodeRenderData>(FlowNodeRenderData);
|
||||
return groupRenderData.hovered;
|
||||
}
|
||||
|
||||
public static create(groupNode?: FlowNodeEntity): FlowGroupController | undefined {
|
||||
if (!groupNode) {
|
||||
return;
|
||||
}
|
||||
if (!FlowGroupController.isGroupNode(groupNode)) {
|
||||
return;
|
||||
}
|
||||
return new FlowGroupController(groupNode);
|
||||
}
|
||||
|
||||
/** 判断节点能否组成分组 */
|
||||
public static validate(nodes: FlowNodeEntity[]): boolean {
|
||||
if (!nodes || !Array.isArray(nodes) || nodes.length === 0) {
|
||||
// 参数不合法
|
||||
return false;
|
||||
}
|
||||
|
||||
// 判断是否有分组节点
|
||||
const isGroupRelatedNode = nodes.some((node) => FlowGroupController.isGroupNode(node));
|
||||
if (isGroupRelatedNode) return false;
|
||||
|
||||
// 判断是否有节点已经处于分组中
|
||||
const hasGroup = nodes.some((node) => node && this.isNodeInGroup(node));
|
||||
if (hasGroup) return false;
|
||||
|
||||
// 判断是否来自同一个父亲
|
||||
const parent = nodes[0].parent;
|
||||
const isSameParent = nodes.every((node) => node.parent === parent);
|
||||
if (!isSameParent) return false;
|
||||
|
||||
// 判断节点索引是否连续
|
||||
const indexes = nodes.map((node) => node.index).sort((a, b) => a - b);
|
||||
const isIndexContinuous = indexes.every((index, i, arr) => {
|
||||
if (i === 0) {
|
||||
return true;
|
||||
}
|
||||
return index === arr[i - 1] + 1;
|
||||
});
|
||||
if (!isIndexContinuous) return false;
|
||||
|
||||
// 判断节点父亲是否已经在分组中
|
||||
const parents = this.findNodeParents(nodes[0]);
|
||||
const parentsInGroup = parents.some((parent) => this.isNodeInGroup(parent));
|
||||
if (parentsInGroup) return false;
|
||||
|
||||
// 参数正确
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 获取节点分组控制 */
|
||||
public static getNodeGroupController(node?: FlowNodeEntity): FlowGroupController | undefined {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
if (!this.isNodeInGroup(node)) {
|
||||
return;
|
||||
}
|
||||
const groupNode = node?.parent;
|
||||
return FlowGroupController.create(groupNode);
|
||||
}
|
||||
|
||||
/** 向上递归查找分组递归控制 */
|
||||
public static getNodeRecursionGroupController(
|
||||
node?: FlowNodeEntity
|
||||
): FlowGroupController | undefined {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
const group = this.getNodeGroupController(node);
|
||||
if (group) {
|
||||
return group;
|
||||
}
|
||||
if (node.parent) {
|
||||
return this.getNodeRecursionGroupController(node.parent);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
/** 是否分组节点 */
|
||||
public static isGroupNode(group: FlowNodeEntity): boolean {
|
||||
return group.flowNodeType === FlowNodeBaseType.GROUP;
|
||||
}
|
||||
|
||||
/** 找到节点所有上级 */
|
||||
private static findNodeParents(node: FlowNodeEntity): FlowNodeEntity[] {
|
||||
const parents = [];
|
||||
let parent = node.parent;
|
||||
while (parent) {
|
||||
parents.push(parent);
|
||||
parent = parent.parent;
|
||||
}
|
||||
return parents;
|
||||
}
|
||||
|
||||
/** 节点是否处于分组中 */
|
||||
private static isNodeInGroup(node: FlowNodeEntity): boolean {
|
||||
// 处于分组中
|
||||
if (node?.parent?.flowNodeType === FlowNodeBaseType.GROUP) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,115 @@
|
||||
import { Rectangle } from '@flowgram.ai/utils';
|
||||
|
||||
import { FlowNodeEntity } from '../../entities';
|
||||
import { FlowNodeRenderData, FlowNodeTransformData } from '../../datas';
|
||||
import { FlowGroupUtils } from './flow-group-utils';
|
||||
|
||||
/** 分组控制器 */
|
||||
export class FlowGroupController {
|
||||
private constructor(public readonly groupNode: FlowNodeEntity) {}
|
||||
|
||||
public get nodes(): FlowNodeEntity[] {
|
||||
return this.groupNode.collapsedChildren || [];
|
||||
}
|
||||
|
||||
public get collapsed(): boolean {
|
||||
const groupTransformData = this.groupNode.getData<FlowNodeTransformData>(FlowNodeTransformData);
|
||||
return groupTransformData.collapsed;
|
||||
}
|
||||
|
||||
public collapse(): void {
|
||||
this.collapsed = true;
|
||||
}
|
||||
|
||||
public expand(): void {
|
||||
this.collapsed = false;
|
||||
}
|
||||
|
||||
/** 获取分组外围的最大边框 */
|
||||
public get bounds(): Rectangle {
|
||||
const groupNodeBounds =
|
||||
this.groupNode.getData<FlowNodeTransformData>(FlowNodeTransformData).bounds;
|
||||
return groupNodeBounds;
|
||||
}
|
||||
|
||||
/** 是否是开始节点 */
|
||||
public isStartNode(node?: FlowNodeEntity): boolean {
|
||||
if (!node) {
|
||||
return false;
|
||||
}
|
||||
const nodes = this.nodes;
|
||||
if (!nodes[0]) {
|
||||
return false;
|
||||
}
|
||||
return node.id === nodes[0].id;
|
||||
}
|
||||
|
||||
/** 是否是结束节点 */
|
||||
public isEndNode(node?: FlowNodeEntity): boolean {
|
||||
if (!node) {
|
||||
return false;
|
||||
}
|
||||
const nodes = this.nodes;
|
||||
if (!nodes[nodes.length - 1]) {
|
||||
return false;
|
||||
}
|
||||
return node.id === nodes[nodes.length - 1].id;
|
||||
}
|
||||
|
||||
public set note(note: string) {
|
||||
this.groupNode.getNodeMeta().note = note;
|
||||
}
|
||||
|
||||
public get note(): string {
|
||||
return this.groupNode.getNodeMeta().note || '';
|
||||
}
|
||||
|
||||
public set noteHeight(height: number) {
|
||||
this.groupNode.getNodeMeta().noteHeight = height;
|
||||
}
|
||||
|
||||
public get noteHeight(): number {
|
||||
return this.groupNode.getNodeMeta().noteHeight || 0;
|
||||
}
|
||||
|
||||
public get positionConfig(): Record<string, number> {
|
||||
return this.groupNode.getNodeMeta().positionConfig;
|
||||
}
|
||||
|
||||
private set collapsed(collapsed: boolean) {
|
||||
const groupTransformData = this.groupNode.getData<FlowNodeTransformData>(FlowNodeTransformData);
|
||||
groupTransformData.collapsed = collapsed;
|
||||
groupTransformData.localDirty = true;
|
||||
if (groupTransformData.parent) groupTransformData.parent.localDirty = true;
|
||||
if (groupTransformData.parent?.firstChild)
|
||||
groupTransformData.parent.firstChild.localDirty = true;
|
||||
}
|
||||
|
||||
public set hovered(hovered: boolean) {
|
||||
const groupRenderData = this.groupNode.getData<FlowNodeRenderData>(FlowNodeRenderData);
|
||||
if (hovered) {
|
||||
groupRenderData.toggleMouseEnter();
|
||||
} else {
|
||||
groupRenderData.toggleMouseLeave();
|
||||
}
|
||||
if (groupRenderData.hovered === hovered) {
|
||||
return;
|
||||
}
|
||||
groupRenderData.hovered = hovered;
|
||||
}
|
||||
|
||||
public get hovered(): boolean {
|
||||
const groupRenderData = this.groupNode.getData<FlowNodeRenderData>(FlowNodeRenderData);
|
||||
return groupRenderData.hovered;
|
||||
}
|
||||
|
||||
public static create(groupNode?: FlowNodeEntity): FlowGroupController | undefined {
|
||||
if (!groupNode) {
|
||||
return;
|
||||
}
|
||||
if (!FlowGroupUtils.isGroupNode(groupNode)) {
|
||||
return;
|
||||
}
|
||||
return new FlowGroupController(groupNode);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,102 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
import { inject, injectable } from 'inversify';
|
||||
import { EntityManager } from '@flowgram.ai/core';
|
||||
|
||||
import { FlowNodeBaseType, FlowOperationBaseService, OperationType } from '../../typings';
|
||||
import { FlowNodeEntity } from '../../entities';
|
||||
import { FlowGroupUtils } from './flow-group-utils';
|
||||
import { FlowGroupController } from './flow-group-controller';
|
||||
|
||||
@injectable()
|
||||
/** 分组服务 */
|
||||
export class FlowGroupService {
|
||||
@inject(EntityManager) public readonly entityManager: EntityManager;
|
||||
|
||||
@inject(FlowOperationBaseService)
|
||||
public readonly operationService: FlowOperationBaseService;
|
||||
|
||||
/** 创建分组节点 */
|
||||
public createGroup(nodes: FlowNodeEntity[]): FlowNodeEntity | undefined {
|
||||
if (!nodes || !Array.isArray(nodes) || nodes.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (!FlowGroupUtils.validate(nodes)) {
|
||||
return;
|
||||
}
|
||||
const sortedNodes: FlowNodeEntity[] = nodes.sort((a, b) => a.index - b.index);
|
||||
const fromNode = sortedNodes[0];
|
||||
const groupId = `group_${nanoid(5)}`;
|
||||
this.operationService.apply({
|
||||
type: OperationType.createGroup,
|
||||
value: {
|
||||
targetId: fromNode.id,
|
||||
groupId,
|
||||
nodeIds: nodes.map((node) => node.id),
|
||||
},
|
||||
});
|
||||
const groupNode = this.entityManager.getEntityById<FlowNodeEntity>(groupId);
|
||||
if (!groupNode) {
|
||||
return;
|
||||
}
|
||||
const group = this.groupController(groupNode);
|
||||
if (!group) {
|
||||
return;
|
||||
}
|
||||
group.expand();
|
||||
return groupNode;
|
||||
}
|
||||
|
||||
/** 删除分组 */
|
||||
public deleteGroup(groupNode: FlowNodeEntity): void {
|
||||
const json = groupNode.toJSON();
|
||||
if (!groupNode.pre || !json) {
|
||||
return;
|
||||
}
|
||||
this.operationService.apply({
|
||||
type: OperationType.deleteNodes,
|
||||
value: {
|
||||
fromId: groupNode.pre.id,
|
||||
nodes: [json],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 取消分组 */
|
||||
public ungroup(groupNode: FlowNodeEntity): void {
|
||||
const group = this.groupController(groupNode);
|
||||
if (!group) {
|
||||
return;
|
||||
}
|
||||
const nodes = group.nodes;
|
||||
if (!groupNode.pre) {
|
||||
return;
|
||||
}
|
||||
group.collapse();
|
||||
this.operationService.apply({
|
||||
type: OperationType.ungroup,
|
||||
value: {
|
||||
groupId: groupNode.id,
|
||||
targetId: groupNode.pre.id,
|
||||
nodeIds: nodes.map((node) => node.id),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 返回所有分组节点 */
|
||||
public getAllGroups(): FlowGroupController[] {
|
||||
const allNodes = this.entityManager.getEntities<FlowNodeEntity>(FlowNodeEntity);
|
||||
const groupNodes = allNodes.filter((node) => node.flowNodeType === FlowNodeBaseType.GROUP);
|
||||
return groupNodes
|
||||
.map((node) => this.groupController(node))
|
||||
.filter(Boolean) as FlowGroupController[];
|
||||
}
|
||||
|
||||
/** 获取分组控制器*/
|
||||
public groupController(group: FlowNodeEntity): FlowGroupController | undefined {
|
||||
return FlowGroupController.create(group);
|
||||
}
|
||||
|
||||
public static validate(nodes: FlowNodeEntity[]): boolean {
|
||||
return FlowGroupUtils.validate(nodes);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,99 @@
|
||||
import { FlowNodeBaseType } from '../../typings';
|
||||
import { FlowNodeEntity } from '../../entities';
|
||||
import { FlowGroupController } from './flow-group-controller';
|
||||
|
||||
export namespace FlowGroupUtils {
|
||||
/** 找到节点所有上级 */
|
||||
const findNodeParents = (node: FlowNodeEntity): FlowNodeEntity[] => {
|
||||
const parents = [];
|
||||
let parent = node.parent;
|
||||
while (parent) {
|
||||
parents.push(parent);
|
||||
parent = parent.parent;
|
||||
}
|
||||
return parents;
|
||||
};
|
||||
|
||||
/** 节点是否处于分组中 */
|
||||
const isNodeInGroup = (node: FlowNodeEntity): boolean => {
|
||||
// 处于分组中
|
||||
if (node?.parent?.flowNodeType === FlowNodeBaseType.GROUP) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/** 判断节点能否组成分组 */
|
||||
export const validate = (nodes: FlowNodeEntity[]): boolean => {
|
||||
if (!nodes || !Array.isArray(nodes) || nodes.length === 0) {
|
||||
// 参数不合法
|
||||
return false;
|
||||
}
|
||||
|
||||
// 判断是否有分组节点
|
||||
const isGroupRelatedNode = nodes.some((node) => isGroupNode(node));
|
||||
if (isGroupRelatedNode) return false;
|
||||
|
||||
// 判断是否有节点已经处于分组中
|
||||
const hasGroup = nodes.some((node) => node && isNodeInGroup(node));
|
||||
if (hasGroup) return false;
|
||||
|
||||
// 判断是否来自同一个父亲
|
||||
const parent = nodes[0].parent;
|
||||
const isSameParent = nodes.every((node) => node.parent === parent);
|
||||
if (!isSameParent) return false;
|
||||
|
||||
// 判断节点索引是否连续
|
||||
const indexes = nodes.map((node) => node.index).sort((a, b) => a - b);
|
||||
const isIndexContinuous = indexes.every((index, i, arr) => {
|
||||
if (i === 0) {
|
||||
return true;
|
||||
}
|
||||
return index === arr[i - 1] + 1;
|
||||
});
|
||||
if (!isIndexContinuous) return false;
|
||||
|
||||
// 判断节点父亲是否已经在分组中
|
||||
const parents = findNodeParents(nodes[0]);
|
||||
const parentsInGroup = parents.some((parent) => isNodeInGroup(parent));
|
||||
if (parentsInGroup) return false;
|
||||
|
||||
// 参数正确
|
||||
return true;
|
||||
};
|
||||
|
||||
/** 获取节点分组控制 */
|
||||
export const getNodeGroupController = (
|
||||
node?: FlowNodeEntity
|
||||
): FlowGroupController | undefined => {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
if (!isNodeInGroup(node)) {
|
||||
return;
|
||||
}
|
||||
const groupNode = node?.parent;
|
||||
return FlowGroupController.create(groupNode);
|
||||
};
|
||||
|
||||
/** 向上递归查找分组递归控制 */
|
||||
export const getNodeRecursionGroupController = (
|
||||
node?: FlowNodeEntity
|
||||
): FlowGroupController | undefined => {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
const group = getNodeGroupController(node);
|
||||
if (group) {
|
||||
return group;
|
||||
}
|
||||
if (node.parent) {
|
||||
return getNodeRecursionGroupController(node.parent);
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
/** 是否分组节点 */
|
||||
export const isGroupNode = (group: FlowNodeEntity): boolean =>
|
||||
group.flowNodeType === FlowNodeBaseType.GROUP;
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
export { FlowGroupController } from './flow-group-controller';
|
||||
export { FlowGroupService } from './flow-group-service';
|
||||
@ -1,4 +1,11 @@
|
||||
import { inject, injectable } from 'inversify';
|
||||
import {
|
||||
type IPoint,
|
||||
PaddingSchema,
|
||||
Rectangle,
|
||||
type ScrollSchema,
|
||||
SizeSchema,
|
||||
} from '@flowgram.ai/utils';
|
||||
import {
|
||||
type FlowDocument,
|
||||
type FlowLayout,
|
||||
@ -7,13 +14,6 @@ import {
|
||||
FlowNodeTransformData,
|
||||
} from '@flowgram.ai/document';
|
||||
import { PlaygroundConfigEntity, TransformData } from '@flowgram.ai/core';
|
||||
import {
|
||||
type IPoint,
|
||||
PaddingSchema,
|
||||
Rectangle,
|
||||
type ScrollSchema,
|
||||
SizeSchema,
|
||||
} from '@flowgram.ai/utils';
|
||||
|
||||
export const FREE_LAYOUT_KEY = 'free-layout';
|
||||
/**
|
||||
@ -64,6 +64,22 @@ export class FreeLayout implements FlowLayout {
|
||||
parentTransform.transform.fireChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新所有受影响的上下游节点
|
||||
*/
|
||||
updateAffectedTransform(node: FlowNodeEntity): void {
|
||||
const transformData = node.transform;
|
||||
if (!transformData.localDirty) {
|
||||
return;
|
||||
}
|
||||
const allParents = this.getAllParents(node);
|
||||
const allBlocks = this.getAllBlocks(node).reverse();
|
||||
const affectedNodes = [...allBlocks, ...allParents];
|
||||
affectedNodes.forEach((node) => {
|
||||
this.fireChange(node);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取节点的 padding 数据
|
||||
* @param node
|
||||
@ -83,7 +99,7 @@ export class FreeLayout implements FlowLayout {
|
||||
*/
|
||||
getInitScroll(contentSize: SizeSchema): ScrollSchema {
|
||||
const bounds = Rectangle.enlarge(
|
||||
this.document.getAllNodes().map(node => node.getData<TransformData>(TransformData).bounds),
|
||||
this.document.getAllNodes().map((node) => node.getData<TransformData>(TransformData).bounds)
|
||||
).pad(30, 30); // 留出 30 像素的边界
|
||||
const viewport = this.playgroundConfig.getViewport(false);
|
||||
const zoom = SizeSchema.fixSize(bounds, viewport);
|
||||
@ -113,4 +129,33 @@ export class FreeLayout implements FlowLayout {
|
||||
getDefaultNodeOrigin(): IPoint {
|
||||
return { x: 0.5, y: 0 };
|
||||
}
|
||||
|
||||
private getAllParents(node: FlowNodeEntity): FlowNodeEntity[] {
|
||||
const parents: FlowNodeEntity[] = [];
|
||||
let current = node.parent;
|
||||
|
||||
while (current) {
|
||||
parents.push(current);
|
||||
current = current.parent;
|
||||
}
|
||||
|
||||
return parents;
|
||||
}
|
||||
|
||||
private getAllBlocks(node: FlowNodeEntity): FlowNodeEntity[] {
|
||||
return node.blocks.reduce<FlowNodeEntity[]>(
|
||||
(acc, child) => [...acc, ...this.getAllBlocks(child)],
|
||||
[node]
|
||||
);
|
||||
}
|
||||
|
||||
private fireChange(node?: FlowNodeEntity): void {
|
||||
const transformData = node?.transform;
|
||||
if (!node || !transformData?.localDirty) {
|
||||
return;
|
||||
}
|
||||
node.clearMemoGlobal();
|
||||
node.clearMemoLocal();
|
||||
transformData.transform.fireChange();
|
||||
}
|
||||
}
|
||||
|
||||
@ -176,17 +176,10 @@ export class WorkflowDragService {
|
||||
x: nodeStartPosition.x + offset.x,
|
||||
y: nodeStartPosition.y + offset.y,
|
||||
};
|
||||
if (node.collapsedChildren?.length > 0) {
|
||||
// 嵌套情况下需将子节点 transform 设为 dirty
|
||||
node.collapsedChildren.forEach((childNode) => {
|
||||
const childNodeTransformData =
|
||||
childNode.getData<FlowNodeTransformData>(FlowNodeTransformData);
|
||||
childNodeTransformData.fireChange();
|
||||
});
|
||||
}
|
||||
transform.update({
|
||||
position: newPosition,
|
||||
});
|
||||
this.document.layout.updateAffectedTransform(node);
|
||||
positions.push(newPosition);
|
||||
});
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { inject, injectable } from 'inversify';
|
||||
import { EntityManager } from '@flowgram.ai/core';
|
||||
import { Emitter, type IPoint } from '@flowgram.ai/utils';
|
||||
import { EntityManager } from '@flowgram.ai/core';
|
||||
|
||||
import {
|
||||
type WorkflowLineEntity,
|
||||
@ -11,7 +11,10 @@ import {
|
||||
/**
|
||||
* 可 Hover 的节点 类型
|
||||
*/
|
||||
export type WorkfloEntityHoverable = WorkflowNodeEntity | WorkflowLineEntity | WorkflowPortEntity;
|
||||
export type WorkflowEntityHoverable = WorkflowNodeEntity | WorkflowLineEntity | WorkflowPortEntity;
|
||||
|
||||
/** @deprecated */
|
||||
export type WorkfloEntityHoverable = WorkflowEntityHoverable;
|
||||
/**
|
||||
* hover 状态管理
|
||||
*/
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import { FlowNodeTransformData, type FlowNodeEntity } from '@flowgram.ai/document';
|
||||
import { TransformData, startTween } from '@flowgram.ai/core';
|
||||
import { type IPoint } from '@flowgram.ai/utils';
|
||||
import { FlowNodeTransformData } from '@flowgram.ai/document';
|
||||
import { TransformData, startTween } from '@flowgram.ai/core';
|
||||
|
||||
import { type WorkflowDocument } from '../workflow-document';
|
||||
import { type WorkflowNodeEntity } from '../entities';
|
||||
|
||||
/**
|
||||
* Coze 中节点坐标,以卡片顶部中间为原点。
|
||||
@ -9,12 +12,12 @@ import { type IPoint } from '@flowgram.ai/utils';
|
||||
* bounds 的原点坐标为左上角。
|
||||
*/
|
||||
export const layoutToPositions = async (
|
||||
nodes: FlowNodeEntity[],
|
||||
nodePositionMap: Record<string, IPoint>,
|
||||
nodes: WorkflowNodeEntity[],
|
||||
nodePositionMap: Record<string, IPoint>
|
||||
): Promise<Record<string, IPoint>> => {
|
||||
// 缓存上次位置,用来还原位置
|
||||
const newNodePositionMap: Record<string, IPoint> = {};
|
||||
nodes.forEach(node => {
|
||||
nodes.forEach((node) => {
|
||||
const transform = node.getData(TransformData);
|
||||
const nodeTransform = node.getData(FlowNodeTransformData);
|
||||
|
||||
@ -24,13 +27,13 @@ export const layoutToPositions = async (
|
||||
};
|
||||
});
|
||||
|
||||
return new Promise(resolve => {
|
||||
return new Promise((resolve) => {
|
||||
startTween({
|
||||
from: { d: 0 },
|
||||
to: { d: 100 },
|
||||
duration: 300,
|
||||
onUpdate: v => {
|
||||
nodes.forEach(node => {
|
||||
onUpdate: (v) => {
|
||||
nodes.forEach((node) => {
|
||||
const transform = node.getData(TransformData);
|
||||
const deltaX = ((nodePositionMap[node.id].x - transform.position.x) * v.d) / 100;
|
||||
const deltaY =
|
||||
@ -38,21 +41,15 @@ export const layoutToPositions = async (
|
||||
v.d) /
|
||||
100;
|
||||
|
||||
if (node.collapsedChildren?.length > 0) {
|
||||
// 嵌套情况下需将子节点 transform 设为 dirty
|
||||
node.collapsedChildren.forEach(childNode => {
|
||||
const childNodeTransformData =
|
||||
childNode.getData<FlowNodeTransformData>(FlowNodeTransformData);
|
||||
childNodeTransformData.fireChange();
|
||||
});
|
||||
}
|
||||
|
||||
transform.update({
|
||||
position: {
|
||||
x: transform.position.x + deltaX,
|
||||
y: transform.position.y + deltaY,
|
||||
},
|
||||
});
|
||||
|
||||
const document = node.document as WorkflowDocument;
|
||||
document.layout.updateAffectedTransform(node);
|
||||
});
|
||||
},
|
||||
onComplete: () => {
|
||||
|
||||
@ -280,6 +280,14 @@ export class WorkflowDocument extends FlowDocument {
|
||||
return node;
|
||||
}
|
||||
|
||||
get layout(): FreeLayout {
|
||||
const layout = this.layouts.find((layout) => layout.name == this.currentLayoutKey);
|
||||
if (!layout) {
|
||||
throw new Error(`Unknown flow layout: ${this.currentLayoutKey}`);
|
||||
}
|
||||
return layout as FreeLayout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取默认的 x y 坐标, 默认为当前画布可视区域中心
|
||||
* @param type
|
||||
|
||||
@ -222,6 +222,9 @@ export function createFreeLayoutPreset(
|
||||
// 2. 如存在自定义配置,以配置为准
|
||||
const element = e.target as Element;
|
||||
if (element) {
|
||||
if (element.classList.contains('gedit-flow-background-layer')) {
|
||||
return true;
|
||||
}
|
||||
if (element.closest('[data-flow-editor-selectable="true"]')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { FlowNodeTransformData } from '@flowgram.ai/document';
|
||||
import { WorkflowDocument } from '@flowgram.ai/free-layout-core';
|
||||
import { PositionSchema, startTween, TransformData } from '@flowgram.ai/core';
|
||||
|
||||
import { LayoutNode } from './type';
|
||||
@ -35,20 +35,13 @@ export class LayoutPosition {
|
||||
const deltaX = ((position.x - transform.position.x) * step) / 100;
|
||||
const deltaY = ((position.y - transform.bounds.height / 2 - transform.position.y) * step) / 100;
|
||||
|
||||
if (layoutNode.hasChildren) {
|
||||
// 嵌套情况下需将子节点 transform 设为 dirty
|
||||
layoutNode.entity.collapsedChildren.forEach((childNode) => {
|
||||
const childNodeTransformData =
|
||||
childNode.getData<FlowNodeTransformData>(FlowNodeTransformData);
|
||||
childNodeTransformData.fireChange();
|
||||
});
|
||||
}
|
||||
|
||||
transform.update({
|
||||
position: {
|
||||
x: transform.position.x + deltaX,
|
||||
y: transform.position.y + deltaY,
|
||||
},
|
||||
});
|
||||
const document = layoutNode.entity.document as WorkflowDocument;
|
||||
document.layout.updateAffectedTransform(layoutNode.entity);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import {
|
||||
WorkflowNodeEntity,
|
||||
WorkflowNodeLinesData,
|
||||
} from '@flowgram.ai/free-layout-core';
|
||||
import { FlowNodeBaseType } from '@flowgram.ai/document';
|
||||
|
||||
import { Layout, type LayoutOptions } from './layout';
|
||||
|
||||
@ -17,13 +18,13 @@ export class AutoLayoutService {
|
||||
|
||||
private async layoutNode(node: WorkflowNodeEntity, options: LayoutOptions): Promise<void> {
|
||||
// 获取子节点
|
||||
const nodes = node.collapsedChildren;
|
||||
const nodes = this.getAvailableBlocks(node);
|
||||
if (!nodes || !Array.isArray(nodes) || !nodes.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取子线条
|
||||
const edges = node.collapsedChildren
|
||||
const edges = node.blocks
|
||||
.map((child) => {
|
||||
const childLinesData = child.getData<WorkflowNodeLinesData>(WorkflowNodeLinesData);
|
||||
return childLinesData.outputLines.filter(Boolean);
|
||||
@ -38,4 +39,18 @@ export class AutoLayoutService {
|
||||
layout.layout();
|
||||
await layout.position();
|
||||
}
|
||||
|
||||
private getAvailableBlocks(node: WorkflowNodeEntity): WorkflowNodeEntity[] {
|
||||
const commonNodes = node.blocks.filter((n) => !this.shouldFlatNode(n));
|
||||
const flatNodes = node.blocks
|
||||
.filter((n) => this.shouldFlatNode(n))
|
||||
.map((flatNode) => flatNode.blocks)
|
||||
.flat();
|
||||
return [...commonNodes, ...flatNodes];
|
||||
}
|
||||
|
||||
private shouldFlatNode(node: WorkflowNodeEntity): boolean {
|
||||
// Group 节点不参与自动布局
|
||||
return node.flowNodeType === FlowNodeBaseType.GROUP;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,66 +1,67 @@
|
||||
{
|
||||
"name": "@flowgram.ai/free-container-plugin",
|
||||
"version": "0.1.8",
|
||||
"homepage": "https://flowgram.ai/",
|
||||
"repository": "https://github.com/bytedance/flowgram.ai",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
"name": "@flowgram.ai/free-container-plugin",
|
||||
"version": "0.1.8",
|
||||
"homepage": "https://flowgram.ai/",
|
||||
"repository": "https://github.com/bytedance/flowgram.ai",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/esm/index.js",
|
||||
"require": "./dist/index.js"
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/esm/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/esm/index.js",
|
||||
"require": "./dist/index.js"
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/esm/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npm run build:fast -- --dts-resolve",
|
||||
"build:fast": "tsup src/index.ts --format cjs,esm --sourcemap --legacy-output",
|
||||
"build:watch": "npm run build:fast -- --dts-resolve",
|
||||
"clean": "rimraf dist",
|
||||
"test": "exit 0",
|
||||
"test:cov": "exit 0",
|
||||
"ts-check": "tsc --noEmit",
|
||||
"watch": "npm run build:fast -- --dts-resolve --watch --ignore-watch dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@flowgram.ai/free-history-plugin": "workspace:*",
|
||||
"@flowgram.ai/free-lines-plugin": "workspace:*",
|
||||
"@flowgram.ai/core": "workspace:*",
|
||||
"@flowgram.ai/document": "workspace:*",
|
||||
"@flowgram.ai/free-layout-core": "workspace:*",
|
||||
"@flowgram.ai/renderer": "workspace:*",
|
||||
"@flowgram.ai/utils": "workspace:*",
|
||||
"inversify": "^6.0.1",
|
||||
"reflect-metadata": "~0.2.2",
|
||||
"lodash": "^4.17.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@flowgram.ai/eslint-config": "workspace:*",
|
||||
"@flowgram.ai/ts-config": "workspace:*",
|
||||
"@types/bezier-js": "4.1.3",
|
||||
"@types/lodash": "^4.14.137",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/styled-components": "^5",
|
||||
"@vitest/coverage-v8": "^0.32.0",
|
||||
"eslint": "^8.54.0",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"styled-components": "^5",
|
||||
"tsup": "^8.0.1",
|
||||
"typescript": "^5.0.4",
|
||||
"vitest": "^0.34.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17",
|
||||
"styled-components": ">=4"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
}
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npm run build:fast -- --dts-resolve",
|
||||
"build:fast": "tsup src/index.ts --format cjs,esm --sourcemap --legacy-output",
|
||||
"build:watch": "npm run build:fast -- --dts-resolve",
|
||||
"clean": "rimraf dist",
|
||||
"test": "exit 0",
|
||||
"test:cov": "exit 0",
|
||||
"ts-check": "tsc --noEmit",
|
||||
"watch": "npm run build:fast -- --dts-resolve --watch --ignore-watch dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@flowgram.ai/free-history-plugin": "workspace:*",
|
||||
"@flowgram.ai/free-lines-plugin": "workspace:*",
|
||||
"@flowgram.ai/core": "workspace:*",
|
||||
"@flowgram.ai/document": "workspace:*",
|
||||
"@flowgram.ai/shortcuts-plugin": "workspace:*",
|
||||
"@flowgram.ai/free-layout-core": "workspace:*",
|
||||
"@flowgram.ai/renderer": "workspace:*",
|
||||
"@flowgram.ai/utils": "workspace:*",
|
||||
"inversify": "^6.0.1",
|
||||
"reflect-metadata": "~0.2.2",
|
||||
"lodash": "^4.17.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@flowgram.ai/eslint-config": "workspace:*",
|
||||
"@flowgram.ai/ts-config": "workspace:*",
|
||||
"@types/bezier-js": "4.1.3",
|
||||
"@types/lodash": "^4.14.137",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/styled-components": "^5",
|
||||
"@vitest/coverage-v8": "^0.32.0",
|
||||
"eslint": "^8.54.0",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"styled-components": "^5",
|
||||
"tsup": "^8.0.1",
|
||||
"typescript": "^5.0.4",
|
||||
"vitest": "^0.34.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17",
|
||||
"styled-components": ">=4"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,2 @@
|
||||
export * from './node-into-container';
|
||||
export * from './sub-canvas';
|
||||
export { createContainerNodePlugin } from './plugin';
|
||||
export type { WorkflowContainerPluginOptions } from './type';
|
||||
|
||||
@ -1,3 +1,8 @@
|
||||
export { NodeIntoContainerType } from './constant';
|
||||
export { NodeIntoContainerService } from './service';
|
||||
export { NodeIntoContainerState, NodeIntoContainerEvent } from './type';
|
||||
export {
|
||||
NodeIntoContainerState,
|
||||
NodeIntoContainerEvent,
|
||||
WorkflowContainerPluginOptions,
|
||||
} from './type';
|
||||
export { createContainerNodePlugin } from './plugin';
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { definePluginCreator } from '@flowgram.ai/core';
|
||||
|
||||
import type { WorkflowContainerPluginOptions } from './type';
|
||||
import { NodeIntoContainerService } from './node-into-container';
|
||||
import { NodeIntoContainerService } from '.';
|
||||
|
||||
export const createContainerNodePlugin = definePluginCreator<WorkflowContainerPluginOptions>({
|
||||
onBind: ({ bind }) => {
|
||||
@ -7,6 +7,7 @@ import {
|
||||
type Disposable,
|
||||
DisposableCollection,
|
||||
Emitter,
|
||||
type IPoint,
|
||||
} from '@flowgram.ai/utils';
|
||||
import {
|
||||
type NodesDragEvent,
|
||||
@ -19,7 +20,7 @@ import {
|
||||
WorkflowSelectService,
|
||||
} from '@flowgram.ai/free-layout-core';
|
||||
import { HistoryService } from '@flowgram.ai/free-history-plugin';
|
||||
import { FlowNodeTransformData, FlowNodeRenderData } from '@flowgram.ai/document';
|
||||
import { FlowNodeTransformData, FlowNodeRenderData, FlowNodeBaseType } from '@flowgram.ai/document';
|
||||
import { PlaygroundConfigEntity, TransformData } from '@flowgram.ai/core';
|
||||
|
||||
import type { NodeIntoContainerEvent, NodeIntoContainerState } from './type';
|
||||
@ -68,24 +69,12 @@ export class NodeIntoContainerService {
|
||||
this.toDispose.dispose();
|
||||
}
|
||||
|
||||
/** 移除节点连线 */
|
||||
public async removeNodeLines(node: WorkflowNodeEntity): Promise<void> {
|
||||
const lines = this.linesManager.getAllLines();
|
||||
lines.forEach((line) => {
|
||||
if (line.from.id !== node.id && line.to?.id !== node.id) {
|
||||
return;
|
||||
}
|
||||
line.dispose();
|
||||
});
|
||||
await this.nextFrame();
|
||||
}
|
||||
|
||||
/** 将节点移出容器 */
|
||||
public async moveOutContainer(params: { node: WorkflowNodeEntity }): Promise<void> {
|
||||
const { node } = params;
|
||||
const parentNode = node.parent;
|
||||
const containerNode = parentNode?.parent;
|
||||
const nodeJSON = await this.document.toNodeJSON(node);
|
||||
const nodeJSON = this.document.toNodeJSON(node);
|
||||
if (
|
||||
!parentNode ||
|
||||
!containerNode ||
|
||||
@ -130,6 +119,29 @@ export class NodeIntoContainerService {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 移除节点所有非法连线 */
|
||||
public async clearInvalidLines(params: {
|
||||
dragNode?: WorkflowNodeEntity;
|
||||
sourceParent?: WorkflowNodeEntity;
|
||||
}): Promise<void> {
|
||||
const { dragNode, sourceParent } = params;
|
||||
if (!dragNode) {
|
||||
return;
|
||||
}
|
||||
if (dragNode.parent === sourceParent) {
|
||||
// 容器节点未改变
|
||||
return;
|
||||
}
|
||||
if (
|
||||
dragNode.parent?.flowNodeType === FlowNodeBaseType.GROUP ||
|
||||
sourceParent?.flowNodeType === FlowNodeBaseType.GROUP
|
||||
) {
|
||||
// 移入移出 group 节点无需删除节点
|
||||
return;
|
||||
}
|
||||
await this.removeNodeLines(dragNode);
|
||||
}
|
||||
|
||||
/** 初始化状态 */
|
||||
private initState(): void {
|
||||
this.state = {
|
||||
@ -175,7 +187,10 @@ export class NodeIntoContainerService {
|
||||
throttledDraggingNode.cancel();
|
||||
draggingNode(event); // 直接触发一次计算,防止延迟
|
||||
await this.dropNodeToContainer(); // 放置节点
|
||||
await this.clearInvalidLines(); // 清除非法线条
|
||||
await this.clearInvalidLines({
|
||||
dragNode: this.state.dragNode,
|
||||
sourceParent: this.state.sourceParent,
|
||||
}); // 清除非法线条
|
||||
this.setDropNode(undefined);
|
||||
this.initState(); // 重置状态
|
||||
this.historyService.endTransaction(); // 结束合并历史记录
|
||||
@ -201,18 +216,11 @@ export class NodeIntoContainerService {
|
||||
this.dragService.startDragSelectedNodes(event.triggerEvent);
|
||||
}
|
||||
|
||||
/** 移除节点所有非法连线 */
|
||||
private async clearInvalidLines(): Promise<void> {
|
||||
const { dragNode, sourceParent } = this.state;
|
||||
if (!dragNode) {
|
||||
return;
|
||||
}
|
||||
if (dragNode.parent === sourceParent) {
|
||||
return;
|
||||
}
|
||||
/** 移除节点连线 */
|
||||
private async removeNodeLines(node: WorkflowNodeEntity): Promise<void> {
|
||||
const lines = this.linesManager.getAllLines();
|
||||
lines.forEach((line) => {
|
||||
if (line.from.id !== dragNode.id && line.to?.id !== dragNode.id) {
|
||||
if (line.from.id !== node.id && line.to?.id !== node.id) {
|
||||
return;
|
||||
}
|
||||
line.dispose();
|
||||
@ -277,15 +285,21 @@ export class NodeIntoContainerService {
|
||||
/** 获取容器节点transforms */
|
||||
private getContainerTransforms(): FlowNodeTransformData[] {
|
||||
return this.document
|
||||
.getRenderDatas(FlowNodeTransformData, false)
|
||||
.filter((transform) => {
|
||||
const { entity } = transform;
|
||||
if (entity.originParent) {
|
||||
return entity.getNodeMeta().selectable && entity.originParent.getNodeMeta().selectable;
|
||||
.getAllNodes()
|
||||
.filter((node) => {
|
||||
if (node.originParent) {
|
||||
return node.getNodeMeta().selectable && node.originParent.getNodeMeta().selectable;
|
||||
}
|
||||
return entity.getNodeMeta().selectable;
|
||||
return node.getNodeMeta().selectable;
|
||||
})
|
||||
.filter((transform) => this.isContainer(transform.entity));
|
||||
.filter((node) => this.isContainer(node))
|
||||
.sort((a, b) => {
|
||||
const aIndex = a.renderData.stackIndex;
|
||||
const bIndex = b.renderData.stackIndex;
|
||||
// 确保层级高的节点在前面
|
||||
return bIndex - aIndex;
|
||||
})
|
||||
.map((node) => node.transform);
|
||||
}
|
||||
|
||||
/** 放置节点到容器 */
|
||||
@ -302,27 +316,66 @@ export class NodeIntoContainerService {
|
||||
|
||||
/** 拖拽节点 */
|
||||
private draggingNode(nodeDragEvent: NodesDragEvent): void {
|
||||
const { dragNode, isDraggingNode, transforms } = this.state;
|
||||
if (!isDraggingNode || !dragNode || this.isContainer(dragNode) || !transforms?.length) {
|
||||
const { dragNode, isDraggingNode, transforms = [] } = this.state;
|
||||
if (!isDraggingNode || !dragNode || !transforms?.length) {
|
||||
return this.setDropNode(undefined);
|
||||
}
|
||||
const mousePos = this.playgroundConfig.getPosFromMouseEvent(nodeDragEvent.dragEvent);
|
||||
const availableTransforms = transforms.filter(
|
||||
(transform) => transform.entity.id !== dragNode.id
|
||||
);
|
||||
const collisionTransform = this.getCollisionTransform({
|
||||
targetPoint: mousePos,
|
||||
transforms: this.state.transforms ?? [],
|
||||
transforms: availableTransforms,
|
||||
});
|
||||
const dropNode = collisionTransform?.entity;
|
||||
if (!dropNode || dragNode.parent?.id === dropNode.id) {
|
||||
const canDrop = this.canDropToContainer({
|
||||
dragNode,
|
||||
dropNode,
|
||||
});
|
||||
if (!canDrop) {
|
||||
return this.setDropNode(undefined);
|
||||
}
|
||||
return this.setDropNode(dropNode);
|
||||
}
|
||||
|
||||
/** 判断能否将节点拖入容器 */
|
||||
protected canDropToContainer(params: {
|
||||
dragNode: WorkflowNodeEntity;
|
||||
dropNode?: WorkflowNodeEntity;
|
||||
}): boolean {
|
||||
const { dragNode, dropNode } = params;
|
||||
const isDropContainer = dropNode?.getNodeMeta<WorkflowNodeMeta>().isContainer;
|
||||
if (!dropNode || !isDropContainer || this.isParent(dragNode, dropNode)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
dragNode.flowNodeType === FlowNodeBaseType.GROUP &&
|
||||
dropNode.flowNodeType !== FlowNodeBaseType.GROUP
|
||||
) {
|
||||
// 禁止将 group 节点拖入非 group 节点(由于目前不支持多节点拖入容器,无法计算有效线条,因此进行屏蔽)
|
||||
return false;
|
||||
}
|
||||
const canDrop = this.dragService.canDropToNode({
|
||||
dragNodeType: dragNode.flowNodeType,
|
||||
dropNode,
|
||||
});
|
||||
if (!canDrop.allowDrop) {
|
||||
return this.setDropNode(undefined);
|
||||
return false;
|
||||
}
|
||||
return this.setDropNode(canDrop.dropNode);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 判断一个节点是否为另一个节点的父节点(向上查找直到根节点) */
|
||||
private isParent(node: WorkflowNodeEntity, parent: WorkflowNodeEntity): boolean {
|
||||
let current = node.parent;
|
||||
while (current) {
|
||||
if (current.id === parent.id) {
|
||||
return true;
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 将节点移入容器 */
|
||||
@ -332,20 +385,12 @@ export class NodeIntoContainerService {
|
||||
}): Promise<void> {
|
||||
const { node, containerNode } = params;
|
||||
const parentNode = node.parent;
|
||||
const nodeJSON = await this.document.toNodeJSON(node);
|
||||
|
||||
this.operationService.moveNode(node, {
|
||||
parent: containerNode,
|
||||
});
|
||||
|
||||
this.operationService.updateNodePosition(
|
||||
node,
|
||||
this.dragService.adjustSubNodePosition(
|
||||
nodeJSON.type as string,
|
||||
containerNode,
|
||||
nodeJSON.meta!.position
|
||||
)
|
||||
);
|
||||
this.operationService.updateNodePosition(node, this.adjustSubNodePosition(node, containerNode));
|
||||
|
||||
await this.nextFrame();
|
||||
|
||||
@ -357,6 +402,38 @@ export class NodeIntoContainerService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 如果存在容器节点,且传入鼠标坐标,需要用容器的坐标减去传入的鼠标坐标
|
||||
*/
|
||||
private adjustSubNodePosition(
|
||||
targetNode: WorkflowNodeEntity,
|
||||
containerNode: WorkflowNodeEntity
|
||||
): IPoint {
|
||||
if (containerNode.flowNodeType === FlowNodeBaseType.ROOT) {
|
||||
return targetNode.transform.position;
|
||||
}
|
||||
const nodeWorldTransform = targetNode.transform.transform.worldTransform;
|
||||
const containerWorldTransform = containerNode.transform.transform.worldTransform;
|
||||
const nodePosition = {
|
||||
x: nodeWorldTransform.tx,
|
||||
y: nodeWorldTransform.ty,
|
||||
};
|
||||
const isParentEmpty = !containerNode.children || containerNode.children.length === 0;
|
||||
const containerPadding = this.document.layout.getPadding(containerNode);
|
||||
if (isParentEmpty) {
|
||||
// 确保空容器节点不偏移
|
||||
return {
|
||||
x: 0,
|
||||
y: containerPadding.top,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
x: nodePosition.x - containerWorldTransform.tx,
|
||||
y: nodePosition.y - containerWorldTransform.ty,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private isContainer(node?: WorkflowNodeEntity): boolean {
|
||||
return node?.getNodeMeta<WorkflowNodeMeta>().isContainer ?? false;
|
||||
}
|
||||
|
||||
@ -18,3 +18,7 @@ export interface NodeIntoContainerEvent {
|
||||
sourceContainer?: WorkflowNodeEntity;
|
||||
targetContainer: WorkflowNodeEntity;
|
||||
}
|
||||
|
||||
export interface WorkflowContainerPluginOptions {
|
||||
disableNodeIntoContainer?: boolean;
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
export { SubCanvasBackground } from './background';
|
||||
export { SubCanvasBorder } from './border';
|
||||
export { SubCanvasRender } from './render';
|
||||
export { SubCanvasTips } from './tips';
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
import React, { CSSProperties, useLayoutEffect, type FC } from 'react';
|
||||
import React, { CSSProperties, type FC } from 'react';
|
||||
|
||||
import { useCurrentEntity } from '@flowgram.ai/free-layout-core';
|
||||
|
||||
import { SubCanvasRenderStyle } from './style';
|
||||
import { SubCanvasTips } from '../tips';
|
||||
import { SubCanvasBorder } from '../border';
|
||||
import { SubCanvasBackground } from '../background';
|
||||
import { useNodeSize } from '../../hooks';
|
||||
import { useNodeSize, useSyncNodeRenderSize } from '../../hooks';
|
||||
|
||||
interface ISubCanvasBorder {
|
||||
className?: string;
|
||||
@ -15,14 +16,10 @@ interface ISubCanvasBorder {
|
||||
export const SubCanvasRender: FC<ISubCanvasBorder> = ({ className, style }) => {
|
||||
const node = useCurrentEntity();
|
||||
const nodeSize = useNodeSize();
|
||||
const { height, width } = nodeSize ?? {};
|
||||
const nodeHeight = nodeSize?.height ?? 0;
|
||||
const { padding } = node.transform;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
node.renderData.node.style.width = width + 'px';
|
||||
node.renderData.node.style.height = height + 'px';
|
||||
}, [height, width]);
|
||||
useSyncNodeRenderSize(nodeSize);
|
||||
|
||||
return (
|
||||
<SubCanvasRenderStyle
|
||||
@ -38,6 +35,7 @@ export const SubCanvasRender: FC<ISubCanvasBorder> = ({ className, style }) => {
|
||||
>
|
||||
<SubCanvasBorder>
|
||||
<SubCanvasBackground />
|
||||
<SubCanvasTips />
|
||||
</SubCanvasBorder>
|
||||
</SubCanvasRenderStyle>
|
||||
);
|
||||
|
||||
@ -0,0 +1,33 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention -- no need */
|
||||
|
||||
const STORAGE_KEY = 'workflow-move-into-sub-canvas-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,11 @@
|
||||
import React from 'react';
|
||||
|
||||
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,37 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useControlTips } from './use-control';
|
||||
import { SubCanvasTipsStyle } from './style';
|
||||
import { isMacOS } from './is-mac-os';
|
||||
import { IconClose } from './icon-close';
|
||||
|
||||
export const SubCanvasTips = () => {
|
||||
const { visible, close, closeForever } = useControlTips();
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<SubCanvasTipsStyle className={'sub-canvas-tips'}>
|
||||
<div className="container">
|
||||
<div className="content">
|
||||
<p className="text">{`Hold ${isMacOS ? 'Cmd ⌘' : 'Ctrl'} to drag node out`}</p>
|
||||
<div
|
||||
className="space"
|
||||
style={{
|
||||
width: 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<p className="close-forever" onClick={closeForever}>
|
||||
Never Remind
|
||||
</p>
|
||||
<div className="close" onClick={close}>
|
||||
<IconClose />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SubCanvasTipsStyle>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export const isMacOS = /(Macintosh|MacIntel|MacPPC|Mac68K|iPad)/.test(navigator.userAgent);
|
||||
@ -0,0 +1,70 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const SubCanvasTipsStyle = styled.div`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
|
||||
.container {
|
||||
height: 100%;
|
||||
background-color: #e4e6f5;
|
||||
border-radius: 4px 4px 0 0;
|
||||
|
||||
.content {
|
||||
overflow: hidden;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.text {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
line-height: 20px;
|
||||
color: rgba(15, 21, 40, 82%);
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.space {
|
||||
width: 128px;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
height: 28px;
|
||||
padding: 0 16px;
|
||||
|
||||
.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%);
|
||||
}
|
||||
|
||||
.close {
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -0,0 +1,64 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { useCurrentEntity } from '@flowgram.ai/free-layout-core';
|
||||
import { useService } from '@flowgram.ai/core';
|
||||
|
||||
import { NodeIntoContainerService, NodeIntoContainerType } from '../../../node-into-container';
|
||||
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,
|
||||
};
|
||||
};
|
||||
@ -1 +1,2 @@
|
||||
export { useNodeSize } from './use-node-size';
|
||||
export { NodeSize, useNodeSize } from './use-node-size';
|
||||
export { useSyncNodeRenderSize } from './use-sync-node-render-size';
|
||||
|
||||
@ -7,7 +7,7 @@ import {
|
||||
} from '@flowgram.ai/free-layout-core';
|
||||
import { FlowNodeTransformData } from '@flowgram.ai/document';
|
||||
|
||||
interface NodeSize {
|
||||
export interface NodeSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
@ -0,0 +1,17 @@
|
||||
import { useLayoutEffect } from 'react';
|
||||
|
||||
import { useCurrentEntity } from '@flowgram.ai/free-layout-core';
|
||||
|
||||
import { NodeSize } from './use-node-size';
|
||||
|
||||
export const useSyncNodeRenderSize = (nodeSize?: NodeSize) => {
|
||||
const node = useCurrentEntity();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!nodeSize) {
|
||||
return;
|
||||
}
|
||||
node.renderData.node.style.width = nodeSize.width + 'px';
|
||||
node.renderData.node.style.height = nodeSize.height + 'px';
|
||||
}, [nodeSize?.width, nodeSize?.height]);
|
||||
};
|
||||
@ -1,6 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface WorkflowContainerPluginOptions {
|
||||
disableNodeIntoContainer?: boolean;
|
||||
renderContent?: ReactNode;
|
||||
}
|
||||
6
packages/plugins/free-group-plugin/.eslintrc.js
Normal file
6
packages/plugins/free-group-plugin/.eslintrc.js
Normal file
@ -0,0 +1,6 @@
|
||||
const { defineConfig } = require('@flowgram.ai/eslint-config');
|
||||
|
||||
module.exports = defineConfig({
|
||||
preset: 'web',
|
||||
packageRoot: __dirname,
|
||||
});
|
||||
68
packages/plugins/free-group-plugin/package.json
Normal file
68
packages/plugins/free-group-plugin/package.json
Normal file
@ -0,0 +1,68 @@
|
||||
{
|
||||
"name": "@flowgram.ai/free-group-plugin",
|
||||
"version": "0.1.8",
|
||||
"homepage": "https://flowgram.ai/",
|
||||
"repository": "https://github.com/bytedance/flowgram.ai",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/esm/index.js",
|
||||
"require": "./dist/index.js"
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/esm/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npm run build:fast -- --dts-resolve",
|
||||
"build:fast": "tsup src/index.ts --format cjs,esm --sourcemap --legacy-output",
|
||||
"build:watch": "npm run build:fast -- --dts-resolve",
|
||||
"clean": "rimraf dist",
|
||||
"test": "exit 0",
|
||||
"test:cov": "exit 0",
|
||||
"ts-check": "tsc --noEmit",
|
||||
"watch": "npm run build:fast -- --dts-resolve --watch --ignore-watch dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@flowgram.ai/free-history-plugin": "workspace:*",
|
||||
"@flowgram.ai/free-container-plugin": "workspace:*",
|
||||
"@flowgram.ai/free-lines-plugin": "workspace:*",
|
||||
"@flowgram.ai/core": "workspace:*",
|
||||
"@flowgram.ai/document": "workspace:*",
|
||||
"@flowgram.ai/shortcuts-plugin": "workspace:*",
|
||||
"@flowgram.ai/free-layout-core": "workspace:*",
|
||||
"@flowgram.ai/renderer": "workspace:*",
|
||||
"@flowgram.ai/utils": "workspace:*",
|
||||
"inversify": "^6.0.1",
|
||||
"reflect-metadata": "~0.2.2",
|
||||
"lodash": "^4.17.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@flowgram.ai/eslint-config": "workspace:*",
|
||||
"@flowgram.ai/ts-config": "workspace:*",
|
||||
"@types/bezier-js": "4.1.3",
|
||||
"@types/lodash": "^4.14.137",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/styled-components": "^5",
|
||||
"@vitest/coverage-v8": "^0.32.0",
|
||||
"eslint": "^8.54.0",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"styled-components": "^5",
|
||||
"tsup": "^8.0.1",
|
||||
"typescript": "^5.0.4",
|
||||
"vitest": "^0.34.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17",
|
||||
"styled-components": ">=4"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
}
|
||||
}
|
||||
4
packages/plugins/free-group-plugin/src/constant.ts
Normal file
4
packages/plugins/free-group-plugin/src/constant.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export enum WorkflowGroupCommand {
|
||||
Group = 'group',
|
||||
Ungroup = 'ungroup',
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
import { ShortcutsRegistry } from '@flowgram.ai/shortcuts-plugin';
|
||||
import { FlowRendererRegistry } from '@flowgram.ai/renderer';
|
||||
import { WorkflowDocument } from '@flowgram.ai/free-layout-core';
|
||||
import { FlowGroupService, FlowNodeBaseType } from '@flowgram.ai/document';
|
||||
import { definePluginCreator, PluginContext } from '@flowgram.ai/core';
|
||||
|
||||
import { WorkflowGroupService } from './workflow-group-service';
|
||||
import { WorkflowGroupPluginOptions } from './type';
|
||||
import { GroupShortcut, UngroupShortcut } from './shortcuts';
|
||||
import { GroupNodeRegistry } from './group-node';
|
||||
|
||||
export const createFreeGroupPlugin = definePluginCreator<WorkflowGroupPluginOptions, PluginContext>(
|
||||
{
|
||||
onBind({ bind, rebind }) {
|
||||
bind(WorkflowGroupService).toSelf().inSingletonScope();
|
||||
rebind(FlowGroupService).toService(WorkflowGroupService);
|
||||
},
|
||||
onInit(
|
||||
ctx,
|
||||
{ groupNodeRender, disableGroupShortcuts = false, disableGroupNodeRegister = false }
|
||||
) {
|
||||
// register node render
|
||||
if (groupNodeRender) {
|
||||
const renderRegistry = ctx.get<FlowRendererRegistry>(FlowRendererRegistry);
|
||||
renderRegistry.registerReactComponent(FlowNodeBaseType.GROUP, groupNodeRender);
|
||||
}
|
||||
// register shortcuts
|
||||
if (!disableGroupShortcuts) {
|
||||
const shortcutsRegistry = ctx.get(ShortcutsRegistry);
|
||||
shortcutsRegistry.addHandlers(new GroupShortcut(ctx), new UngroupShortcut(ctx));
|
||||
}
|
||||
if (!disableGroupNodeRegister) {
|
||||
const document = ctx.get(WorkflowDocument);
|
||||
document.registerFlowNodes(GroupNodeRegistry);
|
||||
}
|
||||
},
|
||||
onReady(ctx) {
|
||||
const groupService = ctx.get(WorkflowGroupService);
|
||||
groupService.ready();
|
||||
},
|
||||
onDispose(ctx) {
|
||||
const groupService = ctx.get(WorkflowGroupService);
|
||||
groupService.dispose();
|
||||
},
|
||||
}
|
||||
);
|
||||
37
packages/plugins/free-group-plugin/src/group-node.tsx
Normal file
37
packages/plugins/free-group-plugin/src/group-node.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { PositionSchema } from '@flowgram.ai/utils';
|
||||
import { WorkflowNodeEntity } from '@flowgram.ai/free-layout-core';
|
||||
import { FlowNodeRegistry, FlowNodeBaseType, FlowNodeTransformData } from '@flowgram.ai/document';
|
||||
|
||||
export const GroupNodeRegistry: FlowNodeRegistry = {
|
||||
type: FlowNodeBaseType.GROUP,
|
||||
meta: {
|
||||
renderKey: FlowNodeBaseType.GROUP,
|
||||
defaultPorts: [],
|
||||
isContainer: true,
|
||||
disableSideBar: true,
|
||||
size: {
|
||||
width: 560,
|
||||
height: 400,
|
||||
},
|
||||
padding: () => ({
|
||||
top: 80,
|
||||
bottom: 40,
|
||||
left: 65,
|
||||
right: 65,
|
||||
}),
|
||||
selectable(node: WorkflowNodeEntity, mousePos?: PositionSchema): boolean {
|
||||
if (!mousePos) {
|
||||
return true;
|
||||
}
|
||||
const transform = node.getData<FlowNodeTransformData>(FlowNodeTransformData);
|
||||
return !transform.bounds.contains(mousePos.x, mousePos.y);
|
||||
},
|
||||
expandable: false,
|
||||
},
|
||||
formMeta: {
|
||||
render: () => <></>,
|
||||
},
|
||||
onCreate() {
|
||||
// NOTICE: 这个函数是为了避免触发固定布局 flowDocument.addBlocksAsChildren
|
||||
},
|
||||
};
|
||||
3
packages/plugins/free-group-plugin/src/index.ts
Normal file
3
packages/plugins/free-group-plugin/src/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { createFreeGroupPlugin } from './create-free-group-plugin';
|
||||
export { WorkflowGroupService } from './workflow-group-service';
|
||||
export { WorkflowGroupCommand } from './constant';
|
||||
31
packages/plugins/free-group-plugin/src/shortcuts/group.ts
Normal file
31
packages/plugins/free-group-plugin/src/shortcuts/group.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { ShortcutsHandler } from '@flowgram.ai/shortcuts-plugin';
|
||||
import { WorkflowSelectService } from '@flowgram.ai/free-layout-core';
|
||||
import { PluginContext } from '@flowgram.ai/core';
|
||||
|
||||
import { WorkflowGroupService } from '../workflow-group-service';
|
||||
import { WorkflowGroupCommand } from '../constant';
|
||||
|
||||
export class GroupShortcut implements ShortcutsHandler {
|
||||
public commandId = WorkflowGroupCommand.Group;
|
||||
|
||||
public commandDetail: ShortcutsHandler['commandDetail'] = {
|
||||
label: 'Group',
|
||||
};
|
||||
|
||||
public shortcuts = ['meta g', 'ctrl g'];
|
||||
|
||||
private selectService: WorkflowSelectService;
|
||||
|
||||
private groupService: WorkflowGroupService;
|
||||
|
||||
constructor(context: PluginContext) {
|
||||
this.selectService = context.get(WorkflowSelectService);
|
||||
this.groupService = context.get(WorkflowGroupService);
|
||||
this.execute = this.execute.bind(this);
|
||||
}
|
||||
|
||||
public async execute(): Promise<void> {
|
||||
this.groupService.createGroup(this.selectService.selectedNodes);
|
||||
this.selectService.clear();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
export { GroupShortcut } from './group';
|
||||
export { UngroupShortcut } from './ungroup';
|
||||
36
packages/plugins/free-group-plugin/src/shortcuts/ungroup.ts
Normal file
36
packages/plugins/free-group-plugin/src/shortcuts/ungroup.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { ShortcutsHandler } from '@flowgram.ai/shortcuts-plugin';
|
||||
import { WorkflowSelectService, WorkflowNodeEntity } from '@flowgram.ai/free-layout-core';
|
||||
import { FlowNodeBaseType } from '@flowgram.ai/document';
|
||||
import { PluginContext } from '@flowgram.ai/core';
|
||||
|
||||
import { WorkflowGroupService } from '../workflow-group-service';
|
||||
import { WorkflowGroupCommand } from '../constant';
|
||||
|
||||
export class UngroupShortcut implements ShortcutsHandler {
|
||||
public commandId = WorkflowGroupCommand.Ungroup;
|
||||
|
||||
public commandDetail: ShortcutsHandler['commandDetail'] = {
|
||||
label: 'Ungroup',
|
||||
};
|
||||
|
||||
public shortcuts = ['meta shift g', 'ctrl shift g'];
|
||||
|
||||
private selectService: WorkflowSelectService;
|
||||
|
||||
private groupService: WorkflowGroupService;
|
||||
|
||||
constructor(context: PluginContext) {
|
||||
this.selectService = context.get(WorkflowSelectService);
|
||||
this.groupService = context.get(WorkflowGroupService);
|
||||
this.execute = this.execute.bind(this);
|
||||
}
|
||||
|
||||
public async execute(_groupNode?: WorkflowNodeEntity): Promise<void> {
|
||||
const groupNode = _groupNode || this.selectService.activatedNode;
|
||||
if (!groupNode || groupNode.flowNodeType !== FlowNodeBaseType.GROUP) {
|
||||
return;
|
||||
}
|
||||
this.groupService.ungroup(groupNode);
|
||||
this.selectService.clear();
|
||||
}
|
||||
}
|
||||
7
packages/plugins/free-group-plugin/src/type.ts
Normal file
7
packages/plugins/free-group-plugin/src/type.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { FC } from 'react';
|
||||
|
||||
export interface WorkflowGroupPluginOptions {
|
||||
groupNodeRender: FC;
|
||||
disableGroupShortcuts?: boolean;
|
||||
disableGroupNodeRegister?: boolean;
|
||||
}
|
||||
57
packages/plugins/free-group-plugin/src/utils.ts
Normal file
57
packages/plugins/free-group-plugin/src/utils.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { WorkflowNodeEntity } from '@flowgram.ai/free-layout-core';
|
||||
import { FlowNodeBaseType } from '@flowgram.ai/document';
|
||||
|
||||
export namespace WorkflowGroupUtils {
|
||||
/** 找到节点所有上级 */
|
||||
// const findNodeParents = (node: WorkflowNodeEntity): WorkflowNodeEntity[] => {
|
||||
// const parents = [];
|
||||
// let parent = node.parent;
|
||||
// while (parent) {
|
||||
// parents.push(parent);
|
||||
// parent = parent.parent;
|
||||
// }
|
||||
// return parents;
|
||||
// };
|
||||
|
||||
/** 节点是否处于分组中 */
|
||||
const isNodeInGroup = (node: WorkflowNodeEntity): boolean => {
|
||||
// 处于分组中
|
||||
if (node?.parent?.flowNodeType === FlowNodeBaseType.GROUP) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/** 是否分组节点 */
|
||||
const isGroupNode = (group: WorkflowNodeEntity): boolean =>
|
||||
group.flowNodeType === FlowNodeBaseType.GROUP;
|
||||
|
||||
/** 判断节点能否组成分组 */
|
||||
export const validate = (nodes: WorkflowNodeEntity[]): boolean => {
|
||||
if (!nodes || !Array.isArray(nodes) || nodes.length === 0) {
|
||||
// 参数不合法
|
||||
return false;
|
||||
}
|
||||
|
||||
// 判断是否有分组节点
|
||||
const isGroupRelatedNode = nodes.some((node) => isGroupNode(node));
|
||||
if (isGroupRelatedNode) return false;
|
||||
|
||||
// 判断是否有节点已经处于分组中
|
||||
const hasGroup = nodes.some((node) => node && isNodeInGroup(node));
|
||||
if (hasGroup) return false;
|
||||
|
||||
// 判断是否来自同一个父亲
|
||||
const parent = nodes[0].parent;
|
||||
const isSameParent = nodes.every((node) => node.parent === parent);
|
||||
if (!isSameParent) return false;
|
||||
|
||||
// 判断节点父亲是否已经在分组中
|
||||
// const parents = findNodeParents(nodes[0]);
|
||||
// const parentsInGroup = parents.some((parent) => isNodeInGroup(parent));
|
||||
// if (parentsInGroup) return false;
|
||||
|
||||
// 参数正确
|
||||
return true;
|
||||
};
|
||||
}
|
||||
116
packages/plugins/free-group-plugin/src/workflow-group-service.ts
Normal file
116
packages/plugins/free-group-plugin/src/workflow-group-service.ts
Normal file
@ -0,0 +1,116 @@
|
||||
import { injectable, inject } from 'inversify';
|
||||
import { DisposableCollection, Disposable } from '@flowgram.ai/utils';
|
||||
import {
|
||||
WorkflowDocument,
|
||||
WorkflowOperationBaseService,
|
||||
WorkflowNodeEntity,
|
||||
nanoid,
|
||||
WorkflowNodeJSON,
|
||||
} from '@flowgram.ai/free-layout-core';
|
||||
import { HistoryService } from '@flowgram.ai/free-history-plugin';
|
||||
import {
|
||||
NodeIntoContainerService,
|
||||
NodeIntoContainerType,
|
||||
} from '@flowgram.ai/free-container-plugin';
|
||||
import { FlowGroupService, FlowNodeBaseType } from '@flowgram.ai/document';
|
||||
import { TransformData } from '@flowgram.ai/core';
|
||||
|
||||
import { WorkflowGroupUtils } from './utils';
|
||||
|
||||
@injectable()
|
||||
/** 分组服务 */
|
||||
export class WorkflowGroupService extends FlowGroupService {
|
||||
@inject(WorkflowDocument) private document: WorkflowDocument;
|
||||
|
||||
@inject(WorkflowOperationBaseService) freeOperationService: WorkflowOperationBaseService;
|
||||
|
||||
@inject(HistoryService) private historyService: HistoryService;
|
||||
|
||||
@inject(NodeIntoContainerService) private nodeIntoContainerService: NodeIntoContainerService;
|
||||
|
||||
private toDispose = new DisposableCollection();
|
||||
|
||||
public ready(): void {
|
||||
this.toDispose.push(this.listenContainer());
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.toDispose.dispose();
|
||||
}
|
||||
|
||||
/** 创建分组节点 */
|
||||
public createGroup(nodes: WorkflowNodeEntity[]): WorkflowNodeEntity | undefined {
|
||||
if (!WorkflowGroupUtils.validate(nodes)) {
|
||||
return;
|
||||
}
|
||||
const parent = nodes[0].parent ?? this.document.root;
|
||||
const groupId = `group_${nanoid(5)}`;
|
||||
const groupJSON: WorkflowNodeJSON = {
|
||||
type: FlowNodeBaseType.GROUP,
|
||||
id: groupId,
|
||||
meta: {
|
||||
position: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
},
|
||||
data: {},
|
||||
};
|
||||
this.historyService.startTransaction();
|
||||
this.document.createWorkflowNodeByType(
|
||||
FlowNodeBaseType.GROUP,
|
||||
{
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
groupJSON,
|
||||
parent.id
|
||||
);
|
||||
nodes.forEach((node) => {
|
||||
this.freeOperationService.moveNode(node, {
|
||||
parent: groupId,
|
||||
});
|
||||
});
|
||||
this.historyService.endTransaction();
|
||||
}
|
||||
|
||||
/** 取消分组 */
|
||||
public ungroup(groupNode: WorkflowNodeEntity): void {
|
||||
const groupBlocks = groupNode.blocks.slice();
|
||||
if (!groupNode.parent) {
|
||||
return;
|
||||
}
|
||||
const groupPosition = groupNode.transform.position;
|
||||
|
||||
this.historyService.startTransaction();
|
||||
groupBlocks.forEach((node) => {
|
||||
this.freeOperationService.moveNode(node, {
|
||||
parent: groupNode.parent?.id,
|
||||
});
|
||||
});
|
||||
groupNode.dispose();
|
||||
groupBlocks.forEach((node) => {
|
||||
const transform = node.getData(TransformData);
|
||||
const position = {
|
||||
x: transform.position.x + groupPosition.x,
|
||||
y: transform.position.y + groupPosition.y,
|
||||
};
|
||||
this.freeOperationService.updateNodePosition(node, position);
|
||||
});
|
||||
this.historyService.endTransaction();
|
||||
}
|
||||
|
||||
private listenContainer(): Disposable {
|
||||
return this.nodeIntoContainerService.on((e) => {
|
||||
if (
|
||||
e.type !== NodeIntoContainerType.Out ||
|
||||
e.sourceContainer?.flowNodeType !== FlowNodeBaseType.GROUP
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (e.sourceContainer?.blocks.length === 0) {
|
||||
e.sourceContainer.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
7
packages/plugins/free-group-plugin/tsconfig.json
Normal file
7
packages/plugins/free-group-plugin/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "@flowgram.ai/ts-config/tsconfig.flow.path.json",
|
||||
"compilerOptions": {
|
||||
},
|
||||
"include": ["./src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
26
packages/plugins/free-group-plugin/vitest.config.ts
Normal file
26
packages/plugins/free-group-plugin/vitest.config.ts
Normal file
@ -0,0 +1,26 @@
|
||||
const path = require('path');
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
commonjsOptions: {
|
||||
transformMixedEsModules: true,
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
mockReset: false,
|
||||
environment: 'jsdom',
|
||||
setupFiles: [path.resolve(__dirname, './vitest.setup.ts')],
|
||||
include: ['**/?(*.){test,spec}.?(c|m)[jt]s?(x)'],
|
||||
exclude: [
|
||||
'**/__mocks__**',
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'**/lib/**', // lib 编译结果忽略掉
|
||||
'**/cypress/**',
|
||||
'**/.{idea,git,cache,output,temp}/**',
|
||||
'**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*',
|
||||
],
|
||||
},
|
||||
});
|
||||
1
packages/plugins/free-group-plugin/vitest.setup.ts
Normal file
1
packages/plugins/free-group-plugin/vitest.setup.ts
Normal file
@ -0,0 +1 @@
|
||||
import 'reflect-metadata';
|
||||
@ -90,8 +90,8 @@ export class FreeHistoryManager {
|
||||
}
|
||||
})
|
||||
: Disposable.NULL,
|
||||
document.onContentChange(async (event) => {
|
||||
await this._changeContentHandler.handle(event, ctx);
|
||||
document.onContentChange((event) => {
|
||||
this._changeContentHandler.handle(event, ctx);
|
||||
}),
|
||||
document.onReload((_event) => {
|
||||
historyService.clear();
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import { injectable, inject } from 'inversify';
|
||||
import { type PluginContext } from '@flowgram.ai/core';
|
||||
import { HistoryService, Operation } from '@flowgram.ai/history';
|
||||
import { type WorkflowContentChangeEvent } from '@flowgram.ai/free-layout-core';
|
||||
import { HistoryService } from '@flowgram.ai/history';
|
||||
import { type PluginContext } from '@flowgram.ai/core';
|
||||
|
||||
import { type IHandler } from '../types';
|
||||
import changes from '../changes';
|
||||
@ -12,16 +12,16 @@ export class ChangeContentHandler implements IHandler<WorkflowContentChangeEvent
|
||||
@inject(HistoryService)
|
||||
private _historyService: HistoryService;
|
||||
|
||||
async handle(event: WorkflowContentChangeEvent, ctx: PluginContext) {
|
||||
handle(event: WorkflowContentChangeEvent, ctx: PluginContext) {
|
||||
if (!this._historyService.undoRedoService.canPush()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const change = changes.find(c => c.type === event.type);
|
||||
const change = changes.find((c) => c.type === event.type);
|
||||
if (!change) {
|
||||
return;
|
||||
}
|
||||
const operation = await change.toOperation(event, ctx);
|
||||
const operation: Operation | undefined = change.toOperation(event, ctx);
|
||||
if (!operation) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { type OperationMeta } from '@flowgram.ai/history';
|
||||
import { WorkflowDocument } from '@flowgram.ai/free-layout-core';
|
||||
import { FlowNodeTransformData } from '@flowgram.ai/document';
|
||||
import { type PluginContext, TransformData } from '@flowgram.ai/core';
|
||||
|
||||
import { type DragNodeOperationValue, FreeOperationType } from '../types';
|
||||
@ -33,14 +32,7 @@ export const dragNodesOperationMeta: OperationMeta<DragNodeOperationValue, Plugi
|
||||
y: point.y,
|
||||
},
|
||||
});
|
||||
// 嵌套情况下需将子节点 transform 设为 dirty
|
||||
if (node.collapsedChildren?.length > 0) {
|
||||
node.collapsedChildren.forEach((childNode) => {
|
||||
const childNodeTransformData =
|
||||
childNode.getData<FlowNodeTransformData>(FlowNodeTransformData);
|
||||
childNodeTransformData.fireChange();
|
||||
});
|
||||
}
|
||||
document.layout.updateAffectedTransform(node);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@ -84,10 +84,7 @@ export interface ResetLayoutOperationValue {
|
||||
|
||||
export interface ContentChangeTypeToOperation<T extends Operation> {
|
||||
type: WorkflowContentChangeType;
|
||||
toOperation: (
|
||||
event: WorkflowContentChangeEvent,
|
||||
ctx: PluginContext
|
||||
) => T | undefined | Promise<T | undefined>;
|
||||
toOperation: (event: WorkflowContentChangeEvent, ctx: PluginContext) => T | undefined;
|
||||
}
|
||||
|
||||
export interface EntityDataType {
|
||||
|
||||
@ -398,17 +398,10 @@ export class WorkflowSnapService {
|
||||
x: transform.position.x + offset.x,
|
||||
y: transform.position.y + offset.y,
|
||||
};
|
||||
if (node.collapsedChildren?.length > 0) {
|
||||
// 嵌套情况下需将子节点 transform 设为 dirty
|
||||
node.collapsedChildren.forEach((childNode) => {
|
||||
const childNodeTransformData =
|
||||
childNode.getData<FlowNodeTransformData>(FlowNodeTransformData);
|
||||
childNodeTransformData.fireChange();
|
||||
});
|
||||
}
|
||||
transform.update({
|
||||
position: positionWithOffset,
|
||||
});
|
||||
this.document.layout.updateAffectedTransform(node);
|
||||
}
|
||||
|
||||
private calcAlignOffset(params: {
|
||||
|
||||
@ -16,7 +16,6 @@ import {
|
||||
} from '@flowgram.ai/core';
|
||||
|
||||
import { StackingContextManager } from '../src/manager';
|
||||
import { StackingComputeMode } from '../src/constant';
|
||||
import { createWorkflowContainer, workflowJSON } from './utils.mock';
|
||||
import { IStackingContextManager } from './type.mock';
|
||||
|
||||
@ -58,13 +57,8 @@ describe('StackingContextManager public methods', () => {
|
||||
</div>
|
||||
`
|
||||
);
|
||||
expect(stackingContextManager.mode).toEqual(StackingComputeMode.Stacking);
|
||||
expect(stackingContextManager.disposers).toHaveLength(4);
|
||||
});
|
||||
it('should init with mode', () => {
|
||||
stackingContextManager.init(StackingComputeMode.Stacking);
|
||||
expect(stackingContextManager.mode).toEqual(StackingComputeMode.Stacking);
|
||||
});
|
||||
it('should execute ready', () => {
|
||||
stackingContextManager.compute = vi.fn();
|
||||
stackingContextManager.ready();
|
||||
@ -206,18 +200,7 @@ describe('StackingContextManager private methods', () => {
|
||||
expect(compute).toBeCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should trigger compute in layers mode', async () => {
|
||||
stackingContextManager.init(StackingComputeMode.Layers);
|
||||
stackingContextManager.ready();
|
||||
await delay(200);
|
||||
const node = document.getNode('loop_0')!;
|
||||
const nodeRenderData = node.getData<FlowNodeRenderData>(FlowNodeRenderData);
|
||||
const element = nodeRenderData.node;
|
||||
expect(element.style.zIndex).toBe('9');
|
||||
});
|
||||
|
||||
it('should trigger compute in stacking mode', async () => {
|
||||
stackingContextManager.init(StackingComputeMode.Stacking);
|
||||
it('should trigger compute', async () => {
|
||||
stackingContextManager.ready();
|
||||
await delay(200);
|
||||
const node = document.getNode('loop_0')!;
|
||||
|
||||
@ -34,10 +34,3 @@ export const StackingConfig = {
|
||||
/** 最大 index */
|
||||
maxIndex,
|
||||
};
|
||||
|
||||
export enum StackingComputeMode {
|
||||
/** 层叠计算模式 */
|
||||
Stacking = 'stacking',
|
||||
/** 层级计算模式 */
|
||||
Layers = 'layers',
|
||||
}
|
||||
|
||||
@ -1,17 +1,14 @@
|
||||
import { definePluginCreator } from '@flowgram.ai/core';
|
||||
|
||||
import { StackingContextManager } from './manager';
|
||||
import { StackingComputeMode } from './constant';
|
||||
|
||||
export const createFreeStackPlugin = definePluginCreator<{
|
||||
mode?: StackingComputeMode;
|
||||
}>({
|
||||
export const createFreeStackPlugin = definePluginCreator({
|
||||
onBind({ bind }) {
|
||||
bind(StackingContextManager).toSelf().inSingletonScope();
|
||||
},
|
||||
onInit(ctx, opts) {
|
||||
onInit(ctx) {
|
||||
const stackingContextManager = ctx.get<StackingContextManager>(StackingContextManager);
|
||||
stackingContextManager.init(opts?.mode);
|
||||
stackingContextManager.init();
|
||||
},
|
||||
onReady(ctx) {
|
||||
const stackingContextManager = ctx.get<StackingContextManager>(StackingContextManager);
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { FlowNodeRenderData } from '@flowgram.ai/document';
|
||||
import type { WorkflowNodeEntity } from '@flowgram.ai/free-layout-core';
|
||||
import type { WorkflowLineEntity } from '@flowgram.ai/free-layout-core';
|
||||
import { FlowNodeRenderData } from '@flowgram.ai/document';
|
||||
|
||||
import type { StackingContext } from './type';
|
||||
import { StackingBaseIndex, StackingConfig, StackingType } from './constant';
|
||||
@ -27,7 +27,7 @@ namespace NodeComputing {
|
||||
export const nodeStackingLevel = (
|
||||
node: WorkflowNodeEntity,
|
||||
context: StackingContext,
|
||||
disableTopLevel = false,
|
||||
disableTopLevel = false
|
||||
): number => {
|
||||
// TODO 后续支持多层级时这个计算逻辑应该去掉,level信息应该直接由 FlowNodeEntity 缓存给出
|
||||
// 多层时这里的计算会有 O(logN) 时间复杂度,并且在多层级联同计算时会有BUG,本次需求不处理这种情况
|
||||
@ -116,10 +116,10 @@ export const layersComputing = (params: {
|
||||
context: StackingContext;
|
||||
}) => {
|
||||
const { nodes, lines, context } = params;
|
||||
nodes.forEach(node => {
|
||||
nodes.forEach((node) => {
|
||||
NodeComputing.compute(node, context);
|
||||
});
|
||||
lines.forEach(line => {
|
||||
lines.forEach((line) => {
|
||||
LineComputing.compute(line, context);
|
||||
});
|
||||
};
|
||||
|
||||
@ -14,8 +14,7 @@ import { EntityManager, PipelineRegistry, PipelineRenderer } from '@flowgram.ai/
|
||||
|
||||
import type { StackingContext } from './type';
|
||||
import { StackingComputing } from './stacking-computing';
|
||||
import { layersComputing } from './layers-computing';
|
||||
import { StackingComputeMode, StackingConfig } from './constant';
|
||||
import { StackingConfig } from './constant';
|
||||
|
||||
@injectable()
|
||||
export class StackingContextManager {
|
||||
@ -41,12 +40,9 @@ export class StackingContextManager {
|
||||
|
||||
private disposers: Disposable[] = [];
|
||||
|
||||
private mode: StackingComputeMode = StackingComputeMode.Stacking;
|
||||
|
||||
constructor() {}
|
||||
|
||||
public init(mode?: StackingComputeMode): void {
|
||||
if (mode) this.mode = mode;
|
||||
public init(): void {
|
||||
this.pipelineRenderer.node.appendChild(this.node);
|
||||
this.mountListener();
|
||||
}
|
||||
@ -66,18 +62,6 @@ export class StackingContextManager {
|
||||
private compute = debounce(this._compute, 10);
|
||||
|
||||
private _compute(): void {
|
||||
if (this.mode === StackingComputeMode.Stacking) {
|
||||
return this.stackingCompute();
|
||||
} else {
|
||||
return layersComputing({
|
||||
nodes: this.nodes,
|
||||
lines: this.lines,
|
||||
context: this.context,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private stackingCompute(): void {
|
||||
const context = this.context;
|
||||
const stackingComputing = new StackingComputing();
|
||||
const { nodeLevel, lineLevel } = stackingComputing.compute({
|
||||
@ -90,7 +74,7 @@ export class StackingContextManager {
|
||||
const nodeRenderData = node.getData<FlowNodeRenderData>(FlowNodeRenderData);
|
||||
const element = nodeRenderData.node;
|
||||
element.style.position = 'absolute';
|
||||
if (!level) {
|
||||
if (level === undefined) {
|
||||
element.style.zIndex = 'auto';
|
||||
nodeRenderData.stackIndex = 0;
|
||||
return;
|
||||
@ -103,7 +87,7 @@ export class StackingContextManager {
|
||||
const level = lineLevel.get(line.id);
|
||||
const element = line.node;
|
||||
element.style.position = 'absolute';
|
||||
if (!level) {
|
||||
if (level === undefined) {
|
||||
element.style.zIndex = 'auto';
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
import {
|
||||
WorkflowLineEntity,
|
||||
WorkflowNodeEntity,
|
||||
WorkflowNodeLinesData,
|
||||
} from '@flowgram.ai/free-layout-core';
|
||||
import { FlowNodeBaseType } from '@flowgram.ai/document';
|
||||
import { WorkflowNodeEntity, WorkflowNodeLinesData } from '@flowgram.ai/free-layout-core';
|
||||
|
||||
import type { StackingContext } from './type';
|
||||
|
||||
@ -38,7 +42,7 @@ export class StackingComputing {
|
||||
this.nodeIndexes = this.computeNodeIndexesMap(nodes);
|
||||
this.topLevel = this.computeTopLevel(nodes);
|
||||
this.maxLevel = this.topLevel * 2;
|
||||
this.layerHandler(root.collapsedChildren);
|
||||
this.layerHandler(root.blocks);
|
||||
return {
|
||||
nodeLevel: this.nodeLevel,
|
||||
lineLevel: this.lineLevel,
|
||||
@ -65,9 +69,9 @@ export class StackingComputing {
|
||||
}
|
||||
|
||||
private computeTopLevel(nodes: WorkflowNodeEntity[]): number {
|
||||
const nodesWithoutRoot = nodes.filter(node => node.id !== FlowNodeBaseType.ROOT);
|
||||
const nodesWithoutRoot = nodes.filter((node) => node.id !== FlowNodeBaseType.ROOT);
|
||||
const nodeHasChildren = nodesWithoutRoot.reduce((count, node) => {
|
||||
if (node.collapsedChildren.length > 0) {
|
||||
if (node.blocks.length > 0) {
|
||||
return count + 1;
|
||||
} else {
|
||||
return count;
|
||||
@ -77,28 +81,12 @@ export class StackingComputing {
|
||||
return nodesWithoutRoot.length + nodeHasChildren + 1;
|
||||
}
|
||||
|
||||
private layerHandler(nodes: WorkflowNodeEntity[], pinTop: boolean = false): void {
|
||||
const sortedNodes = nodes.sort((a, b) => {
|
||||
const aIndex = this.nodeIndexes.get(a.id);
|
||||
const bIndex = this.nodeIndexes.get(b.id);
|
||||
if (aIndex === undefined || bIndex === undefined) {
|
||||
return 0;
|
||||
}
|
||||
return aIndex - bIndex;
|
||||
});
|
||||
|
||||
const lines = nodes
|
||||
.map(node => {
|
||||
const linesData = node.getData<WorkflowNodeLinesData>(WorkflowNodeLinesData);
|
||||
const outputLines = linesData.outputLines.filter(Boolean);
|
||||
const inputLines = linesData.inputLines.filter(Boolean);
|
||||
// 前后线条会有重复,下面 Map 会通过线条 ID 过滤掉
|
||||
return [...outputLines, ...inputLines];
|
||||
})
|
||||
.flat();
|
||||
private layerHandler(layerNodes: WorkflowNodeEntity[], pinTop: boolean = false): void {
|
||||
const nodes = this.sortNodes(layerNodes);
|
||||
const lines = this.getNodesAllLines(nodes);
|
||||
|
||||
// 线条统一设为当前层级最低
|
||||
lines.forEach(line => {
|
||||
lines.forEach((line) => {
|
||||
if (
|
||||
line.isDrawing || // 正在绘制
|
||||
this.context.hoveredEntityID === line.id || // hover
|
||||
@ -111,7 +99,7 @@ export class StackingComputing {
|
||||
}
|
||||
});
|
||||
this.levelIncrease();
|
||||
sortedNodes.forEach(node => {
|
||||
nodes.forEach((node) => {
|
||||
const selected = this.context.selectedIDs.includes(node.id);
|
||||
if (selected) {
|
||||
// 节点置顶条件:选中
|
||||
@ -121,13 +109,47 @@ export class StackingComputing {
|
||||
}
|
||||
// 节点层级逐层增高
|
||||
this.levelIncrease();
|
||||
if (node.collapsedChildren.length > 0) {
|
||||
if (node.blocks.length > 0) {
|
||||
// 子节点层级需低于后续兄弟节点,因此需要先进行计算
|
||||
this.layerHandler(node.collapsedChildren, pinTop || selected);
|
||||
this.layerHandler(node.blocks, pinTop || selected);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private sortNodes(nodes: WorkflowNodeEntity[]): WorkflowNodeEntity[] {
|
||||
return nodes.sort((a, b) => {
|
||||
const aIndex = this.nodeIndexes.get(a.id);
|
||||
const bIndex = this.nodeIndexes.get(b.id);
|
||||
if (aIndex === undefined || bIndex === undefined) {
|
||||
return 0;
|
||||
}
|
||||
return aIndex - bIndex;
|
||||
});
|
||||
}
|
||||
|
||||
private getNodesAllLines(nodes: WorkflowNodeEntity[]): WorkflowLineEntity[] {
|
||||
const lines = nodes
|
||||
.map((node) => {
|
||||
const linesData = node.getData<WorkflowNodeLinesData>(WorkflowNodeLinesData);
|
||||
const outputLines = linesData.outputLines.filter(Boolean);
|
||||
const inputLines = linesData.inputLines.filter(Boolean);
|
||||
return [...outputLines, ...inputLines];
|
||||
})
|
||||
.flat();
|
||||
|
||||
// 过滤出未计算层级的线条,以及高度优先(需要覆盖计算)的线条
|
||||
const filteredLines = lines.filter(
|
||||
(line) => this.lineLevel.get(line.id) === undefined || this.isHigherFirstLine(line)
|
||||
);
|
||||
|
||||
return filteredLines;
|
||||
}
|
||||
|
||||
private isHigherFirstLine(line: WorkflowLineEntity): boolean {
|
||||
// 父子相连的线条,需要作为高度优先的线条,避免线条不可见
|
||||
return line.to?.parent === line.from || line.from?.parent === line.to;
|
||||
}
|
||||
|
||||
private getLevel(pinTop: boolean): number {
|
||||
if (pinTop) {
|
||||
return this.topLevel + this.currentLevel;
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import type { WorkflowEntityHoverable } from '@flowgram.ai/free-layout-core';
|
||||
import type { Entity } from '@flowgram.ai/core';
|
||||
import type { WorkfloEntityHoverable } from '@flowgram.ai/free-layout-core';
|
||||
|
||||
export type StackingContext = {
|
||||
hoveredEntity?: WorkfloEntityHoverable;
|
||||
hoveredEntity?: WorkflowEntityHoverable;
|
||||
hoveredEntityID?: string;
|
||||
selectedEntities: Entity[];
|
||||
selectedIDs: string[];
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user