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:
Louis Young 2025-05-07 21:21:34 +08:00 committed by GitHub
parent 8c0f007127
commit 19ff04abc7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
97 changed files with 3401 additions and 1544 deletions

View File

@ -36,6 +36,7 @@
"@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",

View File

@ -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}
</>
);
};

View File

@ -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',

View File

@ -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);
}

View File

@ -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>
);

View File

@ -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>

View 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',
},
};

View File

@ -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`,
}}
/>
);
};

View File

@ -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>
);

View File

@ -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>
);
};

View File

@ -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>
);

View File

@ -0,0 +1,2 @@
export { GroupNodeRender } from './node-render';
export { IconGroup } from './icon-group';

View File

@ -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>
);
};

View File

@ -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);
}
}

View File

@ -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>
);

View File

@ -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>
);
};

View File

@ -0,0 +1 @@
export const isMacOS = /(Macintosh|MacIntel|MacPPC|Mac68K|iPad)/.test(navigator.userAgent);

View File

@ -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;
}
}
}
`;

View File

@ -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,
};
};

View File

@ -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>
);
};

View File

@ -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>
);

View File

@ -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>
);
};

View File

@ -0,0 +1,7 @@
export const HEADER_HEIGHT = 30;
export const HEADER_PADDING = 5;
export enum GroupField {
Title = 'title',
Color = 'color',
}

View 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;
}

View File

@ -0,0 +1,4 @@
import './index.css';
export { GroupNodeRender } from './components';
export { IconGroup } from './components';

View File

@ -2,3 +2,4 @@ export * from './base-node';
export * from './line-add-button';
export * from './node-panel';
export * from './comment';
export * from './group';

View 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>
);
};

View File

@ -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 />}

View File

@ -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>
);

View File

@ -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>

View File

@ -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,
}),
],
}),
[]

View File

@ -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',
},
{

View File

@ -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,

View File

@ -38,7 +38,7 @@ export const LoopNodeRegistry: FlowNodeRegistry = {
* padding
*/
padding: () => ({
top: 150,
top: 125,
bottom: 100,
left: 100,
right: 100,

View File

@ -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;

View File

@ -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:

View File

@ -9,6 +9,7 @@
"douyinfe",
"flowgram",
"flowgram.ai",
"Hoverable",
"openbracket",
"rsbuild",
"rspack",

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -0,0 +1,2 @@
export { FlowGroupController } from './flow-group-controller';
export { FlowGroupService } from './flow-group-service';

View File

@ -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();
}
}

View File

@ -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);
});

View File

@ -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
*/

View File

@ -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: () => {

View File

@ -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

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -30,6 +30,7 @@
"@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:*",

View File

@ -1,4 +1,2 @@
export * from './node-into-container';
export * from './sub-canvas';
export { createContainerNodePlugin } from './plugin';
export type { WorkflowContainerPluginOptions } from './type';

View File

@ -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';

View File

@ -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 }) => {

View File

@ -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;
}

View File

@ -18,3 +18,7 @@ export interface NodeIntoContainerEvent {
sourceContainer?: WorkflowNodeEntity;
targetContainer: WorkflowNodeEntity;
}
export interface WorkflowContainerPluginOptions {
disableNodeIntoContainer?: boolean;
}

View File

@ -1,3 +1,4 @@
export { SubCanvasBackground } from './background';
export { SubCanvasBorder } from './border';
export { SubCanvasRender } from './render';
export { SubCanvasTips } from './tips';

View File

@ -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>
);

View File

@ -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);
}
}

View File

@ -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>
);

View File

@ -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>
);
};

View File

@ -0,0 +1 @@
export const isMacOS = /(Macintosh|MacIntel|MacPPC|Mac68K|iPad)/.test(navigator.userAgent);

View File

