mirror of
https://gitee.com/ByteDance/flowgram.ai.git
synced 2025-07-07 17:43:29 +08:00
Merge pull request #54 from louisyoungx/feat/sub-canvas
feat: loop node & move into container operation
This commit is contained in:
commit
f30ebc44fe
@ -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",
|
||||
|
||||
BIN
apps/demo-free-layout/src/assets/icon-loop.jpg
Normal file
BIN
apps/demo-free-layout/src/assets/icon-loop.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@ -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);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -1,3 +1,4 @@
|
||||
export * from './base-node';
|
||||
export * from './line-add-button';
|
||||
export * from './node-panel';
|
||||
export * from './container-content';
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 }) => {
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -1 +1,2 @@
|
||||
export { useEditorProps } from './use-editor-props';
|
||||
export { useNodeRenderContext } from './use-node-render-context';
|
||||
|
||||
@ -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 />,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
[]
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { NodeRenderContext } from '../context';
|
||||
|
||||
export const useNodeRenderContext = () => useContext(NodeRenderContext);
|
||||
@ -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',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@ -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 }) => (
|
||||
|
||||
@ -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,
|
||||
];
|
||||
|
||||
@ -14,7 +14,7 @@ export const LLMNodeRegistry: FlowNodeRegistry = {
|
||||
meta: {
|
||||
size: {
|
||||
width: 360,
|
||||
height: 94,
|
||||
height: 305,
|
||||
},
|
||||
},
|
||||
onAdd() {
|
||||
|
||||
70
apps/demo-free-layout/src/nodes/loop/index.ts
Normal file
70
apps/demo-free-layout/src/nodes/loop/index.ts
Normal 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
|
||||
},
|
||||
};
|
||||
@ -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:*",
|
||||
|
||||
85
common/config/rush/pnpm-lock.yaml
generated
85
common/config/rush/pnpm-lock.yaml
generated
@ -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':
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
import { NodeRenderReturnType } from './typings';
|
||||
|
||||
export const NodeRenderContext = React.createContext<NodeRenderReturnType>({} as any);
|
||||
@ -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);
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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('');
|
||||
|
||||
|
||||
@ -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>;
|
||||
@ -18,6 +18,7 @@ export interface WorkflowNodeMeta extends FlowNodeMeta {
|
||||
defaultPorts?: WorkflowPorts; // 默认点位
|
||||
useDynamicPort?: boolean; // 使用动态点位,会计算 data-port-key
|
||||
subCanvas?: (node: WorkflowNodeEntity) => WorkflowSubCanvas | undefined;
|
||||
isContainer?: boolean; // 是否容器节点
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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');
|
||||
@ -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();
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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', {
|
||||
|
||||
@ -236,6 +236,7 @@ export function createFreeLayoutPreset(
|
||||
return true;
|
||||
},
|
||||
ignoreOneSelect: true, // 自由布局不选择单个节点
|
||||
ignoreChildrenLength: true, // 自由布局忽略子节点数量
|
||||
...(opts.selectBox || {}),
|
||||
})
|
||||
);
|
||||
|
||||
6
packages/plugins/free-container-plugin/.eslintrc.js
Normal file
6
packages/plugins/free-container-plugin/.eslintrc.js
Normal file
@ -0,0 +1,6 @@
|
||||
const { defineConfig } = require('@flowgram.ai/eslint-config');
|
||||
|
||||
module.exports = defineConfig({
|
||||
preset: 'web',
|
||||
packageRoot: __dirname,
|
||||
});
|
||||
65
packages/plugins/free-container-plugin/package.json
Normal file
65
packages/plugins/free-container-plugin/package.json
Normal 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/"
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,6 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const ContainerNodeBackgroundStyle = styled.div`
|
||||
position: absolute;
|
||||
inset: 56px 18px 18px;
|
||||
`;
|
||||
@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
`;
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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);
|
||||
}
|
||||
`;
|
||||
@ -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>;
|
||||
};
|
||||
@ -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%;
|
||||
`;
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
`;
|
||||
@ -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';
|
||||
@ -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} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export const ContainerNodeRenderKey = 'container-node-render-key';
|
||||
@ -0,0 +1 @@
|
||||
export { useContainerNodeRenderProps } from './use-render-props';
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,4 @@
|
||||
export { ContainerNodeRenderKey } from './constant';
|
||||
export { ContainerNodeRender } from './render';
|
||||
export type { ContainerNodeMetaRenderProps, ContainerNodeRenderProps } from './type';
|
||||
export * from './components';
|
||||
@ -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>
|
||||
);
|
||||
@ -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;
|
||||
}
|
||||
4
packages/plugins/free-container-plugin/src/index.ts
Normal file
4
packages/plugins/free-container-plugin/src/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from './container-node-render';
|
||||
export * from './node-into-container';
|
||||
export { createContainerNodePlugin } from './plugin';
|
||||
export type { WorkflowContainerPluginOptions } from './type';
|
||||
@ -0,0 +1,4 @@
|
||||
export enum NodeIntoContainerType {
|
||||
In = 'in',
|
||||
Out = 'out',
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
export { NodeIntoContainerType } from './constant';
|
||||
export { NodeIntoContainerService } from './service';
|
||||
export { NodeIntoContainerState, NodeIntoContainerEvent } from './type';
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
34
packages/plugins/free-container-plugin/src/plugin.tsx
Normal file
34
packages/plugins/free-container-plugin/src/plugin.tsx
Normal 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();
|
||||
},
|
||||
});
|
||||
6
packages/plugins/free-container-plugin/src/type.ts
Normal file
6
packages/plugins/free-container-plugin/src/type.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface WorkflowContainerPluginOptions {
|
||||
disableNodeIntoContainer?: boolean;
|
||||
renderContent?: ReactNode;
|
||||
}
|
||||
7
packages/plugins/free-container-plugin/tsconfig.json
Normal file
7
packages/plugins/free-container-plugin/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "@flowgram.ai/ts-config/tsconfig.flow.path.json",
|
||||
"compilerOptions": {
|
||||
},
|
||||
"include": ["./src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
26
packages/plugins/free-container-plugin/vitest.config.ts
Normal file
26
packages/plugins/free-container-plugin/vitest.config.ts
Normal file
@ -0,0 +1,26 @@
|
||||
const path = require('path');
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
commonjsOptions: {
|
||||
transformMixedEsModules: true,
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
mockReset: false,
|
||||
environment: 'jsdom',
|
||||
setupFiles: [path.resolve(__dirname, './vitest.setup.ts')],
|
||||
include: ['**/?(*.){test,spec}.?(c|m)[jt]s?(x)'],
|
||||
exclude: [
|
||||
'**/__mocks__**',
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'**/lib/**', // lib 编译结果忽略掉
|
||||
'**/cypress/**',
|
||||
'**/.{idea,git,cache,output,temp}/**',
|
||||
'**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*',
|
||||
],
|
||||
},
|
||||
});
|
||||
1
packages/plugins/free-container-plugin/vitest.setup.ts
Normal file
1
packages/plugins/free-container-plugin/vitest.setup.ts
Normal file
@ -0,0 +1 @@
|
||||
import 'reflect-metadata';
|
||||
@ -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,
|
||||
}
|
||||
);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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,
|
||||
];
|
||||
|
||||
@ -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();
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
@ -21,6 +21,7 @@ export enum FreeOperationType {
|
||||
changeNodeData = 'changeNodeData',
|
||||
resetLayout = 'resetLayout',
|
||||
dragNodes = 'dragNodes',
|
||||
moveChildNodes = 'moveChildNodes',
|
||||
}
|
||||
|
||||
export interface AddOrDeleteLineOperationValue extends WorkflowLinePortInfo {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user