Merge pull request #54 from louisyoungx/feat/sub-canvas

feat: loop node & move into container operation
This commit is contained in:
xiamidaxia 2025-03-18 16:41:17 +08:00 committed by GitHub
commit f30ebc44fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
84 changed files with 1900 additions and 303 deletions

View File

@ -35,6 +35,7 @@
"@flowgram.ai/free-lines-plugin": "workspace:*",
"@flowgram.ai/free-node-panel-plugin": "workspace:*",
"@flowgram.ai/minimap-plugin": "workspace:*",
"@flowgram.ai/free-container-plugin": "workspace:*",
"lodash-es": "^4.17.21",
"nanoid": "^4.0.2",
"react": "^18",

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -6,14 +6,12 @@ export const BaseNodeStyle = styled.div`
background-color: #fff;
border: 1px solid rgba(6, 7, 9, 0.15);
border-radius: 8px;
box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.04), 0 4px 12px 0 rgba(0, 0, 0, 0.02);
display: flex;
flex-direction: column;
justify-content: center;
position: relative;
width: 360px;
transition: all 0.3s ease;
&.selected {
border: 1px solid var(--coz-stroke-hglt, #4e40e5);
}

View File

@ -0,0 +1,14 @@
import { useNodeRender } from '@flowgram.ai/free-layout-editor';
import { ContainerNodeForm } from '@flowgram.ai/free-container-plugin';
import { NodeRenderContext } from '../../context';
export const ContainerNodeContent = () => {
const nodeRender = useNodeRender();
return (
<NodeRenderContext.Provider value={nodeRender}>
<ContainerNodeForm />;
</NodeRenderContext.Provider>
);
};

View File

@ -1,3 +1,4 @@
export * from './base-node';
export * from './line-add-button';
export * from './node-panel';
export * from './container-content';

View File

@ -1,5 +1,8 @@
import React from 'react';
import { type NodeRenderReturnType } from '@flowgram.ai/free-layout-editor';
import type { NodeRenderReturnType } from '@flowgram.ai/free-layout-editor';
export const NodeRenderContext = React.createContext<NodeRenderReturnType>({} as any);
interface INodeRenderContext extends NodeRenderReturnType {}
/** 业务自定义节点上下文 */
export const NodeRenderContext = React.createContext<INodeRenderContext>({} as INodeRenderContext);

View File

@ -1,8 +1,8 @@
import React, { useContext } from 'react';
import React from 'react';
import { FlowNodeRegistry } from '@flowgram.ai/free-layout-editor';
import { NodeRenderContext } from '../../context';
import { useNodeRenderContext } from '../../hooks';
import { FormTitleDescription, FormWrapper } from './styles';
/**
@ -10,7 +10,7 @@ import { FormTitleDescription, FormWrapper } from './styles';
* @constructor
*/
export function FormContent(props: { children?: React.ReactNode }) {
const { expanded, node } = useContext(NodeRenderContext);
const { expanded, node } = useNodeRenderContext();
const registry = node.getNodeRegistry<FlowNodeRegistry>();
return (
<FormWrapper>

View File

@ -1,32 +1,57 @@
import { useContext } from 'react';
import { useCallback, useState, type MouseEvent } from 'react';
import {
Command,
Field,
FieldRenderProps,
useClientContext,
useService,
} from '@flowgram.ai/free-layout-editor';
import { IconButton, Dropdown, Typography, Button } from '@douyinfe/semi-ui';
import { IconSmallTriangleDown, IconSmallTriangleLeft } from '@douyinfe/semi-icons';
import { NodeIntoContainerService } from '@flowgram.ai/free-container-plugin';
import { IconButton, Dropdown, Typography } from '@douyinfe/semi-ui';
import { IconMore } from '@douyinfe/semi-icons';
import { Feedback } from '../feedback';
import { FlowNodeRegistry } from '../../typings';
import { NodeRenderContext } from '../../context';
import { useNodeRenderContext } from '../../hooks';
import { getIcon } from './utils';
import { Header, Operators, Title } from './styles';
const { Text } = Typography;
function DropdownContent() {
const { node, deleteNode } = useContext(NodeRenderContext);
function DropdownButton() {
const [key, setKey] = useState(0);
const { node, deleteNode } = useNodeRenderContext();
const clientContext = useClientContext();
const registry = node.getNodeRegistry<FlowNodeRegistry>();
const handleCopy = () => {
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(() => {
clientContext.playground.commandService.executeCommand(Command.Default.COPY, node);
};
}, [clientContext, node]);
return (
<Dropdown.Menu>
<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}>
Copy
</Dropdown.Item>
@ -37,11 +62,21 @@ function DropdownContent() {
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 } = useContext(NodeRenderContext);
const { node, readonly } = useNodeRenderContext();
return (
<Header>
@ -56,24 +91,9 @@ export function FormHeader() {
)}
</Field>
</Title>
<Button
type="primary"
icon={expanded ? <IconSmallTriangleDown /> : <IconSmallTriangleLeft />}
size="small"
theme="borderless"
onClick={toggleExpand}
/>
{readonly ? undefined : (
<Operators>
<Dropdown trigger="hover" position="bottomRight" render={<DropdownContent />}>
<IconButton
color="secondary"
size="small"
theme="borderless"
icon={<IconMore />}
onClick={(e) => e.stopPropagation()}
/>
</Dropdown>
<DropdownButton />
</Operators>
)}
</Header>

View File

@ -7,7 +7,8 @@ export const Header = styled.div`
align-items: center;
width: 100%;
column-gap: 8px;
border-radius: 8px;
border-radius: 8px 8px 0 0;
cursor: move;
background: linear-gradient(#f2f2ff 0%, rgba(0, 0, 0, 0.02) 100%);
overflow: hidden;

View File

@ -1,15 +1,13 @@
import { useContext } from 'react';
import { Field } from '@flowgram.ai/free-layout-editor';
import { FxExpression } from '../fx-expression';
import { FormItem } from '../form-item';
import { Feedback } from '../feedback';
import { JsonSchema } from '../../typings';
import { NodeRenderContext } from '../../context';
import { useNodeRenderContext } from '../../hooks';
export function FormInputs() {
const { readonly } = useContext(NodeRenderContext);
const { readonly } = useNodeRenderContext();
return (
<Field<JsonSchema> name="inputs">
{({ field: inputsField }) => {

View File

@ -39,7 +39,7 @@ export function FxExpression(props: FxExpressionProps) {
}
};
return (
<div style={{ display: 'flex' }}>
<div style={{ display: 'flex', maxWidth: 300 }}>
{isExpression ? (
<VariableSelector
value={value.content}

View File

@ -1,10 +1,10 @@
import React, { useContext, useState } from 'react';
import React, { useState } from 'react';
import { Button } from '@douyinfe/semi-ui';
import { IconPlus } from '@douyinfe/semi-icons';
import { JsonSchema } from '../../typings';
import { NodeRenderContext } from '../../context';
import { useNodeRenderContext } from '../../hooks';
import { PropertyEdit } from './property-edit';
export interface PropertiesEditProps {
@ -15,7 +15,7 @@ export interface PropertiesEditProps {
export const PropertiesEdit: React.FC<PropertiesEditProps> = (props) => {
const value = (props.value || {}) as Record<string, JsonSchema>;
const { readonly } = useContext(NodeRenderContext);
const { readonly } = useNodeRenderContext();
const [newProperty, updateNewPropertyFromCache] = useState<{ key: string; value: JsonSchema }>({
key: '',
value: { type: 'string' },

View File

@ -1 +1,2 @@
export { useEditorProps } from './use-editor-props';
export { useNodeRenderContext } from './use-node-render-context';

View File

@ -9,6 +9,7 @@ import {
} from '@flowgram.ai/free-node-panel-plugin';
import { createFreeLinesPlugin } from '@flowgram.ai/free-lines-plugin';
import { FreeLayoutProps } from '@flowgram.ai/free-layout-editor';
import { createContainerNodePlugin } from '@flowgram.ai/free-container-plugin';
import { FlowNodeRegistry, FlowDocumentJSON } from '../typings';
import { shortcuts } from '../shortcuts';
@ -16,6 +17,7 @@ import { createSyncVariablePlugin } from '../plugins';
import { defaultFormMeta } from '../nodes/default-form-meta';
import { SelectorBoxPopover } from '../components/selector-box-popover';
import { BaseNode, LineAddButton, NodePanel } from '../components';
import { ContainerNodeContent } from '../components';
export function useEditorProps(
initialData: FlowDocumentJSON,
@ -199,6 +201,9 @@ export function useEditorProps(
createFreeNodePanelPlugin({
renderer: NodePanel,
}),
createContainerNodePlugin({
renderContent: <ContainerNodeContent />,
}),
],
}),
[]

View File

@ -0,0 +1,5 @@
import { useContext } from 'react';
import { NodeRenderContext } from '../context';
export const useNodeRenderContext = () => useContext(NodeRenderContext);

View File

@ -7,8 +7,8 @@ export const initialData: FlowDocumentJSON = {
type: 'start',
meta: {
position: {
x: 181,
y: 249.5,
x: 180,
y: 313.25,
},
},
data: {
@ -29,8 +29,8 @@ export const initialData: FlowDocumentJSON = {
type: 'condition',
meta: {
position: {
x: 643,
y: 213,
x: 640,
y: 298.75,
},
},
data: {
@ -39,11 +39,17 @@ export const initialData: FlowDocumentJSON = {
conditions: [
{
key: 'if_0',
value: { type: 'expression', content: '' },
value: {
type: 'expression',
content: '',
},
},
{
key: 'if_1',
value: { type: 'expression', content: '' },
key: 'if_f0rOAt',
value: {
type: 'expression',
content: '',
},
},
],
},
@ -71,10 +77,9 @@ export const initialData: FlowDocumentJSON = {
{
id: 'llm_0',
type: 'llm',
blocks: [],
meta: {
position: {
x: 1105,
x: 1430,
y: 0,
},
},
@ -107,29 +112,77 @@ export const initialData: FlowDocumentJSON = {
outputs: {
type: 'object',
properties: {
result: { type: 'string' },
result: {
type: 'string',
},
},
},
},
},
{
id: 'llm_1',
type: 'llm',
blocks: [],
id: 'end_0',
type: 'end',
meta: {
position: {
x: 1105,
y: 405,
x: 2220,
y: 313.25,
},
},
data: {
title: 'LLM_1',
inputsValues: {
modelType: 'gpt-3.5-turbo',
temperature: 0.5,
systemPrompt: 'You are an AI assistant.',
prompt: 'Hello.',
title: 'End',
outputs: {
type: 'object',
properties: {
result: {
type: 'string',
},
},
},
},
},
{
id: 'loop_H8M3U',
type: 'loop',
meta: {
position: {
x: 1020,
y: 532.5,
},
},
data: {
title: 'Loop_2',
inputsValues: {},
inputs: {
type: 'object',
required: ['loopTimes'],
properties: {
loopTimes: {
type: 'number',
},
},
},
outputs: {
type: 'object',
properties: {
result: {
type: 'string',
},
},
},
},
blocks: [
{
id: 'llm_CBdCg',
type: 'llm',
meta: {
position: {
x: 180,
y: 0,
},
},
data: {
title: 'LLM_4',
inputsValues: {},
inputs: {
type: 'object',
required: ['modelType', 'temperature', 'prompt'],
@ -151,22 +204,43 @@ export const initialData: FlowDocumentJSON = {
outputs: {
type: 'object',
properties: {
result: { type: 'string' },
result: {
type: 'string',
},
},
},
},
},
{
id: 'end_0',
type: 'end',
id: 'llm_gZafu',
type: 'llm',
meta: {
position: {
x: 1567,
y: 249.5,
x: 640,
y: 0,
},
},
data: {
title: 'End',
title: 'LLM_5',
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: {
@ -179,10 +253,35 @@ export const initialData: FlowDocumentJSON = {
},
],
edges: [
{ sourceNodeID: 'start_0', targetNodeID: 'condition_0' },
{ sourceNodeID: 'condition_0', sourcePortID: 'if_0', targetNodeID: 'llm_0' },
{ sourceNodeID: 'condition_0', sourcePortID: 'if_1', targetNodeID: 'llm_1' },
{ sourceNodeID: 'llm_0', targetNodeID: 'end_0' },
{ sourceNodeID: 'llm_1', targetNodeID: 'end_0' },
{
sourceNodeID: 'llm_CBdCg',
targetNodeID: 'llm_gZafu',
},
],
},
],
edges: [
{
sourceNodeID: 'start_0',
targetNodeID: 'condition_0',
},
{
sourceNodeID: 'condition_0',
targetNodeID: 'llm_0',
sourcePortID: 'if_0',
},
{
sourceNodeID: 'condition_0',
targetNodeID: 'loop_H8M3U',
sourcePortID: 'if_f0rOAt',
},
{
sourceNodeID: 'llm_0',
targetNodeID: 'end_0',
},
{
sourceNodeID: 'loop_H8M3U',
targetNodeID: 'end_0',
},
],
};

View File

@ -1,15 +1,13 @@
import { useContext } from 'react';
import { nanoid } from 'nanoid';
import { Field, FieldArray } from '@flowgram.ai/free-layout-editor';
import { Button } from '@douyinfe/semi-ui';
import { IconPlus, IconCrossCircleStroked } from '@douyinfe/semi-icons';
import { FlowLiteralValueSchema, FlowRefValueSchema } from '../../../typings';
import { useNodeRenderContext } from '../../../hooks';
import { FxExpression } from '../../../form-components/fx-expression';
import { FormItem } from '../../../form-components';
import { Feedback } from '../../../form-components';
import { NodeRenderContext } from '../../../context';
import { ConditionPort } from './styles';
interface ConditionValue {
@ -18,7 +16,7 @@ interface ConditionValue {
}
export function ConditionInputs() {
const { readonly } = useContext(NodeRenderContext);
const { readonly } = useNodeRenderContext();
return (
<FieldArray name="inputsValues.conditions">
{({ field }) => (

View File

@ -1,5 +1,6 @@
import { FlowNodeRegistry } from '../typings';
import { StartNodeRegistry } from './start';
import { LoopNodeRegistry } from './loop';
import { LLMNodeRegistry } from './llm';
import { EndNodeRegistry } from './end';
import { ConditionNodeRegistry } from './condition';
@ -9,4 +10,5 @@ export const nodeRegistries: FlowNodeRegistry[] = [
StartNodeRegistry,
EndNodeRegistry,
LLMNodeRegistry,
LoopNodeRegistry,
];

View File

@ -14,7 +14,7 @@ export const LLMNodeRegistry: FlowNodeRegistry = {
meta: {
size: {
width: 360,
height: 94,
height: 305,
},
},
onAdd() {

View File

@ -0,0 +1,70 @@
import { nanoid } from 'nanoid';
import {
WorkflowNodeEntity,
PositionSchema,
FlowNodeTransformData,
} from '@flowgram.ai/free-layout-editor';
import { ContainerNodeRenderKey } from '@flowgram.ai/free-container-plugin';
import { FlowNodeRegistry } from '../../typings';
import iconLoop from '../../assets/icon-loop.jpg';
let index = 0;
export const LoopNodeRegistry: FlowNodeRegistry = {
type: 'loop',
info: {
icon: iconLoop,
description:
'Used to repeatedly execute a series of tasks by setting the number of iterations and logic.',
},
meta: {
renderKey: ContainerNodeRenderKey,
isContainer: true,
size: {
width: 560,
height: 400,
},
padding: () => ({
top: 205,
bottom: 50,
left: 100,
right: 100,
}),
selectable(node: WorkflowNodeEntity, mousePos?: PositionSchema): boolean {
if (!mousePos) {
return true;
}
const transform = node.getData<FlowNodeTransformData>(FlowNodeTransformData);
// 鼠标开始时所在位置不包括当前节点时才可选中
return !transform.bounds.contains(mousePos.x, mousePos.y);
},
},
onAdd() {
return {
id: `loop_${nanoid(5)}`,
type: 'loop',
data: {
title: `Loop_${++index}`,
inputsValues: {},
inputs: {
type: 'object',
required: ['loopTimes'],
properties: {
loopTimes: {
type: 'number',
},
},
},
outputs: {
type: 'object',
properties: {
result: { type: 'string' },
},
},
},
};
},
onCreate() {
// NOTICE: 这个函数是为了避免触发固定布局 flowDocument.addBlocksAsChildren
},
};

View File

@ -29,6 +29,7 @@
"@flowgram.ai/free-auto-layout-plugin": "workspace:*",
"@flowgram.ai/minimap-plugin": "workspace:*",
"@flowgram.ai/free-stack-plugin": "workspace:*",
"@flowgram.ai/free-container-plugin": "workspace:*",
"@flowgram.ai/free-snap-plugin": "workspace:*",
"@flowgram.ai/free-node-panel-plugin": "workspace:*",
"@flowgram.ai/free-lines-plugin": "workspace:*",

View File

@ -193,6 +193,9 @@ importers:
'@douyinfe/semi-ui':
specifier: ^2.72.3
version: 2.72.3(acorn@8.14.0)(react-dom@18.3.1)(react@18.3.1)
'@flowgram.ai/free-container-plugin':
specifier: workspace:*
version: link:../../packages/plugins/free-container-plugin
'@flowgram.ai/free-layout-editor':
specifier: workspace:*
version: link:../../packages/client/free-layout-editor
@ -409,6 +412,9 @@ importers:
'@flowgram.ai/free-auto-layout-plugin':
specifier: workspace:*
version: link:../../packages/plugins/free-auto-layout-plugin
'@flowgram.ai/free-container-plugin':
specifier: workspace:*
version: link:../../packages/plugins/free-container-plugin
'@flowgram.ai/free-layout-editor':
specifier: workspace:*
version: link:../../packages/client/free-layout-editor
@ -2083,6 +2089,85 @@ importers:
specifier: ^0.34.6
version: 0.34.6(jsdom@22.1.0)
../../packages/plugins/free-container-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-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/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.0)(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-history-plugin:
dependencies:
'@flowgram.ai/core':

View File

@ -17,6 +17,7 @@ import {
FlowNodeJSON,
MoveNodeConfig,
OnNodeAddEvent,
OnNodeMoveEvent,
} from '../typings';
import { FlowDocument } from '../flow-document';
import { FlowNodeEntity } from '../entities';
@ -38,9 +39,13 @@ export class FlowOperationBaseServiceImpl implements FlowOperationBaseService {
protected toDispose = new DisposableCollection();
private onNodeMoveEmitter = new Emitter<OnNodeMoveEvent>();
readonly onNodeMove = this.onNodeMoveEmitter.event;
@postConstruct()
protected init() {
this.toDispose.push(this.onNodeAddEmitter);
this.toDispose.pushAll([this.onNodeAddEmitter, this.onNodeMoveEmitter]);
}
addNode(nodeJSON: FlowNodeJSON, config: AddNodeConfig = {}): FlowNodeEntity {
@ -127,7 +132,7 @@ export class FlowOperationBaseServiceImpl implements FlowOperationBaseService {
return;
}
let toIndex = typeof index === 'undefined' ? parent.children.length : index;
let toIndex = typeof index === 'undefined' ? newParentEntity.collapsedChildren.length : index;
return this.doMoveNode(entity, newParentEntity, toIndex);
}
@ -301,10 +306,23 @@ export class FlowOperationBaseServiceImpl implements FlowOperationBaseService {
}
protected doMoveNode(node: FlowNodeEntity, newParent: FlowNodeEntity, index: number) {
return this.document.moveChildNodes({
if (!node.parent) {
throw new Error('root node cannot move');
}
const event: OnNodeMoveEvent = {
node,
fromParent: node.parent,
toParent: newParent,
fromIndex: this.getNodeIndex(node),
toIndex: index,
};
this.document.moveChildNodes({
nodeIds: [this.toId(node)],
toParentId: this.toId(newParent),
toIndex: index,
});
this.onNodeMoveEmitter.fire(event);
}
}

View File

@ -233,6 +233,17 @@ export interface OnNodeAddEvent {
data: AddNodeData;
}
/**
*
*/
export interface OnNodeMoveEvent {
node: FlowNodeEntity;
fromParent: FlowNodeEntity;
fromIndex: number;
toParent: FlowNodeEntity;
toIndex: number;
}
export interface FlowOperationBaseService extends Disposable {
/**
*
@ -300,6 +311,11 @@ export interface FlowOperationBaseService extends Disposable {
*
*/
onNodeAdd: Event<OnNodeAddEvent>;
/**
*
*/
onNodeMove: Event<OnNodeMoveEvent>;
}
export const FlowOperationBaseService = Symbol('FlowOperationBaseService');

View File

@ -56,12 +56,12 @@ describe('use-node-render', () => {
const { result } = renderHook(() => useNodeRender(), {
wrapper,
});
result.current.selectNode(new MouseEvent('click', { metaKey: true }) as any);
result.current.selectNode(new MouseEvent('click', { shiftKey: true }) as any);
const { result: result2 } = renderHook(() => useNodeRender(), {
wrapper,
});
expect(result2.current.selected).toEqual(true);
result.current.selectNode(new MouseEvent('click', { metaKey: true }) as any);
result.current.selectNode(new MouseEvent('click', { shiftKey: true }) as any);
const { result: result3 } = renderHook(() => useNodeRender(), {
wrapper,
});
@ -98,7 +98,7 @@ describe('use-node-render', () => {
});
expect(result2.current.selected).toEqual(true);
expect(node.getData(PositionData).toJSON()).toEqual({ x: 100, y: 100 });
result.current.selectNode(new MouseEvent('click', { metaKey: true }) as any);
result.current.selectNode(new MouseEvent('click', { shiftKey: true }) as any);
const { result: result3 } = renderHook(() => useNodeRender(), {
wrapper,
});
@ -115,7 +115,7 @@ describe('use-node-render', () => {
);
await delay(10);
// 拖拽结束可以取消选中
result.current.selectNode(new MouseEvent('click', { metaKey: true }) as any);
result.current.selectNode(new MouseEvent('click', { shiftKey: true }) as any);
expect(result.current.selected).toEqual(false);
});
it('start drag input', async () => {

View File

@ -163,6 +163,7 @@ export function createSubCanvasNodes(document: WorkflowDocument) {
id: 'subCanvas_0',
type: FlowNodeBaseType.SUB_CANVAS,
meta: {
isContainer: true,
position: { x: 100, y: 0 },
subCanvas: () => ({
isCanvas: true,

View File

@ -205,6 +205,7 @@ describe('workflow-drag-service', () => {
id: 'sub_canvas_0',
type: FlowNodeBaseType.SUB_CANVAS,
meta: {
isContainer: true,
position: {
x: 0,
y: 0,
@ -506,6 +507,7 @@ describe('workflow-drag-service', () => {
id: 'sub_canvas_0',
type: FlowNodeBaseType.SUB_CANVAS,
meta: {
isContainer: true,
position: { x: 0, y: -500 },
size: { width: 1000, height: 1000 },
selectable: true,

View File

@ -177,6 +177,7 @@ describe('workflow-document', () => {
id: 'sun_canvas_0',
type: FlowNodeBaseType.SUB_CANVAS,
meta: {
isContainer: true,
position: { x: 10, y: 10 },
},
blocks: [
@ -499,6 +500,7 @@ describe('workflow-document with nestedJSON & subCanvas', () => {
id: 'subCanvas_0',
type: FlowNodeBaseType.SUB_CANVAS,
meta: {
isContainer: true,
position: { x: 100, y: 0 },
subCanvas: () => ({
isCanvas: true,

View File

@ -1,4 +1,5 @@
import { type IPoint, Rectangle, Emitter } from '@flowgram.ai/utils';
import { FlowNodeTransformData } from '@flowgram.ai/document';
import {
Entity,
type EntityOpts,
@ -129,7 +130,7 @@ export class WorkflowPortEntity extends Entity<WorkflowPortEntityOpts> {
get point(): IPoint {
const { targetElement } = this;
const { bounds } = this.node.getData(TransformData)!;
const { bounds } = this.node.getData(FlowNodeTransformData)!;
if (targetElement) {
const pos = domReactToBounds(targetElement.getBoundingClientRect()).center;
return this.entityManager
@ -164,7 +165,7 @@ export class WorkflowPortEntity extends Entity<WorkflowPortEntityOpts> {
*/
get relativePosition(): IPoint {
const { point } = this;
const { bounds } = this.node.getData(TransformData)!;
const { bounds } = this.node.getData(FlowNodeTransformData)!;
return {
x: point.x - bounds.x,
y: point.y - bounds.y,

View File

@ -0,0 +1,5 @@
import React from 'react';
import { NodeRenderReturnType } from './typings';
export const NodeRenderContext = React.createContext<NodeRenderReturnType>({} as any);

View File

@ -57,7 +57,7 @@ export function useNodeRender(nodeFromProps?: WorkflowNodeEntity): NodeRenderRet
}
isDragging.current = true;
// 拖拽选中的节点
dragService.startDragSelectedNodes(e).finally(() =>
dragService.startDragSelectedNodes(e)?.finally(() =>
setTimeout(() => {
isDragging.current = false;
})
@ -75,7 +75,7 @@ export function useNodeRender(nodeFromProps?: WorkflowNodeEntity): NodeRenderRet
return;
}
// 追加选择
if (e.metaKey || e.shiftKey || e.ctrlKey) {
if (e.shiftKey) {
selectionService.toggleSelect(node);
} else {
selectionService.selectNode(node);

View File

@ -2,3 +2,4 @@ export * from './workflow-select-service';
export * from './workflow-hover-service';
export * from './workflow-drag-service';
export * from './workflow-reset-layout-service';
export * from './workflow-operation-base-service';

View File

@ -7,14 +7,15 @@ import {
type IPoint,
PromiseDeferred,
Emitter,
type PositionSchema,
DisposableCollection,
Rectangle,
delay,
Disposable,
} from '@flowgram.ai/utils';
import type { PositionSchema } from '@flowgram.ai/utils';
import {
FlowNodeTransformData,
FlowNodeType,
FlowOperationBaseService,
type FlowNodeEntity,
} from '@flowgram.ai/document';
@ -31,6 +32,7 @@ import { WorkflowLinesManager } from '../workflow-lines-manager';
import { WorkflowDocumentOptions } from '../workflow-document-option';
import { WorkflowDocument } from '../workflow-document';
import { WorkflowCommands } from '../workflow-commands';
import { LineEventProps, NodesDragEvent, OnDragLineEnd } from '../typings/workflow-drag';
import { type WorkflowNodeJSON, type WorkflowNodeMeta } from '../typings';
import { WorkflowNodePortsData } from '../entity-datas';
import {
@ -60,36 +62,6 @@ function checkDragSuccess(
return false;
}
interface LineEventProps {
type: 'onDrag' | 'onDragEnd';
onDragNodeId?: string;
event?: MouseEvent;
}
interface INodesDragEvent {
type: string;
nodes: FlowNodeEntity[];
startPositions: IPoint[];
altKey: boolean;
}
export interface NodesDragEndEvent extends INodesDragEvent {
type: 'onDragEnd';
}
export type NodesDragEvent = NodesDragEndEvent;
export type onDragLineEndParams = {
fromPort: WorkflowPortEntity;
toPort?: WorkflowPortEntity;
mousePos: PositionSchema;
line?: WorkflowLineEntity;
originLine?: WorkflowLineEntity;
event: PlaygroundDragEvent;
};
export type OnDragLineEnd = (params: onDragLineEndParams) => Promise<void>;
@injectable()
export class WorkflowDragService {
@inject(PlaygroundConfigEntity)
@ -147,22 +119,24 @@ export class WorkflowDragService {
/**
*
* @param event
* @param triggerEvent
*/
startDragSelectedNodes(event: MouseEvent | React.MouseEvent): Promise<boolean> {
startDragSelectedNodes(triggerEvent: MouseEvent | React.MouseEvent): Promise<boolean> {
let { selectedNodes } = this.selectService;
if (
selectedNodes.length === 0 ||
this.playgroundConfig.readonly ||
this.playgroundConfig.disabled
this.playgroundConfig.disabled ||
this.isDragging
) {
return Promise.resolve(false);
}
this.isDragging = true;
const sameParent = this.childrenOfContainer(selectedNodes);
if (sameParent && sameParent.flowNodeType !== FlowNodeBaseType.ROOT) {
selectedNodes = [sameParent];
}
const { altKey } = event;
const { altKey } = triggerEvent;
// 节点整体开始位置
let startPosition = this.getNodesPosition(selectedNodes);
// 单个节点开始位置
@ -173,11 +147,19 @@ export class WorkflowDragService {
let dragSuccess = false;
const startTime = Date.now();
const dragger = new PlaygroundDrag({
onDragStart: () => {
this.isDragging = true;
onDragStart: (dragEvent) => {
this._nodesDragEmitter.fire({
type: 'onDragStart',
nodes: selectedNodes,
startPositions,
altKey,
dragEvent,
triggerEvent,
dragger,
});
},
onDrag: (e) => {
if (!dragSuccess && checkDragSuccess(Date.now() - startTime, e)) {
onDrag: (dragEvent) => {
if (!dragSuccess && checkDragSuccess(Date.now() - startTime, dragEvent)) {
dragSuccess = true;
if (altKey) {
// 按住 alt 为复制
@ -207,11 +189,13 @@ export class WorkflowDragService {
// 计算拖拽偏移量
const offset: IPoint = this.getDragPosOffset({
event: e,
event: dragEvent,
selectedNodes,
startPosition,
});
const positions: PositionSchema[] = [];
selectedNodes.forEach((node, index) => {
const transform = node.getData(TransformData);
const nodeStartPosition = startPositions[index];
@ -230,21 +214,36 @@ export class WorkflowDragService {
transform.update({
position: newPosition,
});
positions.push(newPosition);
});
this._nodesDragEmitter.fire({
type: 'onDragging',
nodes: selectedNodes,
startPositions,
positions,
altKey,
dragEvent,
triggerEvent,
dragger,
});
},
onDragEnd: () => {
onDragEnd: (dragEvent) => {
this.isDragging = false;
this._nodesDragEmitter.fire({
type: 'onDragEnd',
nodes: selectedNodes,
startPositions,
altKey,
dragEvent,
triggerEvent,
dragger,
});
},
});
return dragger
.start(event.clientX, event.clientY, this.playgroundConfig)
.then(() => dragSuccess);
.start(triggerEvent.clientX, triggerEvent.clientY, this.playgroundConfig)
?.then(() => dragSuccess);
}
/**
@ -332,6 +331,13 @@ export class WorkflowDragService {
},
onDragEnd: async (e) => {
const dropNode = this._dropNode;
const { allowDrop } = this.canDropToNode({
dragNodeType: type,
dropNode,
});
if (!allowDrop) {
return this.clearDrop();
}
const dragNode = await this.dropCard(type, e, data, dropNode);
this.clearDrop();
if (dragNode) {
@ -396,6 +402,27 @@ export class WorkflowDragService {
};
}
/**
*
*/
public canDropToNode(params: { dragNodeType?: FlowNodeType; dropNode?: WorkflowNodeEntity }): {
allowDrop: boolean;
message?: string;
dropNode?: WorkflowNodeEntity;
} {
const { dragNodeType, dropNode } = params;
if (!dragNodeType) {
return {
allowDrop: false,
message: 'Please select a node to drop',
};
}
return {
allowDrop: true,
dropNode,
};
}
/**
*
*/
@ -440,10 +467,12 @@ export class WorkflowDragService {
}
return this.nodeSelectable(entity);
})
.filter((transform) => {
const { entity } = transform;
return entity.flowNodeType === FlowNodeBaseType.SUB_CANVAS;
});
.filter((transform) => this.isContainer(transform.entity));
}
/** 是否容器节点 */
private isContainer(node?: WorkflowNodeEntity): boolean {
return node?.getNodeMeta<WorkflowNodeMeta>().isContainer ?? false;
}
/**
@ -519,8 +548,8 @@ export class WorkflowDragService {
return {
hasError: false,
};
} else if (toNode.flowNodeType === FlowNodeBaseType.SUB_CANVAS) {
// 在子画布内进行连线的情况,需忽略
} else if (this.isContainer(toNode)) {
// 在容器内进行连线的情况,需忽略
return {
hasError: false,
};
@ -631,7 +660,7 @@ export class WorkflowDragService {
});
this.setLineColor(line, this.linesManager.lineColor.drawing);
if (toNode && toNode.flowNodeType !== FlowNodeBaseType.SUB_CANVAS) {
if (toNode && !this.isContainer(toNode)) {
// 如果鼠标 hover 在 node 中的时候,默认连线到这个 node 的初始位置
const portsData = toNode.getData(WorkflowNodePortsData)!;
toPort = portsData.inputPorts[0];

View File

@ -0,0 +1,45 @@
import { inject } from 'inversify';
import { IPoint, Emitter } from '@flowgram.ai/utils';
import { FlowNodeEntityOrId, FlowOperationBaseServiceImpl } from '@flowgram.ai/document';
import { TransformData } from '@flowgram.ai/core';
import { WorkflowDocument } from '../workflow-document';
import {
NodePostionUpdateEvent,
WorkflowOperationBaseService,
} from '../typings/workflow-operation';
export class WorkflowOperationBaseServiceImpl
extends FlowOperationBaseServiceImpl
implements WorkflowOperationBaseService
{
@inject(WorkflowDocument)
protected declare document: WorkflowDocument;
private onNodePostionUpdateEmitter = new Emitter<NodePostionUpdateEvent>();
public readonly onNodePostionUpdate = this.onNodePostionUpdateEmitter.event;
updateNodePosition(nodeOrId: FlowNodeEntityOrId, position: IPoint): void {
const node = this.toNodeEntity(nodeOrId);
if (!node) {
return;
}
const transformData = node.getData(TransformData);
const oldPosition = {
x: transformData.position.x,
y: transformData.position.y,
};
transformData.update({
position,
});
this.onNodePostionUpdateEmitter.fire({
node,
oldPosition,
newPosition: position,
});
}
}

View File

@ -4,6 +4,8 @@ export * from './workflow-node';
export * from './workflow-registry';
export * from './workflow-line';
export * from './workflow-sub-canvas';
export * from './workflow-operation';
export * from './workflow-drag';
export const URLParams = Symbol('');

View File

@ -0,0 +1,49 @@
import type React from 'react';
import { type PositionSchema } from '@flowgram.ai/utils';
import { type FlowNodeEntity } from '@flowgram.ai/document';
import { PlaygroundDrag, type PlaygroundDragEvent } from '@flowgram.ai/core';
import { type WorkflowLineEntity, type WorkflowPortEntity } from '../entities';
export interface LineEventProps {
type: 'onDrag' | 'onDragEnd';
onDragNodeId?: string;
event?: MouseEvent;
}
interface INodesDragEvent {
type: string;
nodes: FlowNodeEntity[];
startPositions: PositionSchema[];
altKey: boolean;
dragEvent: PlaygroundDragEvent;
triggerEvent: MouseEvent | React.MouseEvent;
dragger: PlaygroundDrag;
}
export interface NodesDragStartEvent extends INodesDragEvent {
type: 'onDragStart';
}
export interface NodesDragEndEvent extends INodesDragEvent {
type: 'onDragEnd';
}
export interface NodesDraggingEvent extends INodesDragEvent {
type: 'onDragging';
positions: PositionSchema[];
}
export type NodesDragEvent = NodesDragStartEvent | NodesDraggingEvent | NodesDragEndEvent;
export type onDragLineEndParams = {
fromPort: WorkflowPortEntity;
toPort?: WorkflowPortEntity;
mousePos: PositionSchema;
line?: WorkflowLineEntity;
originLine?: WorkflowLineEntity;
event: PlaygroundDragEvent;
};
export type OnDragLineEnd = (params: onDragLineEndParams) => Promise<void>;

View File

@ -18,6 +18,7 @@ export interface WorkflowNodeMeta extends FlowNodeMeta {
defaultPorts?: WorkflowPorts; // 默认点位
useDynamicPort?: boolean; // 使用动态点位,会计算 data-port-key
subCanvas?: (node: WorkflowNodeEntity) => WorkflowSubCanvas | undefined;
isContainer?: boolean; // 是否容器节点
}
/**

View File

@ -0,0 +1,28 @@
import { IPoint, Event } from '@flowgram.ai/utils';
import {
FlowNodeEntity,
FlowNodeEntityOrId,
FlowOperationBaseService,
} from '@flowgram.ai/document';
export interface NodePostionUpdateEvent {
node: FlowNodeEntity;
oldPosition: IPoint;
newPosition: IPoint;
}
export interface WorkflowOperationBaseService extends FlowOperationBaseService {
/**
*
*/
readonly onNodePostionUpdate: Event<NodePostionUpdateEvent>;
/**
*
* @param nodeOrId
* @param position
* @returns
*/
updateNodePosition(nodeOrId: FlowNodeEntityOrId, position: IPoint): void;
}
export const WorkflowOperationBaseService = Symbol('WorkflowOperationBaseService');

View File

@ -1,6 +1,6 @@
import { ContainerModule } from 'inversify';
import { FlowDocument, FlowDocumentContribution } from '@flowgram.ai/document';
import { bindContributions } from '@flowgram.ai/utils';
import { FlowDocument, FlowDocumentContribution } from '@flowgram.ai/document';
import { WorkflowLinesManager } from './workflow-lines-manager';
import {
@ -10,12 +10,13 @@ import {
import { WorkflowDocumentContribution } from './workflow-document-contribution';
import { WorkflowDocument, WorkflowDocumentProvider } from './workflow-document';
import { getUrlParams } from './utils/get-url-params';
import { URLParams } from './typings';
import { URLParams, WorkflowOperationBaseService } from './typings';
import {
WorkflowDragService,
WorkflowHoverService,
WorkflowSelectService,
WorkflowResetLayoutService,
WorkflowOperationBaseServiceImpl,
} from './service';
import { FreeLayout } from './layout';
@ -28,6 +29,7 @@ export const WorkflowDocumentContainerModule = new ContainerModule(
bind(WorkflowSelectService).toSelf().inSingletonScope();
bind(WorkflowHoverService).toSelf().inSingletonScope();
bind(WorkflowResetLayoutService).toSelf().inSingletonScope();
bind(WorkflowOperationBaseService).to(WorkflowOperationBaseServiceImpl).inSingletonScope();
bind(URLParams)
.toDynamicValue(() => getUrlParams())
.inSingletonScope();
@ -37,7 +39,7 @@ export const WorkflowDocumentContainerModule = new ContainerModule(
});
rebind(FlowDocument).toService(WorkflowDocument);
bind(WorkflowDocumentProvider)
.toDynamicValue(ctx => () => ctx.container.get(WorkflowDocument))
.toDynamicValue((ctx) => () => ctx.container.get(WorkflowDocument))
.inSingletonScope();
},
}
);

View File

@ -4,8 +4,13 @@ import { TransformData } from '@flowgram.ai/core';
import { type WorkflowLinesManager } from './workflow-lines-manager';
import { initFormDataFromJSON, toFormJSON } from './utils/flow-node-form-data';
import { LineColor, LineRenderType, WorkflowNodeJSON, WorkflowNodeMeta } from './typings';
import { onDragLineEndParams } from './service';
import {
LineColor,
LineRenderType,
onDragLineEndParams,
WorkflowNodeJSON,
WorkflowNodeMeta,
} from './typings';
import {
type WorkflowLineEntity,
type WorkflowLinePortInfo,

View File

@ -367,11 +367,11 @@ export class WorkflowDocument extends FlowDocument {
const endNodeId = allNode.find((node) => node.isNodeEnd)!.id;
// 子画布内节点无需开始/结束
const nodeInSubCanvas = allNode
.filter((node) => node.parent?.flowNodeType === FlowNodeBaseType.SUB_CANVAS)
const nodeInContainer = allNode
.filter((node) => node.parent?.getNodeMeta<WorkflowNodeMeta>().isContainer)
.map((node) => node.id);
const associatedCache = new Set([endNodeId, ...nodeInSubCanvas]);
const associatedCache = new Set([endNodeId, ...nodeInContainer]);
const bfs = (nodeId: string) => {
if (associatedCache.has(nodeId)) {
return;

View File

@ -1,11 +1,9 @@
import React from 'react';
import { inject, injectable } from 'inversify';
import {
FlowNodeEntity,
FlowNodeRenderData,
FlowNodeTransformData,
} from '@flowgram.ai/document';
import { domUtils } from '@flowgram.ai/utils';
import { Rectangle } from '@flowgram.ai/utils';
import { FlowNodeEntity, FlowNodeRenderData, FlowNodeTransformData } from '@flowgram.ai/document';
import {
CommandRegistry,
EditorState,
@ -16,8 +14,6 @@ import {
observeEntity,
observeEntityDatas,
} from '@flowgram.ai/core';
import { domUtils } from '@flowgram.ai/utils';
import { Rectangle } from '@flowgram.ai/utils';
import { FlowRendererKey, FlowRendererRegistry } from '../flow-renderer-registry';
import { FlowSelectConfigEntity, SelectorBoxConfigEntity } from '../entities';
@ -32,6 +28,7 @@ export interface SelectorBoxPopoverProps {
export interface FlowSelectorBoundsLayerOptions extends LayerOptions {
ignoreOneSelect?: boolean;
ignoreChildrenLength?: boolean;
boundsPadding?: number; // 边框留白,默认 10
disableBackground?: boolean; // 禁用背景框
backgroundClassName?: string; // 节点下边
@ -130,6 +127,7 @@ export class FlowSelectorBoundsLayer extends Layer<FlowSelectorBoundsLayerOption
render(): JSX.Element {
const {
ignoreOneSelect,
ignoreChildrenLength,
SelectorBoxPopover: SelectorBoxPopoverFromOpts,
disableBackground,
CustomBoundsRenderer,
@ -148,7 +146,7 @@ export class FlowSelectorBoundsLayer extends Layer<FlowSelectorBoundsLayerOption
(ignoreOneSelect &&
selectedNodes.length === 1 &&
// 选中的节点不包含多个子节点
(selectedNodes[0] as FlowNodeEntity).childrenLength <= 1)
(ignoreChildrenLength || (selectedNodes[0] as FlowNodeEntity).childrenLength <= 1))
) {
domUtils.setStyle(bg, {
display: 'none',

View File

@ -105,6 +105,32 @@ describe('history-operation-service moveNode', () => {
expect(getNodeChildrenIds(root)).toEqual(['start_0', 'dynamicSplit_0', 'end_0']);
});
it('move node with parent and without index', async () => {
const root = flowDocument.getNode('root');
const block0 = flowDocument.getNode('block_0');
flowOperationService.addNode(
{ id: 'test0', type: 'test' },
{
parent: block0,
}
);
flowDocument.addFromNode('start_0', {
type: 'test',
id: 'test1',
});
flowOperationService.moveNode('test1', { parent: 'block_0' });
expect(getNodeChildrenIds(root)).toEqual(['start_0', 'dynamicSplit_0', 'end_0']);
expect(getNodeChildrenIds(block0)).toEqual(['$blockOrderIcon$block_0', 'test0', 'test1']);
await historyService.undo();
expect(getNodeChildrenIds(root)).toEqual(['start_0', 'test1', 'dynamicSplit_0', 'end_0']);
expect(getNodeChildrenIds(block0)).toEqual(['$blockOrderIcon$block_0', 'test0']);
});
it('move node with parent and index', async () => {
const root = flowDocument.getNode('root');
flowDocument.addFromNode('dynamicSplit_0', {

View File

@ -236,6 +236,7 @@ export function createFreeLayoutPreset(
return true;
},
ignoreOneSelect: true, // 自由布局不选择单个节点
ignoreChildrenLength: true, // 自由布局忽略子节点数量
...(opts.selectBox || {}),
})
);

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,65 @@
{
"name": "@flowgram.ai/free-container-plugin",
"version": "0.1.8",
"homepage": "https://flowgram.ai/",
"repository": "https://github.com/bytedance/flowgram.ai",
"license": "MIT",
"exports": {
"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": "vitest run",
"test:cov": "exit 0",
"ts-check": "tsc --noEmit",
"watch": "npm run build:fast -- --dts-resolve --watch --ignore-watch dist"
},
"dependencies": {
"@flowgram.ai/free-history-plugin": "workspace:*",
"@flowgram.ai/free-lines-plugin": "workspace:*",
"@flowgram.ai/core": "workspace:*",
"@flowgram.ai/document": "workspace:*",
"@flowgram.ai/free-layout-core": "workspace:*",
"@flowgram.ai/renderer": "workspace:*",
"@flowgram.ai/utils": "workspace:*",
"inversify": "^6.0.1",
"reflect-metadata": "~0.2.2",
"lodash": "^4.17.21"
},
"devDependencies": {
"@flowgram.ai/eslint-config": "workspace:*",
"@flowgram.ai/ts-config": "workspace:*",
"@types/bezier-js": "4.1.3",
"@types/lodash": "^4.14.137",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/styled-components": "^5",
"@vitest/coverage-v8": "^0.32.0",
"eslint": "^8.54.0",
"react": "^18",
"react-dom": "^18",
"styled-components": "^5",
"tsup": "^8.0.1",
"typescript": "^5.0.4",
"vitest": "^0.34.6"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17",
"styled-components": ">=4"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
}
}

View File

@ -0,0 +1,32 @@
import React, { type FC } from 'react';
import { useNodeRender } from '@flowgram.ai/free-layout-core';
import { ContainerNodeBackgroundStyle } from './style';
export const ContainerNodeBackground: FC = () => {
const { node } = useNodeRender();
return (
<ContainerNodeBackgroundStyle
className="container-node-background"
data-flow-editor-selectable="true"
>
<svg width="100%" height="100%">
<pattern
id="container-node-dot-pattern"
width="20"
height="20"
patternUnits="userSpaceOnUse"
>
<circle cx="1" cy="1" r="1" stroke="#eceeef" fillOpacity="0.5" />
</pattern>
<rect
width="100%"
height="100%"
fill="url(#container-node-dot-pattern)"
data-node-panel-container={node.id}
/>
</svg>
</ContainerNodeBackgroundStyle>
);
};

View File

@ -0,0 +1,6 @@
import styled from 'styled-components';
export const ContainerNodeBackgroundStyle = styled.div`
position: absolute;
inset: 56px 18px 18px;
`;

View File

@ -0,0 +1,21 @@
import React, { type FC } from 'react';
import { useNodeRender } from '@flowgram.ai/free-layout-core';
import { FlowNodeTransformData } from '@flowgram.ai/document';
import { ContainerNodeBorderStyle } from './style';
export const ContainerNodeBorder: FC = () => {
const { node } = useNodeRender();
const transformData = node.getData(FlowNodeTransformData);
const topWidth = Math.max(transformData.padding.top - 50, 50);
return (
<ContainerNodeBorderStyle
className="container-node-border"
style={{
borderTopWidth: topWidth,
}}
/>
);
};

View File

@ -0,0 +1,35 @@
import styled from 'styled-components';
export const ContainerNodeBorderStyle = styled.div`
pointer-events: none;
position: absolute;
display: flex;
align-items: center;
width: 100%;
height: 100%;
background-color: transparent;
border: 1px solid var(--coz-stroke-plus, rgba(6, 7, 9, 15%));
border-color: var(--coz-bg-plus, rgb(249, 249, 249));
border-style: solid;
border-width: 8px;
border-radius: 8px;
box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 4%), 0 4px 12px 0 rgba(0, 0, 0, 2%);
&::before {
content: '';
position: absolute;
z-index: 0;
inset: -4px;
background-color: transparent;
border-color: var(--coz-bg-plus, rgb(249, 249, 249));
border-style: solid;
border-width: 4px;
border-radius: 8px;
}
`;

View File

@ -0,0 +1,72 @@
import React, { useEffect, useState, type FC, type ReactNode } from 'react';
import { useNodeRender, WorkflowNodePortsData } from '@flowgram.ai/free-layout-core';
import { FlowNodeTransformData } from '@flowgram.ai/document';
import { useContainerNodeRenderProps } from '../../hooks';
import { ContainerNodeContainerStyle } from './style';
interface IContainerNodeContainer {
children: ReactNode | ReactNode[];
}
export const ContainerNodeContainer: FC<IContainerNodeContainer> = ({ children }) => {
const { node, selected, selectNode, nodeRef } = useNodeRender();
const nodeMeta = node.getNodeMeta();
const { size = { width: 300, height: 200 } } = nodeMeta;
const { style = {} } = useContainerNodeRenderProps();
const transform = node.getData<FlowNodeTransformData>(FlowNodeTransformData);
const [width, setWidth] = useState(size.width);
const [height, setHeight] = useState(size.height);
const updatePorts = () => {
const portsData = node.getData<WorkflowNodePortsData>(WorkflowNodePortsData);
portsData.updateDynamicPorts();
};
const updateSize = () => {
// 无子节点时
if (node.blocks.length === 0) {
setWidth(size.width);
setHeight(size.height);
return;
}
// 存在子节点时,只监听宽高变化
setWidth(transform.bounds.width);
setHeight(transform.bounds.height);
};
useEffect(() => {
const dispose = transform.onDataChange(() => {
updateSize();
updatePorts();
});
return () => dispose.dispose();
}, [transform, width, height]);
useEffect(() => {
// 初始化触发一次
updateSize();
}, []);
return (
<ContainerNodeContainerStyle
className="container-node-container"
style={{
width,
height,
outline: selected ? '1px solid var(--coz-stroke-hglt, #4E40E5)' : '',
...style,
}}
ref={nodeRef}
data-node-selected={String(selected)}
onMouseDown={selectNode}
onClick={(e) => {
selectNode(e);
}}
>
{children}
</ContainerNodeContainerStyle>
);
};

View File

@ -0,0 +1,18 @@
import styled from 'styled-components';
export const ContainerNodeContainerStyle = styled.div`
display: flex;
align-items: flex-start;
box-sizing: border-box;
min-width: 400px;
min-height: 300px;
background-color: #f2f3f5;
border-radius: 8px;
outline: 1px solid var(--coz-stroke-plus, rgba(6, 7, 9, 15%));
.container-node-container-selected {
outline: 1px solid var(--coz-stroke-hglt, #4e40e5);
}
`;

View File

@ -0,0 +1,13 @@
import React, { type FC } from 'react';
import { useNodeRender } from '@flowgram.ai/free-layout-core';
import { ContainerNodeFormStyle } from './style';
export const ContainerNodeForm: FC = () => {
const { form } = useNodeRender();
if (!form) {
return null;
}
return <ContainerNodeFormStyle>{form.render()}</ContainerNodeFormStyle>;
};

View File

@ -0,0 +1,7 @@
import styled from 'styled-components';
export const ContainerNodeFormStyle = styled.div`
background-color: #fff;
border-radius: 8px 8px 0 0;
width: 100%;
`;

View File

@ -0,0 +1,27 @@
import React, { ReactNode, type FC } from 'react';
import { useNodeRender } from '@flowgram.ai/free-layout-core';
import { ContainerNodeHeaderStyle } from './style';
interface IContainerNodeHeader {
children?: ReactNode | ReactNode[];
}
export const ContainerNodeHeader: FC<IContainerNodeHeader> = ({ children }) => {
const { startDrag, onFocus, onBlur } = useNodeRender();
return (
<ContainerNodeHeaderStyle
className="container-node-header"
draggable={true}
onMouseDown={(e) => {
startDrag(e);
}}
onFocus={onFocus}
onBlur={onBlur}
>
{children}
</ContainerNodeHeaderStyle>
);
};

View File

@ -0,0 +1,74 @@
import styled from 'styled-components';
export const ContainerNodeHeaderStyle = styled.div`
z-index: 0;
display: flex;
gap: 8px;
align-items: center;
justify-content: flex-start;
width: 100%;
height: auto;
border-radius: 8px 8px 0 0;
.container-node-logo {
position: relative;
flex-shrink: 0;
width: 24px;
height: 24px;
border-radius: 4px;
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
display: block;
width: 100%;
height: 100%;
border-radius: 4px;
box-shadow: inset 0 0 0 1px var(--coz-stroke-plus);
}
> img {
width: 24px;
height: 24px;
border-radius: 4px;
}
}
.container-node-title {
margin: 0;
padding: 0;
font-size: 16px;
font-weight: 500;
font-style: normal;
line-height: 22px;
color: var(--coz-fg-primary, rgba(6, 7, 9, 80%));
text-overflow: ellipsis;
}
.container-node-tooltip {
height: 100%;
}
.container-node-tooltip-icon {
display: flex;
align-items: center;
width: 16px;
height: 100%;
color: #a7a9b0;
}
`;

View File

@ -0,0 +1,6 @@
export { ContainerNodeHeader } from './header';
export { ContainerNodeBackground } from './background';
export { ContainerNodeContainer } from './container';
export { ContainerNodeBorder } from './border';
export { ContainerNodePorts } from './ports';
export { ContainerNodeForm } from './form';

View File

@ -0,0 +1,33 @@
import React, { useEffect, type FC } from 'react';
import { WorkflowPortRender } from '@flowgram.ai/free-lines-plugin';
import { WorkflowNodePortsData, useNodeRender } from '@flowgram.ai/free-layout-core';
import { useContainerNodeRenderProps } from '../../hooks';
export const ContainerNodePorts: FC = () => {
const { node, ports } = useNodeRender();
const { renderPorts } = useContainerNodeRenderProps();
useEffect(() => {
const portsData = node.getData<WorkflowNodePortsData>(WorkflowNodePortsData);
portsData.updateDynamicPorts();
}, [node]);
return (
<>
{renderPorts.map((p) => (
<div
key={`canvas-port${p.id}`}
className="container-node-port"
data-port-id={p.id}
data-port-type={p.type}
style={p.style}
/>
))}
{ports.map((p) => (
<WorkflowPortRender key={p.id} entity={p} />
))}
</>
);
};

View File

@ -0,0 +1 @@
export const ContainerNodeRenderKey = 'container-node-render-key';

View File

@ -0,0 +1 @@
export { useContainerNodeRenderProps } from './use-render-props';

View File

@ -0,0 +1,22 @@
import { useNodeRender } from '@flowgram.ai/free-layout-core';
import { type ContainerNodeMetaRenderProps } from '../type';
export const useContainerNodeRenderProps = (): ContainerNodeMetaRenderProps => {
const { node } = useNodeRender();
const nodeMeta = node.getNodeMeta();
const {
title = '',
tooltip,
renderPorts = [],
style = {},
} = (nodeMeta?.renderContainerNode?.() ?? {}) as Partial<ContainerNodeMetaRenderProps>;
return {
title,
tooltip,
renderPorts,
style,
};
};

View File

@ -0,0 +1,4 @@
export { ContainerNodeRenderKey } from './constant';
export { ContainerNodeRender } from './render';
export type { ContainerNodeMetaRenderProps, ContainerNodeRenderProps } from './type';
export * from './components';

View File

@ -0,0 +1,19 @@
import React, { type FC } from 'react';
import type { ContainerNodeRenderProps } from './type';
import {
ContainerNodeBackground,
ContainerNodeHeader,
ContainerNodePorts,
ContainerNodeBorder,
ContainerNodeContainer,
} from './components';
export const ContainerNodeRender: FC<ContainerNodeRenderProps> = ({ content }) => (
<ContainerNodeContainer>
<ContainerNodeBackground />
<ContainerNodeBorder />
<ContainerNodeHeader>{content}</ContainerNodeHeader>
<ContainerNodePorts />
</ContainerNodeContainer>
);

View File

@ -0,0 +1,19 @@
import { ReactNode } from 'react';
import type { WorkflowNodeEntity } from '@flowgram.ai/free-layout-core';
export interface ContainerNodeMetaRenderProps {
title: string;
tooltip?: string;
renderPorts: {
id: string;
type: 'input' | 'output';
style: React.CSSProperties;
}[];
style: React.CSSProperties;
}
export interface ContainerNodeRenderProps {
node: WorkflowNodeEntity;
content?: ReactNode;
}

View File

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

View File

@ -0,0 +1,4 @@
export enum NodeIntoContainerType {
In = 'in',
Out = 'out',
}

View File

@ -0,0 +1,3 @@
export { NodeIntoContainerType } from './constant';
export { NodeIntoContainerService } from './service';
export { NodeIntoContainerState, NodeIntoContainerEvent } from './type';

View File

@ -0,0 +1,377 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion -- no need */
import { throttle } from 'lodash';
import { inject, injectable } from 'inversify';
import {
type PositionSchema,
Rectangle,
type Disposable,
DisposableCollection,
Emitter,
} from '@flowgram.ai/utils';
import {
type NodesDragEvent,
WorkflowDocument,
WorkflowDragService,
WorkflowLinesManager,
type WorkflowNodeEntity,
WorkflowNodeMeta,
WorkflowOperationBaseService,
WorkflowSelectService,
} from '@flowgram.ai/free-layout-core';
import { HistoryService } from '@flowgram.ai/free-history-plugin';
import { FlowNodeTransformData, FlowNodeRenderData } from '@flowgram.ai/document';
import { PlaygroundConfigEntity, TransformData } from '@flowgram.ai/core';
import type { NodeIntoContainerEvent, NodeIntoContainerState } from './type';
import { NodeIntoContainerType } from './constant';
@injectable()
export class NodeIntoContainerService {
public state: NodeIntoContainerState;
@inject(WorkflowDragService)
private dragService: WorkflowDragService;
@inject(WorkflowDocument)
private document: WorkflowDocument;
@inject(PlaygroundConfigEntity)
private playgroundConfig: PlaygroundConfigEntity;
@inject(WorkflowOperationBaseService)
private operationService: WorkflowOperationBaseService;
@inject(WorkflowLinesManager)
private linesManager: WorkflowLinesManager;
@inject(HistoryService) private historyService: HistoryService;
@inject(WorkflowSelectService) private selectService: WorkflowSelectService;
private emitter = new Emitter<NodeIntoContainerEvent>();
private toDispose = new DisposableCollection();
public readonly on = this.emitter.event;
public init(): void {
this.initState();
this.toDispose.push(this.emitter);
}
public ready(): void {
this.toDispose.push(this.listenDragToContainer());
}
public dispose(): void {
this.initState();
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);
if (
!parentNode ||
!containerNode ||
!this.isContainer(parentNode) ||
!nodeJSON.meta?.position
) {
return;
}
this.operationService.moveNode(node, {
parent: containerNode,
});
const parentTransform = parentNode.getData<TransformData>(TransformData);
this.operationService.updateNodePosition(node, {
x: parentTransform.position.x + nodeJSON.meta!.position!.x,
y: parentTransform.position.y + nodeJSON.meta!.position!.y,
});
parentTransform.fireChange();
await this.nextFrame();
parentTransform.fireChange();
this.emitter.fire({
type: NodeIntoContainerType.Out,
node,
sourceContainer: parentNode,
targetContainer: containerNode,
});
}
/** 能否将节点移出容器 */
public canMoveOutContainer(node: WorkflowNodeEntity): boolean {
const parentNode = node.parent;
const containerNode = parentNode?.parent;
if (!parentNode || !containerNode || !this.isContainer(parentNode)) {
return false;
}
return true;
}
/** 初始化状态 */
private initState(): void {
this.state = {
isDraggingNode: false,
isSkipEvent: false,
transforms: undefined,
dragNode: undefined,
dropNode: undefined,
sourceParent: undefined,
};
}
/** 监听节点拖拽 */
private listenDragToContainer(): Disposable {
const draggingNode = (e: NodesDragEvent) => this.draggingNode(e);
const throttledDraggingNode = throttle(draggingNode, 200); // 200ms触发一次计算
return this.dragService.onNodesDrag(async (event) => {
if (this.selectService.selectedNodes.length !== 1) {
return;
}
if (event.type === 'onDragStart') {
if (this.state.isSkipEvent) {
// 拖出容器后重新进入
this.state.isSkipEvent = false;
return;
}
this.historyService.startTransaction(); // 开始合并历史记录
this.state.isDraggingNode = true;
this.state.transforms = this.getContainerTransforms();
this.state.dragNode = this.selectService.selectedNodes[0];
this.state.dropNode = undefined;
this.state.sourceParent = this.state.dragNode?.parent;
await this.dragOutContainer(event); // 检查是否需拖出容器
}
if (event.type === 'onDragging') {
throttledDraggingNode(event);
}
if (event.type === 'onDragEnd') {
if (this.state.isSkipEvent) {
// 拖出容器情况下需跳过本次事件
return;
}
throttledDraggingNode.cancel();
draggingNode(event); // 直接触发一次计算,防止延迟
await this.dropNodeToContainer(); // 放置节点
await this.clearInvalidLines(); // 清除非法线条
this.setDropNode(undefined);
this.initState(); // 重置状态
this.historyService.endTransaction(); // 结束合并历史记录
}
});
}
/** 监听节点拖拽出容器 */
private async dragOutContainer(event: NodesDragEvent): Promise<void> {
const { dragNode } = this.state;
const activated = event.triggerEvent.metaKey || event.triggerEvent.ctrlKey;
if (
!activated || // 必须按住指定按键
!dragNode || // 必须有一个节点
!this.canMoveOutContainer(dragNode) // 需要能被移出容器
) {
return;
}
this.moveOutContainer({ node: dragNode });
this.state.isSkipEvent = true;
event.dragger.stop(event.dragEvent.clientX, event.dragEvent.clientY);
await this.nextFrame();
this.dragService.startDragSelectedNodes(event.triggerEvent);
}
/** 移除节点所有非法连线 */
private async clearInvalidLines(): Promise<void> {
const { dragNode, sourceParent } = this.state;
if (!dragNode) {
return;
}
if (dragNode.parent === sourceParent) {
return;
}
const lines = this.linesManager.getAllLines();
lines.forEach((line) => {
if (line.from.id !== dragNode.id && line.to?.id !== dragNode.id) {
return;
}
line.dispose();
});
await this.nextFrame();
}
/** 获取重叠位置 */
private getCollisionTransform(params: {
transforms: FlowNodeTransformData[];
targetRect?: Rectangle;
targetPoint?: PositionSchema;
withPadding?: boolean;
}): FlowNodeTransformData | undefined {
const { targetRect, targetPoint, transforms, withPadding = false } = params;
const collisionTransform = transforms.find((transform) => {
const { bounds, entity } = transform;
const padding = withPadding ? this.document.layout.getPadding(entity) : { left: 0, right: 0 };
const transformRect = new Rectangle(
bounds.x + padding.left + padding.right,
bounds.y,
bounds.width,
bounds.height
);
// 检测两个正方形是否相互碰撞
if (targetRect) {
return this.isRectIntersects(targetRect, transformRect);
}
if (targetPoint) {
return this.isPointInRect(targetPoint, transformRect);
}
return false;
});
return collisionTransform;
}
/** 设置放置节点高亮 */
private setDropNode(dropNode?: WorkflowNodeEntity) {
if (this.state.dropNode === dropNode) {
return;
}
if (this.state.dropNode) {
// 清除上一个节点高亮
const renderData = this.state.dropNode.getData(FlowNodeRenderData);
const renderDom = renderData.node?.children?.[0] as HTMLElement;
if (renderDom) {
renderDom.style.outline = '';
}
}
this.state.dropNode = dropNode;
if (!dropNode) {
return;
}
// 设置当前节点高亮
const renderData = dropNode.getData(FlowNodeRenderData);
const renderDom = renderData.node?.children?.[0] as HTMLElement;
if (renderDom) {
renderDom.style.outline = '1px solid var(--coz-stroke-hglt,#4e40e5)';
}
}
/** 获取容器节点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;
}
return entity.getNodeMeta().selectable;
})
.filter((transform) => this.isContainer(transform.entity));
}
/** 放置节点到容器 */
private async dropNodeToContainer(): Promise<void> {
const { dropNode, dragNode, isDraggingNode } = this.state;
if (!isDraggingNode || !dragNode || !dropNode) {
return;
}
return await this.moveIntoContainer({
node: dragNode,
containerNode: dropNode,
});
}
/** 拖拽节点 */
private draggingNode(nodeDragEvent: NodesDragEvent): void {
const { dragNode, isDraggingNode, transforms } = this.state;
if (!isDraggingNode || !dragNode || this.isContainer(dragNode) || !transforms?.length) {
return this.setDropNode(undefined);
}
const mousePos = this.playgroundConfig.getPosFromMouseEvent(nodeDragEvent.dragEvent);
const collisionTransform = this.getCollisionTransform({
targetPoint: mousePos,
transforms: this.state.transforms ?? [],
});
const dropNode = collisionTransform?.entity;
if (!dropNode || dragNode.parent?.id === dropNode.id) {
return this.setDropNode(undefined);
}
const canDrop = this.dragService.canDropToNode({
dragNodeType: dragNode.flowNodeType,
dropNode,
});
if (!canDrop.allowDrop) {
return this.setDropNode(undefined);
}
return this.setDropNode(canDrop.dropNode);
}
/** 将节点移入容器 */
private async moveIntoContainer(params: {
node: WorkflowNodeEntity;
containerNode: WorkflowNodeEntity;
}): 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
)
);
await this.nextFrame();
this.emitter.fire({
type: NodeIntoContainerType.In,
node,
sourceContainer: parentNode,
targetContainer: containerNode,
});
}
private isContainer(node?: WorkflowNodeEntity): boolean {
return node?.getNodeMeta<WorkflowNodeMeta>().isContainer ?? false;
}
/** 判断点是否在矩形内 */
private isPointInRect(point: PositionSchema, rect: Rectangle): boolean {
return (
point.x >= rect.left && point.x <= rect.right && point.y >= rect.top && point.y <= rect.bottom
);
}
/** 判断两个矩形是否相交 */
private isRectIntersects(rectA: Rectangle, rectB: Rectangle): boolean {
// 检查水平方向是否有重叠
const hasHorizontalOverlap = rectA.right > rectB.left && rectA.left < rectB.right;
// 检查垂直方向是否有重叠
const hasVerticalOverlap = rectA.bottom > rectB.top && rectA.top < rectB.bottom;
// 只有当水平和垂直方向都有重叠时,两个矩形才相交
return hasHorizontalOverlap && hasVerticalOverlap;
}
private async nextFrame(): Promise<void> {
await new Promise((resolve) => requestAnimationFrame(resolve));
}
}

View File

@ -0,0 +1,20 @@
import type { WorkflowNodeEntity } from '@flowgram.ai/free-layout-core';
import type { FlowNodeTransformData } from '@flowgram.ai/document';
import type { NodeIntoContainerType } from './constant';
export interface NodeIntoContainerState {
isDraggingNode: boolean;
isSkipEvent: boolean;
transforms?: FlowNodeTransformData[];
dragNode?: WorkflowNodeEntity;
dropNode?: WorkflowNodeEntity;
sourceParent?: WorkflowNodeEntity;
}
export interface NodeIntoContainerEvent {
type: NodeIntoContainerType;
node: WorkflowNodeEntity;
sourceContainer?: WorkflowNodeEntity;
targetContainer: WorkflowNodeEntity;
}

View File

@ -0,0 +1,34 @@
import React from 'react';
import { FlowRendererRegistry } from '@flowgram.ai/renderer';
import { definePluginCreator } from '@flowgram.ai/core';
import type { WorkflowContainerPluginOptions } from './type';
import { NodeIntoContainerService } from './node-into-container';
import {
ContainerNodeRenderKey,
ContainerNodeRender,
ContainerNodeRenderProps,
} from './container-node-render';
export const createContainerNodePlugin = definePluginCreator<WorkflowContainerPluginOptions>({
onBind: ({ bind }) => {
bind(NodeIntoContainerService).toSelf().inSingletonScope();
},
onInit(ctx, options) {
ctx.get(NodeIntoContainerService).init();
const registry = ctx.get<FlowRendererRegistry>(FlowRendererRegistry);
registry.registerReactComponent(ContainerNodeRenderKey, (props: ContainerNodeRenderProps) => (
<ContainerNodeRender {...props} content={options.renderContent} />
));
},
onReady(ctx, options) {
if (options.disableNodeIntoContainer !== true) {
ctx.get(NodeIntoContainerService).ready();
}
},
onDispose(ctx) {
ctx.get(NodeIntoContainerService).dispose();
},
});

View File

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

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

@ -7,12 +7,14 @@ import {
WorkflowDocument,
WorkflowResetLayoutService,
WorkflowDragService,
WorkflowOperationBaseService,
} from '@flowgram.ai/free-layout-core';
import { FlowNodeFormData } from '@flowgram.ai/form-core';
import { FormManager } from '@flowgram.ai/form-core';
import { OperationType } from '@flowgram.ai/document';
import { type PluginContext, PositionData } from '@flowgram.ai/core';
import { type FreeHistoryPluginOptions, FreeOperationType } from './types';
import { DragNodeOperationValue, type FreeHistoryPluginOptions, FreeOperationType } from './types';
import { HistoryEntityManager } from './history-entity-manager';
import { DragNodesHandler } from './handlers/drag-nodes-handler';
import { ChangeNodeDataHandler } from './handlers/change-node-data-handler';
@ -40,6 +42,9 @@ export class FreeHistoryManager {
private _toDispose: DisposableCollection = new DisposableCollection();
@inject(WorkflowOperationBaseService)
private _operationService: WorkflowOperationBaseService;
onInit(ctx: PluginContext, opts: FreeHistoryPluginOptions) {
const document = ctx.get<WorkflowDocument>(WorkflowDocument);
const historyService = ctx.get<HistoryService>(HistoryService);
@ -101,6 +106,39 @@ export class FreeHistoryManager {
{ noApply: true }
);
}),
this._operationService.onNodeMove(({ node, fromParent, fromIndex, toParent, toIndex }) => {
historyService.pushOperation(
{
type: OperationType.moveChildNodes,
value: {
fromParentId: fromParent.id,
fromIndex,
nodeIds: [node.id],
toParentId: toParent.id,
toIndex,
},
},
{
noApply: true,
}
);
}),
this._operationService.onNodePostionUpdate((event) => {
const value: DragNodeOperationValue = {
ids: [event.node.id],
value: [event.newPosition],
oldValue: [event.oldPosition],
};
historyService.pushOperation(
{
type: FreeOperationType.dragNodes,
value,
},
{
noApply: true,
}
);
}),
]);
}

View File

@ -1,7 +1,7 @@
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 { WorkflowDocument } from '@flowgram.ai/free-layout-core';
import { type OperationMeta } from '@flowgram.ai/history';
import { type DragNodeOperationValue, FreeOperationType } from '../types';
import { baseOperationMeta } from './base';
@ -9,7 +9,7 @@ import { baseOperationMeta } from './base';
export const dragNodesOperationMeta: OperationMeta<DragNodeOperationValue, PluginContext, void> = {
...baseOperationMeta,
type: FreeOperationType.dragNodes,
inverse: op => ({
inverse: (op) => ({
...op,
value: {
...op.value,
@ -35,7 +35,7 @@ export const dragNodesOperationMeta: OperationMeta<DragNodeOperationValue, Plugi
});
// 嵌套情况下需将子节点 transform 设为 dirty
if (node.collapsedChildren?.length > 0) {
node.collapsedChildren.forEach(childNode => {
node.collapsedChildren.forEach((childNode) => {
const childNodeTransformData =
childNode.getData<FlowNodeTransformData>(FlowNodeTransformData);
childNodeTransformData.fireChange();
@ -43,5 +43,4 @@ export const dragNodesOperationMeta: OperationMeta<DragNodeOperationValue, Plugi
}
});
},
shouldMerge: () => false,
};

View File

@ -1,4 +1,5 @@
import { resetLayoutOperationMeta } from './reset-layout';
import { moveChildNodesOperationMeta } from './move-child-nodes';
import { dragNodesOperationMeta } from './drag-nodes';
import { deleteNodeOperationMeta } from './delete-node';
import { deleteLineOperationMeta } from './delete-line';
@ -14,4 +15,5 @@ export const operationMetas = [
changeNodeDataOperationMeta,
resetLayoutOperationMeta,
dragNodesOperationMeta,
moveChildNodesOperationMeta,
];

View File

@ -0,0 +1,37 @@
import { OperationMeta } from '@flowgram.ai/history';
import { WorkflowDocument } from '@flowgram.ai/free-layout-core';
import { MoveChildNodesOperationValue, OperationType } from '@flowgram.ai/document';
import { FlowNodeBaseType } from '@flowgram.ai/document';
import { PluginContext, TransformData } from '@flowgram.ai/core';
import { baseOperationMeta } from './base';
export const moveChildNodesOperationMeta: OperationMeta<
MoveChildNodesOperationValue,
PluginContext,
void
> = {
...baseOperationMeta,
type: OperationType.moveChildNodes,
inverse: (op) => ({
...op,
value: {
...op.value,
fromIndex: op.value.toIndex,
toIndex: op.value.fromIndex,
fromParentId: op.value.toParentId,
toParentId: op.value.fromParentId,
},
}),
apply: (operation, ctx: PluginContext) => {
const document = ctx.get<WorkflowDocument>(WorkflowDocument);
document.moveChildNodes(operation.value);
const fromContainer = document.getNode(operation.value.fromParentId);
requestAnimationFrame(() => {
if (fromContainer && fromContainer.flowNodeType !== FlowNodeBaseType.ROOT) {
const fromContainerTransformData = fromContainer.getData(TransformData);
fromContainerTransformData.fireChange();
}
});
},
};

View File

@ -21,6 +21,7 @@ export enum FreeOperationType {
changeNodeData = 'changeNodeData',
resetLayout = 'resetLayout',
dragNodes = 'dragNodes',
moveChildNodes = 'moveChildNodes',
}
export interface AddOrDeleteLineOperationValue extends WorkflowLinePortInfo {

View File

@ -1,6 +1,17 @@
/* eslint-disable complexity */
import { inject, injectable } from 'inversify';
import { type IPoint } from '@flowgram.ai/utils';
import { SelectorBoxConfigEntity } from '@flowgram.ai/renderer';
import {
WorkflowDocument,
WorkflowDragService,
WorkflowHoverService,
WorkflowLineEntity,
WorkflowLinesManager,
WorkflowNodeEntity,
WorkflowSelectService,
} from '@flowgram.ai/free-layout-core';
import { WorkflowPortEntity } from '@flowgram.ai/free-layout-core';
import { FlowNodeTransformData } from '@flowgram.ai/document';
import {
EditorState,
@ -12,17 +23,6 @@ import {
observeEntityDatas,
type LayerOptions,
} from '@flowgram.ai/core';
import {
WorkflowDocument,
WorkflowDragService,
WorkflowHoverService,
WorkflowLineEntity,
WorkflowLinesManager,
WorkflowNodeEntity,
WorkflowSelectService,
} from '@flowgram.ai/free-layout-core';
import { WorkflowPortEntity } from '@flowgram.ai/free-layout-core';
import { type IPoint } from '@flowgram.ai/utils';
import { getSelectionBounds } from './selection-utils';
const PORT_BG_CLASS_NAME = 'workflow-port-bg';
@ -79,9 +79,9 @@ export class HoverLayer extends Layer<HoverLayerOptions> {
autorun(): void {
const { activatedNode } = this.selectionService;
this.nodeTransformsWithSort = this.nodeTransforms
.filter(n => n.entity.id !== 'root')
.filter((n) => n.entity.id !== 'root')
.reverse() // 后创建的排在前面
.sort(n1 => (n1.entity === activatedNode ? -1 : 0));
.sort((n1) => (n1.entity === activatedNode ? -1 : 0));
}
/**
@ -145,17 +145,18 @@ export class HoverLayer extends Layer<HoverLayerOptions> {
const selectionBounds = getSelectionBounds(
this.selectionService.selection,
// 这里只考虑多选模式,单选模式已经下沉到 use-node-render 中
true,
true
);
if (selectionBounds.width > 0 && selectionBounds.contains(mousePos.x, mousePos.y)) {
/**
*
*/
this.dragService.startDragSelectedNodes(e).then(dragSuccess => {
this.dragService.startDragSelectedNodes(e)?.then((dragSuccess) => {
if (!dragSuccess) {
// 拖拽没有成功触发了点击
if (hoveredNode && hoveredNode instanceof WorkflowNodeEntity) {
if (e.metaKey || e.shiftKey || e.ctrlKey) {
// 追加选择
if (e.shiftKey) {
this.selectionService.toggleSelect(hoveredNode);
} else {
this.selectionService.selectNode(hoveredNode);
@ -188,8 +189,8 @@ export class HoverLayer extends Layer<HoverLayerOptions> {
const portHovered = this.linesManager.getPortFromMousePos(mousePos);
const lineDomNodes = this.playgroundNode.querySelectorAll(LINE_CLASS_NAME);
const checkTargetFromLine = [...lineDomNodes].some(lineDom =>
lineDom.contains(target as HTMLElement),
const checkTargetFromLine = [...lineDomNodes].some((lineDom) =>
lineDom.contains(target as HTMLElement)
);
// 默认 只有 output 点位可以 hover
if (portHovered) {
@ -212,13 +213,13 @@ export class HoverLayer extends Layer<HoverLayerOptions> {
}
const nodeHovered = nodeTransforms.find((trans: FlowNodeTransformData) =>
trans.bounds.contains(mousePos.x, mousePos.y),
trans.bounds.contains(mousePos.x, mousePos.y)
)?.entity as WorkflowNodeEntity;
// 判断当前鼠标位置所在元素是否在节点内部
const nodeDomNodes = this.playgroundNode.querySelectorAll(NODE_CLASS_NAME);
const checkTargetFromNode = [...nodeDomNodes].some(nodeDom =>
nodeDom.contains(target as HTMLElement),
const checkTargetFromNode = [...nodeDomNodes].some((nodeDom) =>
nodeDom.contains(target as HTMLElement)
);
if (nodeHovered || checkTargetFromNode) {

View File

@ -1,30 +1,20 @@
import { FlowNodeTransformData, FlowNodeEntity } from '@flowgram.ai/document';
import { type Entity } from '@flowgram.ai/core';
import { Rectangle } from '@flowgram.ai/utils';
import { WorkflowNodeEntity } from '@flowgram.ai/free-layout-core';
import { FlowNodeTransformData } from '@flowgram.ai/document';
import { type Entity } from '@flowgram.ai/core';
const BOUNDS_PADDING = 2;
export function getSelectionBounds(
selection: Entity[],
ignoreOneSelect?: boolean, // 忽略单选
ignoreOneSelect: boolean = true // 忽略单选
): Rectangle {
const selectedNodes = selection.filter(node => node instanceof FlowNodeEntity);
if (!selectedNodes?.length) {
return Rectangle.EMPTY;
}
const selectedNodes = selection.filter((node) => node instanceof WorkflowNodeEntity);
// 选中单个的时候不显示
if (
ignoreOneSelect &&
selectedNodes.length === 1 &&
// 选中的节点不包含多个子节点
(selectedNodes[0] as FlowNodeEntity).childrenLength <= 1
) {
return Rectangle.EMPTY;
}
return Rectangle.enlarge(selectedNodes.map(n => n.getData(FlowNodeTransformData)!.bounds)).pad(
BOUNDS_PADDING,
);
return selectedNodes.length > (ignoreOneSelect ? 1 : 0)
? Rectangle.enlarge(selectedNodes.map((n) => n.getData(FlowNodeTransformData)!.bounds)).pad(
BOUNDS_PADDING
)
: Rectangle.EMPTY;
}

View File

@ -8,12 +8,12 @@ import {
WorkflowPortEntity,
WorkflowNodePortsData,
WorkflowNodeEntity,
WorkflowNodeMeta,
} from '@flowgram.ai/free-layout-core';
import { WorkflowSelectService } from '@flowgram.ai/free-layout-core';
import { WorkflowNodeJSON } from '@flowgram.ai/free-layout-core';
import { FreeOperationType, HistoryService } from '@flowgram.ai/free-history-plugin';
import { FlowNodeTransformData } from '@flowgram.ai/document';
import { FlowNodeBaseType } from '@flowgram.ai/document';
import { PlaygroundConfigEntity } from '@flowgram.ai/core';
import { TransformData } from '@flowgram.ai/core';
@ -293,7 +293,7 @@ export class WorkflowNodePanelService {
}
const fromNode = fromPort?.node;
const fromContainer = fromNode?.parent;
if (fromNode?.flowNodeType === FlowNodeBaseType.SUB_CANVAS) {
if (this.isContainer(fromNode)) {
// 子画布内部输入连线
return fromNode;
}
@ -303,7 +303,7 @@ export class WorkflowNodePanelService {
/** 获取端口矩形 */
private getPortBox(port: WorkflowPortEntity, offset: IPoint = { x: 0, y: 0 }): Rectangle {
const node = port.node;
if (node.flowNodeType === FlowNodeBaseType.SUB_CANVAS) {
if (this.isContainer(node)) {
// 子画布内部端口需要虚拟节点
const { point } = port;
if (port.portType === 'input') {
@ -424,7 +424,7 @@ export class WorkflowNodePanelService {
/** 获取后续节点 */
private getSubsequentNodes(node: WorkflowNodeEntity): WorkflowNodeEntity[] {
if (node.flowNodeType === FlowNodeBaseType.SUB_CANVAS) {
if (this.isContainer(node)) {
return [];
}
const brothers = node.parent?.collapsedChildren ?? [];
@ -436,7 +436,7 @@ export class WorkflowNodePanelService {
}
if (
!line.to?.id ||
line.to.flowNodeType === FlowNodeBaseType.SUB_CANVAS // 子画布内部成环
this.isContainer(line.to) // 子画布内部成环
) {
return;
}
@ -519,4 +519,9 @@ export class WorkflowNodePanelService {
},
});
}
/** 是否容器节点 */
private isContainer(node?: WorkflowNodeEntity): boolean {
return node?.getNodeMeta<WorkflowNodeMeta>().isContainer ?? false;
}
}

View File

@ -1,11 +1,11 @@
import { inject, injectable } from 'inversify';
import { Disposable, Emitter, Rectangle } from '@flowgram.ai/utils';
import { IPoint } from '@flowgram.ai/utils';
import { WorkflowNodeEntity, WorkflowDocument } from '@flowgram.ai/free-layout-core';
import { WorkflowDragService } from '@flowgram.ai/free-layout-core';
import { FlowNodeTransformData } from '@flowgram.ai/document';
import { FlowNodeBaseType } from '@flowgram.ai/document';
import { EntityManager, PlaygroundConfigEntity, TransformData } from '@flowgram.ai/core';
import { WorkflowNodeEntity, WorkflowDocument } from '@flowgram.ai/free-layout-core';
import { WorkflowDragService } from '@flowgram.ai/free-layout-core';
import { Disposable, Emitter, Rectangle } from '@flowgram.ai/utils';
import { IPoint } from '@flowgram.ai/utils';
import { isEqual, isGreaterThan, isLessThan, isLessThanOrEqual, isNumber } from './utils';
import type {
@ -44,6 +44,8 @@ export class WorkflowSnapService {
public readonly onSnap = this.snapEmitter.event;
private _disabled = false;
public init(params: Partial<WorkflowSnapServiceOptions> = {}): void {
this.options = {
...SnapDefaultOptions,
@ -53,18 +55,46 @@ export class WorkflowSnapService {
}
public dispose(): void {
this.disposers.forEach(disposer => disposer.dispose());
this.disposers.forEach((disposer) => disposer.dispose());
}
public get disabled(): boolean {
return this._disabled;
}
public disable(): void {
if (this._disabled) {
return;
}
this._disabled = true;
this.clear();
}
public enable(): void {
if (!this._disabled) {
return;
}
this._disabled = false;
this.clear();
}
private mountListener(): void {
const dragAdjusterDisposer = this.dragService.registerPosAdjuster(params =>
this.snapping({
targetNodes: params.selectedNodes,
position: params.position,
}),
);
const dragEndDisposer = this.dragService.onNodesDrag(event => {
if (event.type !== 'onDragEnd') {
const dragAdjusterDisposer = this.dragService.registerPosAdjuster((params) => {
const { selectedNodes: targetNodes, position } = params;
const isMultiSnapping = this.options.enableMultiSnapping ? false : targetNodes.length !== 1;
if (this._disabled || !this.options.enableEdgeSnapping || isMultiSnapping) {
return {
x: 0,
y: 0,
};
}
return this.snapping({
targetNodes,
position,
});
});
const dragEndDisposer = this.dragService.onNodesDrag((event) => {
if (event.type !== 'onDragEnd' || this._disabled) {
return;
}
if (this.options.enableGridSnapping) {
@ -81,24 +111,15 @@ export class WorkflowSnapService {
}
private snapping(params: { targetNodes: WorkflowNodeEntity[]; position: IPoint }): IPoint {
const { targetNodes: targetNodes, position } = params;
const { targetNodes, position } = params;
const isMultiSnapping = this.options.enableMultiSnapping ? false : targetNodes.length !== 1;
if (!this.options.enableEdgeSnapping || isMultiSnapping) {
return {
x: 0,
y: 0,
};
}
const selectedBounds = this.getBounds(targetNodes);
const targetBounds = this.getBounds(targetNodes);
const targetRect = new Rectangle(
position.x,
position.y,
selectedBounds.width,
selectedBounds.height,
targetBounds.width,
targetBounds.height
);
const snapNodeRects = this.getSnapNodeRects({
@ -127,7 +148,7 @@ export class WorkflowSnapService {
position.x + offset.x,
position.y + offset.y,
targetRect.width,
targetRect.height,
targetRect.height
);
this.snapEmitter.fire({
@ -155,23 +176,23 @@ export class WorkflowSnapService {
});
// 找到最近的线条
const topYClosestLine = snapLines.horizontal.find(line =>
isLessThanOrEqual(Math.abs(line.y - targetRect.top), edgeThreshold),
const topYClosestLine = snapLines.horizontal.find((line) =>
isLessThanOrEqual(Math.abs(line.y - targetRect.top), edgeThreshold)
);
const bottomYClosestLine = snapLines.horizontal.find(line =>
isLessThanOrEqual(Math.abs(line.y - targetRect.bottom), edgeThreshold),
const bottomYClosestLine = snapLines.horizontal.find((line) =>
isLessThanOrEqual(Math.abs(line.y - targetRect.bottom), edgeThreshold)
);
const leftXClosestLine = snapLines.vertical.find(line =>
isLessThanOrEqual(Math.abs(line.x - targetRect.left), edgeThreshold),
const leftXClosestLine = snapLines.vertical.find((line) =>
isLessThanOrEqual(Math.abs(line.x - targetRect.left), edgeThreshold)
);
const rightXClosestLine = snapLines.vertical.find(line =>
isLessThanOrEqual(Math.abs(line.x - targetRect.right), edgeThreshold),
const rightXClosestLine = snapLines.vertical.find((line) =>
isLessThanOrEqual(Math.abs(line.x - targetRect.right), edgeThreshold)
);
const midYClosestLine = snapLines.midHorizontal.find(line =>
isLessThanOrEqual(Math.abs(line.y - targetRect.center.y), edgeThreshold),
const midYClosestLine = snapLines.midHorizontal.find((line) =>
isLessThanOrEqual(Math.abs(line.y - targetRect.center.y), edgeThreshold)
);
const midXClosestLine = snapLines.midVertical.find(line =>
isLessThanOrEqual(Math.abs(line.x - targetRect.center.x), edgeThreshold),
const midXClosestLine = snapLines.midVertical.find((line) =>
isLessThanOrEqual(Math.abs(line.x - targetRect.center.x), edgeThreshold)
);
// 计算最近坐标
@ -227,11 +248,11 @@ export class WorkflowSnapService {
x: snappedPosition.x - rect.x,
y: snappedPosition.y - rect.y,
};
targetNodes.forEach(node =>
targetNodes.forEach((node) =>
this.updateNodePositionWithOffset({
node,
offset,
}),
})
);
}
@ -256,7 +277,7 @@ export class WorkflowSnapService {
const midHorizontalLines: SnapMidHorizontalLine[] = [];
const midVerticalLines: SnapMidVerticalLine[] = [];
snapNodeRects.forEach(snapNodeRect => {
snapNodeRects.forEach((snapNodeRect) => {
const nodeBounds = snapNodeRect.rect;
const nodeCenter = nodeBounds.center;
// 边缘横线
@ -310,11 +331,11 @@ export class WorkflowSnapService {
const targetCenter = targetRect.center;
const targetContainerId = targetNodes[0].parent?.id ?? this.document.root.id;
const disabledNodeIds = targetNodes.map(n => n.id);
const disabledNodeIds = targetNodes.map((n) => n.id);
disabledNodeIds.push(FlowNodeBaseType.ROOT);
const availableNodes = this.nodes
.filter(n => n.parent?.id === targetContainerId)
.filter(n => !disabledNodeIds.includes(n.id))
.filter((n) => n.parent?.id === targetContainerId)
.filter((n) => !disabledNodeIds.includes(n.id))
.sort((nodeA, nodeB) => {
const nodeCenterA = nodeA.getData(FlowNodeTransformData)!.bounds.center;
const nodeCenterB = nodeB.getData(FlowNodeTransformData)!.bounds.center;
@ -340,7 +361,7 @@ export class WorkflowSnapService {
const availableNodes = this.getAvailableNodes(params);
const viewRect = this.viewRect();
return availableNodes
.map(node => {
.map((node) => {
const snapNodeRect: SnapNodeRect = {
id: node.id,
rect: node.getData(FlowNodeTransformData).bounds,
@ -367,7 +388,7 @@ export class WorkflowSnapService {
if (nodes.length === 0) {
return Rectangle.EMPTY;
}
return Rectangle.enlarge(nodes.map(n => n.getData(FlowNodeTransformData)!.bounds));
return Rectangle.enlarge(nodes.map((n) => n.getData(FlowNodeTransformData)!.bounds));
}
private updateNodePositionWithOffset(params: { node: WorkflowNodeEntity; offset: IPoint }): void {
@ -379,7 +400,7 @@ export class WorkflowSnapService {
};
if (node.collapsedChildren?.length > 0) {
// 嵌套情况下需将子节点 transform 设为 dirty
node.collapsedChildren.forEach(childNode => {
node.collapsedChildren.forEach((childNode) => {
const childNodeTransformData =
childNode.getData<FlowNodeTransformData>(FlowNodeTransformData);
childNodeTransformData.fireChange();
@ -451,7 +472,7 @@ export class WorkflowSnapService {
const rightAlignX = alignRects.right[0].rect.left - alignSpacing.right;
const isAlignRight = isLessThanOrEqual(
Math.abs(targetRect.right - rightAlignX),
alignThreshold,
alignThreshold
);
if (isAlignRight) {
rightX = rightAlignX - targetRect.width;
@ -463,7 +484,7 @@ export class WorkflowSnapService {
const leftAlignX = alignRects.left[0].rect.right + alignSpacing.midHorizontal;
const isAlignMidHorizontal = isLessThanOrEqual(
Math.abs(targetRect.left - leftAlignX),
alignThreshold,
alignThreshold
);
if (isAlignMidHorizontal) {
midX = leftAlignX;
@ -475,7 +496,7 @@ export class WorkflowSnapService {
const topAlignY = alignRects.top[0].rect.bottom + alignSpacing.midVertical;
const isAlignMidVertical = isLessThanOrEqual(
Math.abs(targetRect.top - topAlignY),
alignThreshold,
alignThreshold
);
if (isAlignMidVertical) {
midY = topAlignY;
@ -552,7 +573,7 @@ export class WorkflowSnapService {
const leftHorizontalRects: AlignRect[] = [];
const rightHorizontalRects: AlignRect[] = [];
snapNodeRects.forEach(snapNodeRect => {
snapNodeRects.forEach((snapNodeRect) => {
const nodeRect = snapNodeRect.rect;
const { isVerticalIntersection, isHorizontalIntersection, isIntersection } =
this.intersection(nodeRect, targetRect);
@ -612,7 +633,7 @@ export class WorkflowSnapService {
}
const { isVerticalIntersection, isHorizontalIntersection, isIntersection } = this.intersection(
rectA,
rectB,
rectB
);
if (isIntersection) {
return;
@ -620,13 +641,13 @@ export class WorkflowSnapService {
if (isHorizontal && isHorizontalIntersection && !isVerticalIntersection) {
const betweenSpacing = Math.min(
Math.abs(rectA.left - rectB.right),
Math.abs(rectA.right - rectB.left),
Math.abs(rectA.right - rectB.left)
);
return (betweenSpacing - targetRect.width) / 2;
} else if (!isHorizontal && isVerticalIntersection && !isHorizontalIntersection) {
const betweenSpacing = Math.min(
Math.abs(rectA.top - rectB.bottom),
Math.abs(rectA.bottom - rectB.top),
Math.abs(rectA.bottom - rectB.top)
);
return (betweenSpacing - targetRect.height) / 2;
}
@ -646,7 +667,7 @@ export class WorkflowSnapService {
const { isVerticalIntersection, isHorizontalIntersection, isIntersection } = this.intersection(
rectA,
rectB,
rectB
);
if (isIntersection) {
@ -663,7 +684,7 @@ export class WorkflowSnapService {
private intersection(
rectA: Rectangle,
rectB: Rectangle,
rectB: Rectangle
): {
isHorizontalIntersection: boolean;
isVerticalIntersection: boolean;

View File

@ -607,6 +607,12 @@
"versionPolicyName": "publishPolicy",
"tags": ["level-1","team-flow"]
},
{
"packageName": "@flowgram.ai/free-container-plugin",
"projectFolder": "packages/plugins/free-container-plugin",
"versionPolicyName": "publishPolicy",
"tags": ["level-1","team-flow"]
},
{
"packageName": "@flowgram.ai/group-plugin",
"projectFolder": "packages/plugins/group-plugin",