@ -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;
}
}
}
`;

View File

@ -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,
};
};

View File

@ -1 +1,2 @@
export { useNodeSize } from './use-node-size';
export { NodeSize, useNodeSize } from './use-node-size';
export { useSyncNodeRenderSize } from './use-sync-node-render-size';

View File

@ -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;
}

View File

@ -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]);
};

View File

@ -1,6 +0,0 @@
import type { ReactNode } from 'react';
export interface WorkflowContainerPluginOptions {
disableNodeIntoContainer?: boolean;
renderContent?: ReactNode;
}

View File

@ -0,0 +1,6 @@
const { defineConfig } = require('@flowgram.ai/eslint-config');
module.exports = defineConfig({
preset: 'web',
packageRoot: __dirname,
});

View 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/"
}
}

View File

@ -0,0 +1,4 @@
export enum WorkflowGroupCommand {
Group = 'group',
Ungroup = 'ungroup',
}

View File

@ -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();
},
}
);

View 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
},
};

View File

@ -0,0 +1,3 @@
export { createFreeGroupPlugin } from './create-free-group-plugin';
export { WorkflowGroupService } from './workflow-group-service';
export { WorkflowGroupCommand } from './constant';

View 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();
}
}

View File

@ -0,0 +1,2 @@
export { GroupShortcut } from './group';
export { UngroupShortcut } from './ungroup';

View 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();
}
}

View File

@ -0,0 +1,7 @@
import { FC } from 'react';
export interface WorkflowGroupPluginOptions {
groupNodeRender: FC;
disableGroupShortcuts?: boolean;
disableGroupNodeRegister?: boolean;
}

View 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;
};
}

View 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();
}
});
}
}

View File

@ -0,0 +1,7 @@
{
"extends": "@flowgram.ai/ts-config/tsconfig.flow.path.json",
"compilerOptions": {
},
"include": ["./src"],
"exclude": ["node_modules"]
}

View 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.*',
],
},
});

View File

@ -0,0 +1 @@
import 'reflect-metadata';

View File

@ -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();

View File

@ -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;
}

View File

@ -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);
});
},
};

View File

@ -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 {

View File

@ -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: {

View File

@ -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')!;

View File

@ -34,10 +34,3 @@ export const StackingConfig = {
/** 最大 index */
maxIndex,
};
export enum StackingComputeMode {
/** 层叠计算模式 */
Stacking = 'stacking',
/** 层级计算模式 */
Layers = 'layers',
}

View File

@ -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);

View File

@ -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);
});
};

View File

@ -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;
}

View File

@ -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;

View File

@ -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[];

View File

@ -167,14 +167,12 @@
// "[^@]+@users\\.noreply\\.github\\.com",
// "rush-bot@example\\.org"
// ],
/**
* When Rush reports that the address is malformed, the notice can include an example
* of a recommended email. Make sure it conforms to one of the allowedEmailRegExps
* expressions.
*/
// "sampleEmail": "example@users.noreply.github.com",
/**
* The commit message to use when committing changes during 'rush publish'.
*
@ -183,7 +181,6 @@
* in the commit message, and then customize Rush's message to contain that string.
*/
// "versionBumpCommitMessage": "Bump versions [skip ci]",
/**
* The commit message to use when committing changes during 'rush version'.
*
@ -192,7 +189,6 @@
* in the commit message, and then customize Rush's message to contain that string.
*/
// "changeLogUpdateCommitMessage": "Update changelogs [skip ci]",
/**
* The commit message to use when committing changefiles during 'rush change --commit'
*
@ -218,13 +214,11 @@
* to retrieve the latest activity for the remote main branch.
*/
// "url": "https://github.com/microsoft/rush-example",
/**
* The default branch name. This tells "rush change" which remote branch to compare against.
* The default value is "main"
*/
// "defaultBranch": "main",
/**
* The default remote. This tells "rush change" which remote to compare against if the remote URL is
* not set or if a remote matching the provided remote URL is not found.
@ -605,6 +599,12 @@
"versionPolicyName": "publishPolicy",
"tags": ["level-1", "team-flow"]
},
{
"packageName": "@flowgram.ai/free-group-plugin",
"projectFolder": "packages/plugins/free-group-plugin",
"versionPolicyName": "publishPolicy",
"tags": ["level-1", "team-flow"]
},
{
"packageName": "@flowgram.ai/group-plugin",
"projectFolder": "packages/plugins/group-plugin",