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-lines-plugin": "workspace:*",
|
||||||
"@flowgram.ai/free-node-panel-plugin": "workspace:*",
|
"@flowgram.ai/free-node-panel-plugin": "workspace:*",
|
||||||
"@flowgram.ai/minimap-plugin": "workspace:*",
|
"@flowgram.ai/minimap-plugin": "workspace:*",
|
||||||
|
"@flowgram.ai/free-container-plugin": "workspace:*",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"nanoid": "^4.0.2",
|
"nanoid": "^4.0.2",
|
||||||
"react": "^18",
|
"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;
|
background-color: #fff;
|
||||||
border: 1px solid rgba(6, 7, 9, 0.15);
|
border: 1px solid rgba(6, 7, 9, 0.15);
|
||||||
border-radius: 8px;
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 360px;
|
width: 360px;
|
||||||
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
&.selected {
|
&.selected {
|
||||||
border: 1px solid var(--coz-stroke-hglt, #4e40e5);
|
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 './base-node';
|
||||||
export * from './line-add-button';
|
export * from './line-add-button';
|
||||||
export * from './node-panel';
|
export * from './node-panel';
|
||||||
|
export * from './container-content';
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
import React from 'react';
|
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 { FlowNodeRegistry } from '@flowgram.ai/free-layout-editor';
|
||||||
|
|
||||||
import { NodeRenderContext } from '../../context';
|
import { useNodeRenderContext } from '../../hooks';
|
||||||
import { FormTitleDescription, FormWrapper } from './styles';
|
import { FormTitleDescription, FormWrapper } from './styles';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -10,7 +10,7 @@ import { FormTitleDescription, FormWrapper } from './styles';
|
|||||||
* @constructor
|
* @constructor
|
||||||
*/
|
*/
|
||||||
export function FormContent(props: { children?: React.ReactNode }) {
|
export function FormContent(props: { children?: React.ReactNode }) {
|
||||||
const { expanded, node } = useContext(NodeRenderContext);
|
const { expanded, node } = useNodeRenderContext();
|
||||||
const registry = node.getNodeRegistry<FlowNodeRegistry>();
|
const registry = node.getNodeRegistry<FlowNodeRegistry>();
|
||||||
return (
|
return (
|
||||||
<FormWrapper>
|
<FormWrapper>
|
||||||
|
|||||||
@ -1,32 +1,57 @@
|
|||||||
import { useContext } from 'react';
|
import { useCallback, useState, type MouseEvent } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
Field,
|
Field,
|
||||||
FieldRenderProps,
|
FieldRenderProps,
|
||||||
useClientContext,
|
useClientContext,
|
||||||
|
useService,
|
||||||
} from '@flowgram.ai/free-layout-editor';
|
} from '@flowgram.ai/free-layout-editor';
|
||||||
import { IconButton, Dropdown, Typography, Button } from '@douyinfe/semi-ui';
|
import { NodeIntoContainerService } from '@flowgram.ai/free-container-plugin';
|
||||||
import { IconSmallTriangleDown, IconSmallTriangleLeft } from '@douyinfe/semi-icons';
|
import { IconButton, Dropdown, Typography } from '@douyinfe/semi-ui';
|
||||||
import { IconMore } from '@douyinfe/semi-icons';
|
import { IconMore } from '@douyinfe/semi-icons';
|
||||||
|
|
||||||
import { Feedback } from '../feedback';
|
import { Feedback } from '../feedback';
|
||||||
import { FlowNodeRegistry } from '../../typings';
|
import { FlowNodeRegistry } from '../../typings';
|
||||||
import { NodeRenderContext } from '../../context';
|
import { useNodeRenderContext } from '../../hooks';
|
||||||
import { getIcon } from './utils';
|
import { getIcon } from './utils';
|
||||||
import { Header, Operators, Title } from './styles';
|
import { Header, Operators, Title } from './styles';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
function DropdownContent() {
|
function DropdownButton() {
|
||||||
const { node, deleteNode } = useContext(NodeRenderContext);
|
const [key, setKey] = useState(0);
|
||||||
|
const { node, deleteNode } = useNodeRenderContext();
|
||||||
const clientContext = useClientContext();
|
const clientContext = useClientContext();
|
||||||
const registry = node.getNodeRegistry<FlowNodeRegistry>();
|
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.playground.commandService.executeCommand(Command.Default.COPY, node);
|
||||||
};
|
}, [clientContext, node]);
|
||||||
return (
|
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}>
|
<Dropdown.Item onClick={handleCopy} disabled={registry.meta!.copyDisable === true}>
|
||||||
Copy
|
Copy
|
||||||
</Dropdown.Item>
|
</Dropdown.Item>
|
||||||
@ -37,11 +62,21 @@ function DropdownContent() {
|
|||||||
Delete
|
Delete
|
||||||
</Dropdown.Item>
|
</Dropdown.Item>
|
||||||
</Dropdown.Menu>
|
</Dropdown.Menu>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
color="secondary"
|
||||||
|
size="small"
|
||||||
|
theme="borderless"
|
||||||
|
icon={<IconMore />}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</Dropdown>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FormHeader() {
|
export function FormHeader() {
|
||||||
const { node, expanded, toggleExpand, readonly } = useContext(NodeRenderContext);
|
const { node, readonly } = useNodeRenderContext();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Header>
|
<Header>
|
||||||
@ -56,24 +91,9 @@ export function FormHeader() {
|
|||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
</Title>
|
</Title>
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
icon={expanded ? <IconSmallTriangleDown /> : <IconSmallTriangleLeft />}
|
|
||||||
size="small"
|
|
||||||
theme="borderless"
|
|
||||||
onClick={toggleExpand}
|
|
||||||
/>
|
|
||||||
{readonly ? undefined : (
|
{readonly ? undefined : (
|
||||||
<Operators>
|
<Operators>
|
||||||
<Dropdown trigger="hover" position="bottomRight" render={<DropdownContent />}>
|
<DropdownButton />
|
||||||
<IconButton
|
|
||||||
color="secondary"
|
|
||||||
size="small"
|
|
||||||
theme="borderless"
|
|
||||||
icon={<IconMore />}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
</Dropdown>
|
|
||||||
</Operators>
|
</Operators>
|
||||||
)}
|
)}
|
||||||
</Header>
|
</Header>
|
||||||
|
|||||||
@ -7,7 +7,8 @@ export const Header = styled.div`
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
column-gap: 8px;
|
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%);
|
background: linear-gradient(#f2f2ff 0%, rgba(0, 0, 0, 0.02) 100%);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@ -1,15 +1,13 @@
|
|||||||
import { useContext } from 'react';
|
|
||||||
|
|
||||||
import { Field } from '@flowgram.ai/free-layout-editor';
|
import { Field } from '@flowgram.ai/free-layout-editor';
|
||||||
|
|
||||||
import { FxExpression } from '../fx-expression';
|
import { FxExpression } from '../fx-expression';
|
||||||
import { FormItem } from '../form-item';
|
import { FormItem } from '../form-item';
|
||||||
import { Feedback } from '../feedback';
|
import { Feedback } from '../feedback';
|
||||||
import { JsonSchema } from '../../typings';
|
import { JsonSchema } from '../../typings';
|
||||||
import { NodeRenderContext } from '../../context';
|
import { useNodeRenderContext } from '../../hooks';
|
||||||
|
|
||||||
export function FormInputs() {
|
export function FormInputs() {
|
||||||
const { readonly } = useContext(NodeRenderContext);
|
const { readonly } = useNodeRenderContext();
|
||||||
return (
|
return (
|
||||||
<Field<JsonSchema> name="inputs">
|
<Field<JsonSchema> name="inputs">
|
||||||
{({ field: inputsField }) => {
|
{({ field: inputsField }) => {
|
||||||
|
|||||||
@ -39,7 +39,7 @@ export function FxExpression(props: FxExpressionProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex' }}>
|
<div style={{ display: 'flex', maxWidth: 300 }}>
|
||||||
{isExpression ? (
|
{isExpression ? (
|
||||||
<VariableSelector
|
<VariableSelector
|
||||||
value={value.content}
|
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 { Button } from '@douyinfe/semi-ui';
|
||||||
import { IconPlus } from '@douyinfe/semi-icons';
|
import { IconPlus } from '@douyinfe/semi-icons';
|
||||||
|
|
||||||
import { JsonSchema } from '../../typings';
|
import { JsonSchema } from '../../typings';
|
||||||
import { NodeRenderContext } from '../../context';
|
import { useNodeRenderContext } from '../../hooks';
|
||||||
import { PropertyEdit } from './property-edit';
|
import { PropertyEdit } from './property-edit';
|
||||||
|
|
||||||
export interface PropertiesEditProps {
|
export interface PropertiesEditProps {
|
||||||
@ -15,7 +15,7 @@ export interface PropertiesEditProps {
|
|||||||
|
|
||||||
export const PropertiesEdit: React.FC<PropertiesEditProps> = (props) => {
|
export const PropertiesEdit: React.FC<PropertiesEditProps> = (props) => {
|
||||||
const value = (props.value || {}) as Record<string, JsonSchema>;
|
const value = (props.value || {}) as Record<string, JsonSchema>;
|
||||||
const { readonly } = useContext(NodeRenderContext);
|
const { readonly } = useNodeRenderContext();
|
||||||
const [newProperty, updateNewPropertyFromCache] = useState<{ key: string; value: JsonSchema }>({
|
const [newProperty, updateNewPropertyFromCache] = useState<{ key: string; value: JsonSchema }>({
|
||||||
key: '',
|
key: '',
|
||||||
value: { type: 'string' },
|
value: { type: 'string' },
|
||||||
|
|||||||
@ -1 +1,2 @@
|
|||||||
export { useEditorProps } from './use-editor-props';
|
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';
|
} from '@flowgram.ai/free-node-panel-plugin';
|
||||||
import { createFreeLinesPlugin } from '@flowgram.ai/free-lines-plugin';
|
import { createFreeLinesPlugin } from '@flowgram.ai/free-lines-plugin';
|
||||||
import { FreeLayoutProps } from '@flowgram.ai/free-layout-editor';
|
import { FreeLayoutProps } from '@flowgram.ai/free-layout-editor';
|
||||||
|
import { createContainerNodePlugin } from '@flowgram.ai/free-container-plugin';
|
||||||
|
|
||||||
import { FlowNodeRegistry, FlowDocumentJSON } from '../typings';
|
import { FlowNodeRegistry, FlowDocumentJSON } from '../typings';
|
||||||
import { shortcuts } from '../shortcuts';
|
import { shortcuts } from '../shortcuts';
|
||||||
@ -16,6 +17,7 @@ import { createSyncVariablePlugin } from '../plugins';
|
|||||||
import { defaultFormMeta } from '../nodes/default-form-meta';
|
import { defaultFormMeta } from '../nodes/default-form-meta';
|
||||||
import { SelectorBoxPopover } from '../components/selector-box-popover';
|
import { SelectorBoxPopover } from '../components/selector-box-popover';
|
||||||
import { BaseNode, LineAddButton, NodePanel } from '../components';
|
import { BaseNode, LineAddButton, NodePanel } from '../components';
|
||||||
|
import { ContainerNodeContent } from '../components';
|
||||||
|
|
||||||
export function useEditorProps(
|
export function useEditorProps(
|
||||||
initialData: FlowDocumentJSON,
|
initialData: FlowDocumentJSON,
|
||||||
@ -199,6 +201,9 @@ export function useEditorProps(
|
|||||||
createFreeNodePanelPlugin({
|
createFreeNodePanelPlugin({
|
||||||
renderer: NodePanel,
|
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',
|
type: 'start',
|
||||||
meta: {
|
meta: {
|
||||||
position: {
|
position: {
|
||||||
x: 181,
|
x: 180,
|
||||||
y: 249.5,
|
y: 313.25,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
@ -29,8 +29,8 @@ export const initialData: FlowDocumentJSON = {
|
|||||||
type: 'condition',
|
type: 'condition',
|
||||||
meta: {
|
meta: {
|
||||||
position: {
|
position: {
|
||||||
x: 643,
|
x: 640,
|
||||||
y: 213,
|
y: 298.75,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
@ -39,11 +39,17 @@ export const initialData: FlowDocumentJSON = {
|
|||||||
conditions: [
|
conditions: [
|
||||||
{
|
{
|
||||||
key: 'if_0',
|
key: 'if_0',
|
||||||
value: { type: 'expression', content: '' },
|
value: {
|
||||||
|
type: 'expression',
|
||||||
|
content: '',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'if_1',
|
key: 'if_f0rOAt',
|
||||||
value: { type: 'expression', content: '' },
|
value: {
|
||||||
|
type: 'expression',
|
||||||
|
content: '',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -71,10 +77,9 @@ export const initialData: FlowDocumentJSON = {
|
|||||||
{
|
{
|
||||||
id: 'llm_0',
|
id: 'llm_0',
|
||||||
type: 'llm',
|
type: 'llm',
|
||||||
blocks: [],
|
|
||||||
meta: {
|
meta: {
|
||||||
position: {
|
position: {
|
||||||
x: 1105,
|
x: 1430,
|
||||||
y: 0,
|
y: 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -107,29 +112,77 @@ export const initialData: FlowDocumentJSON = {
|
|||||||
outputs: {
|
outputs: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
result: { type: 'string' },
|
result: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'llm_1',
|
id: 'end_0',
|
||||||
type: 'llm',
|
type: 'end',
|
||||||
blocks: [],
|
|
||||||
meta: {
|
meta: {
|
||||||
position: {
|
position: {
|
||||||
x: 1105,
|
x: 2220,
|
||||||
y: 405,
|
y: 313.25,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
title: 'LLM_1',
|
title: 'End',
|
||||||
inputsValues: {
|
outputs: {
|
||||||
modelType: 'gpt-3.5-turbo',
|
type: 'object',
|
||||||
temperature: 0.5,
|
properties: {
|
||||||
systemPrompt: 'You are an AI assistant.',
|
result: {
|
||||||
prompt: 'Hello.',
|
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: {
|
inputs: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
required: ['modelType', 'temperature', 'prompt'],
|
required: ['modelType', 'temperature', 'prompt'],
|
||||||
@ -151,22 +204,43 @@ export const initialData: FlowDocumentJSON = {
|
|||||||
outputs: {
|
outputs: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
result: { type: 'string' },
|
result: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'end_0',
|
id: 'llm_gZafu',
|
||||||
type: 'end',
|
type: 'llm',
|
||||||
meta: {
|
meta: {
|
||||||
position: {
|
position: {
|
||||||
x: 1567,
|
x: 640,
|
||||||
y: 249.5,
|
y: 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
data: {
|
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: {
|
outputs: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@ -179,10 +253,35 @@ export const initialData: FlowDocumentJSON = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
edges: [
|
edges: [
|
||||||
{ sourceNodeID: 'start_0', targetNodeID: 'condition_0' },
|
{
|
||||||
{ sourceNodeID: 'condition_0', sourcePortID: 'if_0', targetNodeID: 'llm_0' },
|
sourceNodeID: 'llm_CBdCg',
|
||||||
{ sourceNodeID: 'condition_0', sourcePortID: 'if_1', targetNodeID: 'llm_1' },
|
targetNodeID: 'llm_gZafu',
|
||||||
{ sourceNodeID: 'llm_0', targetNodeID: 'end_0' },
|
},
|
||||||
{ sourceNodeID: 'llm_1', targetNodeID: 'end_0' },
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
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 { nanoid } from 'nanoid';
|
||||||
import { Field, FieldArray } from '@flowgram.ai/free-layout-editor';
|
import { Field, FieldArray } from '@flowgram.ai/free-layout-editor';
|
||||||
import { Button } from '@douyinfe/semi-ui';
|
import { Button } from '@douyinfe/semi-ui';
|
||||||
import { IconPlus, IconCrossCircleStroked } from '@douyinfe/semi-icons';
|
import { IconPlus, IconCrossCircleStroked } from '@douyinfe/semi-icons';
|
||||||
|
|
||||||
import { FlowLiteralValueSchema, FlowRefValueSchema } from '../../../typings';
|
import { FlowLiteralValueSchema, FlowRefValueSchema } from '../../../typings';
|
||||||
|
import { useNodeRenderContext } from '../../../hooks';
|
||||||
import { FxExpression } from '../../../form-components/fx-expression';
|
import { FxExpression } from '../../../form-components/fx-expression';
|
||||||
import { FormItem } from '../../../form-components';
|
import { FormItem } from '../../../form-components';
|
||||||
import { Feedback } from '../../../form-components';
|
import { Feedback } from '../../../form-components';
|
||||||
import { NodeRenderContext } from '../../../context';
|
|
||||||
import { ConditionPort } from './styles';
|
import { ConditionPort } from './styles';
|
||||||
|
|
||||||
interface ConditionValue {
|
interface ConditionValue {
|
||||||
@ -18,7 +16,7 @@ interface ConditionValue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ConditionInputs() {
|
export function ConditionInputs() {
|
||||||
const { readonly } = useContext(NodeRenderContext);
|
const { readonly } = useNodeRenderContext();
|
||||||
return (
|
return (
|
||||||
<FieldArray name="inputsValues.conditions">
|
<FieldArray name="inputsValues.conditions">
|
||||||
{({ field }) => (
|
{({ field }) => (
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { FlowNodeRegistry } from '../typings';
|
import { FlowNodeRegistry } from '../typings';
|
||||||
import { StartNodeRegistry } from './start';
|
import { StartNodeRegistry } from './start';
|
||||||
|
import { LoopNodeRegistry } from './loop';
|
||||||
import { LLMNodeRegistry } from './llm';
|
import { LLMNodeRegistry } from './llm';
|
||||||
import { EndNodeRegistry } from './end';
|
import { EndNodeRegistry } from './end';
|
||||||
import { ConditionNodeRegistry } from './condition';
|
import { ConditionNodeRegistry } from './condition';
|
||||||
@ -9,4 +10,5 @@ export const nodeRegistries: FlowNodeRegistry[] = [
|
|||||||
StartNodeRegistry,
|
StartNodeRegistry,
|
||||||
EndNodeRegistry,
|
EndNodeRegistry,
|
||||||
LLMNodeRegistry,
|
LLMNodeRegistry,
|
||||||
|
LoopNodeRegistry,
|
||||||
];
|
];
|
||||||
|
|||||||
@ -14,7 +14,7 @@ export const LLMNodeRegistry: FlowNodeRegistry = {
|
|||||||
meta: {
|
meta: {
|
||||||
size: {
|
size: {
|
||||||
width: 360,
|
width: 360,
|
||||||
height: 94,
|
height: 305,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
onAdd() {
|
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/free-auto-layout-plugin": "workspace:*",
|
||||||
"@flowgram.ai/minimap-plugin": "workspace:*",
|
"@flowgram.ai/minimap-plugin": "workspace:*",
|
||||||
"@flowgram.ai/free-stack-plugin": "workspace:*",
|
"@flowgram.ai/free-stack-plugin": "workspace:*",
|
||||||
|
"@flowgram.ai/free-container-plugin": "workspace:*",
|
||||||
"@flowgram.ai/free-snap-plugin": "workspace:*",
|
"@flowgram.ai/free-snap-plugin": "workspace:*",
|
||||||
"@flowgram.ai/free-node-panel-plugin": "workspace:*",
|
"@flowgram.ai/free-node-panel-plugin": "workspace:*",
|
||||||
"@flowgram.ai/free-lines-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':
|
'@douyinfe/semi-ui':
|
||||||
specifier: ^2.72.3
|
specifier: ^2.72.3
|
||||||
version: 2.72.3(acorn@8.14.0)(react-dom@18.3.1)(react@18.3.1)
|
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':
|
'@flowgram.ai/free-layout-editor':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/client/free-layout-editor
|
version: link:../../packages/client/free-layout-editor
|
||||||
@ -409,6 +412,9 @@ importers:
|
|||||||
'@flowgram.ai/free-auto-layout-plugin':
|
'@flowgram.ai/free-auto-layout-plugin':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/plugins/free-auto-layout-plugin
|
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':
|
'@flowgram.ai/free-layout-editor':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/client/free-layout-editor
|
version: link:../../packages/client/free-layout-editor
|
||||||
@ -2083,6 +2089,85 @@ importers:
|
|||||||
specifier: ^0.34.6
|
specifier: ^0.34.6
|
||||||
version: 0.34.6(jsdom@22.1.0)
|
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:
|
../../packages/plugins/free-history-plugin:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@flowgram.ai/core':
|
'@flowgram.ai/core':
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import {
|
|||||||
FlowNodeJSON,
|
FlowNodeJSON,
|
||||||
MoveNodeConfig,
|
MoveNodeConfig,
|
||||||
OnNodeAddEvent,
|
OnNodeAddEvent,
|
||||||
|
OnNodeMoveEvent,
|
||||||
} from '../typings';
|
} from '../typings';
|
||||||
import { FlowDocument } from '../flow-document';
|
import { FlowDocument } from '../flow-document';
|
||||||
import { FlowNodeEntity } from '../entities';
|
import { FlowNodeEntity } from '../entities';
|
||||||
@ -38,9 +39,13 @@ export class FlowOperationBaseServiceImpl implements FlowOperationBaseService {
|
|||||||
|
|
||||||
protected toDispose = new DisposableCollection();
|
protected toDispose = new DisposableCollection();
|
||||||
|
|
||||||
|
private onNodeMoveEmitter = new Emitter<OnNodeMoveEvent>();
|
||||||
|
|
||||||
|
readonly onNodeMove = this.onNodeMoveEmitter.event;
|
||||||
|
|
||||||
@postConstruct()
|
@postConstruct()
|
||||||
protected init() {
|
protected init() {
|
||||||
this.toDispose.push(this.onNodeAddEmitter);
|
this.toDispose.pushAll([this.onNodeAddEmitter, this.onNodeMoveEmitter]);
|
||||||
}
|
}
|
||||||
|
|
||||||
addNode(nodeJSON: FlowNodeJSON, config: AddNodeConfig = {}): FlowNodeEntity {
|
addNode(nodeJSON: FlowNodeJSON, config: AddNodeConfig = {}): FlowNodeEntity {
|
||||||
@ -127,7 +132,7 @@ export class FlowOperationBaseServiceImpl implements FlowOperationBaseService {
|
|||||||
return;
|
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);
|
return this.doMoveNode(entity, newParentEntity, toIndex);
|
||||||
}
|
}
|
||||||
@ -301,10 +306,23 @@ export class FlowOperationBaseServiceImpl implements FlowOperationBaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected doMoveNode(node: FlowNodeEntity, newParent: FlowNodeEntity, index: number) {
|
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)],
|
nodeIds: [this.toId(node)],
|
||||||
toParentId: this.toId(newParent),
|
toParentId: this.toId(newParent),
|
||||||
toIndex: index,
|
toIndex: index,
|
||||||
});
|
});
|
||||||
|
this.onNodeMoveEmitter.fire(event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -233,6 +233,17 @@ export interface OnNodeAddEvent {
|
|||||||
data: AddNodeData;
|
data: AddNodeData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 节点移动事件
|
||||||
|
*/
|
||||||
|
export interface OnNodeMoveEvent {
|
||||||
|
node: FlowNodeEntity;
|
||||||
|
fromParent: FlowNodeEntity;
|
||||||
|
fromIndex: number;
|
||||||
|
toParent: FlowNodeEntity;
|
||||||
|
toIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FlowOperationBaseService extends Disposable {
|
export interface FlowOperationBaseService extends Disposable {
|
||||||
/**
|
/**
|
||||||
* 执行操作
|
* 执行操作
|
||||||
@ -300,6 +311,11 @@ export interface FlowOperationBaseService extends Disposable {
|
|||||||
* 添加节点的回调
|
* 添加节点的回调
|
||||||
*/
|
*/
|
||||||
onNodeAdd: Event<OnNodeAddEvent>;
|
onNodeAdd: Event<OnNodeAddEvent>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 节点移动的回调
|
||||||
|
*/
|
||||||
|
onNodeMove: Event<OnNodeMoveEvent>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FlowOperationBaseService = Symbol('FlowOperationBaseService');
|
export const FlowOperationBaseService = Symbol('FlowOperationBaseService');
|
||||||
|
|||||||
@ -56,12 +56,12 @@ describe('use-node-render', () => {
|
|||||||
const { result } = renderHook(() => useNodeRender(), {
|
const { result } = renderHook(() => useNodeRender(), {
|
||||||
wrapper,
|
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(), {
|
const { result: result2 } = renderHook(() => useNodeRender(), {
|
||||||
wrapper,
|
wrapper,
|
||||||
});
|
});
|
||||||
expect(result2.current.selected).toEqual(true);
|
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(), {
|
const { result: result3 } = renderHook(() => useNodeRender(), {
|
||||||
wrapper,
|
wrapper,
|
||||||
});
|
});
|
||||||
@ -98,7 +98,7 @@ describe('use-node-render', () => {
|
|||||||
});
|
});
|
||||||
expect(result2.current.selected).toEqual(true);
|
expect(result2.current.selected).toEqual(true);
|
||||||
expect(node.getData(PositionData).toJSON()).toEqual({ x: 100, y: 100 });
|
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(), {
|
const { result: result3 } = renderHook(() => useNodeRender(), {
|
||||||
wrapper,
|
wrapper,
|
||||||
});
|
});
|
||||||
@ -115,7 +115,7 @@ describe('use-node-render', () => {
|
|||||||
);
|
);
|
||||||
await delay(10);
|
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);
|
expect(result.current.selected).toEqual(false);
|
||||||
});
|
});
|
||||||
it('start drag input', async () => {
|
it('start drag input', async () => {
|
||||||
|
|||||||
@ -163,6 +163,7 @@ export function createSubCanvasNodes(document: WorkflowDocument) {
|
|||||||
id: 'subCanvas_0',
|
id: 'subCanvas_0',
|
||||||
type: FlowNodeBaseType.SUB_CANVAS,
|
type: FlowNodeBaseType.SUB_CANVAS,
|
||||||
meta: {
|
meta: {
|
||||||
|
isContainer: true,
|
||||||
position: { x: 100, y: 0 },
|
position: { x: 100, y: 0 },
|
||||||
subCanvas: () => ({
|
subCanvas: () => ({
|
||||||
isCanvas: true,
|
isCanvas: true,
|
||||||
|
|||||||
@ -205,6 +205,7 @@ describe('workflow-drag-service', () => {
|
|||||||
id: 'sub_canvas_0',
|
id: 'sub_canvas_0',
|
||||||
type: FlowNodeBaseType.SUB_CANVAS,
|
type: FlowNodeBaseType.SUB_CANVAS,
|
||||||
meta: {
|
meta: {
|
||||||
|
isContainer: true,
|
||||||
position: {
|
position: {
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
@ -506,6 +507,7 @@ describe('workflow-drag-service', () => {
|
|||||||
id: 'sub_canvas_0',
|
id: 'sub_canvas_0',
|
||||||
type: FlowNodeBaseType.SUB_CANVAS,
|
type: FlowNodeBaseType.SUB_CANVAS,
|
||||||
meta: {
|
meta: {
|
||||||
|
isContainer: true,
|
||||||
position: { x: 0, y: -500 },
|
position: { x: 0, y: -500 },
|
||||||
size: { width: 1000, height: 1000 },
|
size: { width: 1000, height: 1000 },
|
||||||
selectable: true,
|
selectable: true,
|
||||||
|
|||||||
@ -177,6 +177,7 @@ describe('workflow-document', () => {
|
|||||||
id: 'sun_canvas_0',
|
id: 'sun_canvas_0',
|
||||||
type: FlowNodeBaseType.SUB_CANVAS,
|
type: FlowNodeBaseType.SUB_CANVAS,
|
||||||
meta: {
|
meta: {
|
||||||
|
isContainer: true,
|
||||||
position: { x: 10, y: 10 },
|
position: { x: 10, y: 10 },
|
||||||
},
|
},
|
||||||
blocks: [
|
blocks: [
|
||||||
@ -499,6 +500,7 @@ describe('workflow-document with nestedJSON & subCanvas', () => {
|
|||||||
id: 'subCanvas_0',
|
id: 'subCanvas_0',
|
||||||
type: FlowNodeBaseType.SUB_CANVAS,
|
type: FlowNodeBaseType.SUB_CANVAS,
|
||||||
meta: {
|
meta: {
|
||||||
|
isContainer: true,
|
||||||
position: { x: 100, y: 0 },
|
position: { x: 100, y: 0 },
|
||||||
subCanvas: () => ({
|
subCanvas: () => ({
|
||||||
isCanvas: true,
|
isCanvas: true,
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { type IPoint, Rectangle, Emitter } from '@flowgram.ai/utils';
|
import { type IPoint, Rectangle, Emitter } from '@flowgram.ai/utils';
|
||||||
|
import { FlowNodeTransformData } from '@flowgram.ai/document';
|
||||||
import {
|
import {
|
||||||
Entity,
|
Entity,
|
||||||
type EntityOpts,
|
type EntityOpts,
|
||||||
@ -129,7 +130,7 @@ export class WorkflowPortEntity extends Entity<WorkflowPortEntityOpts> {
|
|||||||
|
|
||||||
get point(): IPoint {
|
get point(): IPoint {
|
||||||
const { targetElement } = this;
|
const { targetElement } = this;
|
||||||
const { bounds } = this.node.getData(TransformData)!;
|
const { bounds } = this.node.getData(FlowNodeTransformData)!;
|
||||||
if (targetElement) {
|
if (targetElement) {
|
||||||
const pos = domReactToBounds(targetElement.getBoundingClientRect()).center;
|
const pos = domReactToBounds(targetElement.getBoundingClientRect()).center;
|
||||||
return this.entityManager
|
return this.entityManager
|
||||||
@ -164,7 +165,7 @@ export class WorkflowPortEntity extends Entity<WorkflowPortEntityOpts> {
|
|||||||
*/
|
*/
|
||||||
get relativePosition(): IPoint {
|
get relativePosition(): IPoint {
|
||||||
const { point } = this;
|
const { point } = this;
|
||||||
const { bounds } = this.node.getData(TransformData)!;
|
const { bounds } = this.node.getData(FlowNodeTransformData)!;
|
||||||
return {
|
return {
|
||||||
x: point.x - bounds.x,
|
x: point.x - bounds.x,
|
||||||
y: point.y - bounds.y,
|
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;
|
isDragging.current = true;
|
||||||
// 拖拽选中的节点
|
// 拖拽选中的节点
|
||||||
dragService.startDragSelectedNodes(e).finally(() =>
|
dragService.startDragSelectedNodes(e)?.finally(() =>
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
isDragging.current = false;
|
isDragging.current = false;
|
||||||
})
|
})
|
||||||
@ -75,7 +75,7 @@ export function useNodeRender(nodeFromProps?: WorkflowNodeEntity): NodeRenderRet
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 追加选择
|
// 追加选择
|
||||||
if (e.metaKey || e.shiftKey || e.ctrlKey) {
|
if (e.shiftKey) {
|
||||||
selectionService.toggleSelect(node);
|
selectionService.toggleSelect(node);
|
||||||
} else {
|
} else {
|
||||||
selectionService.selectNode(node);
|
selectionService.selectNode(node);
|
||||||
|
|||||||
@ -2,3 +2,4 @@ export * from './workflow-select-service';
|
|||||||
export * from './workflow-hover-service';
|
export * from './workflow-hover-service';
|
||||||
export * from './workflow-drag-service';
|
export * from './workflow-drag-service';
|
||||||
export * from './workflow-reset-layout-service';
|
export * from './workflow-reset-layout-service';
|
||||||
|
export * from './workflow-operation-base-service';
|
||||||
|
|||||||
@ -7,14 +7,15 @@ import {
|
|||||||
type IPoint,
|
type IPoint,
|
||||||
PromiseDeferred,
|
PromiseDeferred,
|
||||||
Emitter,
|
Emitter,
|
||||||
|
type PositionSchema,
|
||||||
DisposableCollection,
|
DisposableCollection,
|
||||||
Rectangle,
|
Rectangle,
|
||||||
delay,
|
delay,
|
||||||
Disposable,
|
Disposable,
|
||||||
} from '@flowgram.ai/utils';
|
} from '@flowgram.ai/utils';
|
||||||
import type { PositionSchema } from '@flowgram.ai/utils';
|
|
||||||
import {
|
import {
|
||||||
FlowNodeTransformData,
|
FlowNodeTransformData,
|
||||||
|
FlowNodeType,
|
||||||
FlowOperationBaseService,
|
FlowOperationBaseService,
|
||||||
type FlowNodeEntity,
|
type FlowNodeEntity,
|
||||||
} from '@flowgram.ai/document';
|
} from '@flowgram.ai/document';
|
||||||
@ -31,6 +32,7 @@ import { WorkflowLinesManager } from '../workflow-lines-manager';
|
|||||||
import { WorkflowDocumentOptions } from '../workflow-document-option';
|
import { WorkflowDocumentOptions } from '../workflow-document-option';
|
||||||
import { WorkflowDocument } from '../workflow-document';
|
import { WorkflowDocument } from '../workflow-document';
|
||||||
import { WorkflowCommands } from '../workflow-commands';
|
import { WorkflowCommands } from '../workflow-commands';
|
||||||
|
import { LineEventProps, NodesDragEvent, OnDragLineEnd } from '../typings/workflow-drag';
|
||||||
import { type WorkflowNodeJSON, type WorkflowNodeMeta } from '../typings';
|
import { type WorkflowNodeJSON, type WorkflowNodeMeta } from '../typings';
|
||||||
import { WorkflowNodePortsData } from '../entity-datas';
|
import { WorkflowNodePortsData } from '../entity-datas';
|
||||||
import {
|
import {
|
||||||
@ -60,36 +62,6 @@ function checkDragSuccess(
|
|||||||
return false;
|
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()
|
@injectable()
|
||||||
export class WorkflowDragService {
|
export class WorkflowDragService {
|
||||||
@inject(PlaygroundConfigEntity)
|
@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;
|
let { selectedNodes } = this.selectService;
|
||||||
if (
|
if (
|
||||||
selectedNodes.length === 0 ||
|
selectedNodes.length === 0 ||
|
||||||
this.playgroundConfig.readonly ||
|
this.playgroundConfig.readonly ||
|
||||||
this.playgroundConfig.disabled
|
this.playgroundConfig.disabled ||
|
||||||
|
this.isDragging
|
||||||
) {
|
) {
|
||||||
return Promise.resolve(false);
|
return Promise.resolve(false);
|
||||||
}
|
}
|
||||||
|
this.isDragging = true;
|
||||||
const sameParent = this.childrenOfContainer(selectedNodes);
|
const sameParent = this.childrenOfContainer(selectedNodes);
|
||||||
if (sameParent && sameParent.flowNodeType !== FlowNodeBaseType.ROOT) {
|
if (sameParent && sameParent.flowNodeType !== FlowNodeBaseType.ROOT) {
|
||||||
selectedNodes = [sameParent];
|
selectedNodes = [sameParent];
|
||||||
}
|
}
|
||||||
const { altKey } = event;
|
const { altKey } = triggerEvent;
|
||||||
// 节点整体开始位置
|
// 节点整体开始位置
|
||||||
let startPosition = this.getNodesPosition(selectedNodes);
|
let startPosition = this.getNodesPosition(selectedNodes);
|
||||||
// 单个节点开始位置
|
// 单个节点开始位置
|
||||||
@ -173,11 +147,19 @@ export class WorkflowDragService {
|
|||||||
let dragSuccess = false;
|
let dragSuccess = false;
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const dragger = new PlaygroundDrag({
|
const dragger = new PlaygroundDrag({
|
||||||
onDragStart: () => {
|
onDragStart: (dragEvent) => {
|
||||||
this.isDragging = true;
|
this._nodesDragEmitter.fire({
|
||||||
|
type: 'onDragStart',
|
||||||
|
nodes: selectedNodes,
|
||||||
|
startPositions,
|
||||||
|
altKey,
|
||||||
|
dragEvent,
|
||||||
|
triggerEvent,
|
||||||
|
dragger,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onDrag: (e) => {
|
onDrag: (dragEvent) => {
|
||||||
if (!dragSuccess && checkDragSuccess(Date.now() - startTime, e)) {
|
if (!dragSuccess && checkDragSuccess(Date.now() - startTime, dragEvent)) {
|
||||||
dragSuccess = true;
|
dragSuccess = true;
|
||||||
if (altKey) {
|
if (altKey) {
|
||||||
// 按住 alt 为复制
|
// 按住 alt 为复制
|
||||||
@ -207,11 +189,13 @@ export class WorkflowDragService {
|
|||||||
|
|
||||||
// 计算拖拽偏移量
|
// 计算拖拽偏移量
|
||||||
const offset: IPoint = this.getDragPosOffset({
|
const offset: IPoint = this.getDragPosOffset({
|
||||||
event: e,
|
event: dragEvent,
|
||||||
selectedNodes,
|
selectedNodes,
|
||||||
startPosition,
|
startPosition,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const positions: PositionSchema[] = [];
|
||||||
|
|
||||||
selectedNodes.forEach((node, index) => {
|
selectedNodes.forEach((node, index) => {
|
||||||
const transform = node.getData(TransformData);
|
const transform = node.getData(TransformData);
|
||||||
const nodeStartPosition = startPositions[index];
|
const nodeStartPosition = startPositions[index];
|
||||||
@ -230,21 +214,36 @@ export class WorkflowDragService {
|
|||||||
transform.update({
|
transform.update({
|
||||||
position: newPosition,
|
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.isDragging = false;
|
||||||
this._nodesDragEmitter.fire({
|
this._nodesDragEmitter.fire({
|
||||||
type: 'onDragEnd',
|
type: 'onDragEnd',
|
||||||
nodes: selectedNodes,
|
nodes: selectedNodes,
|
||||||
startPositions,
|
startPositions,
|
||||||
altKey,
|
altKey,
|
||||||
|
dragEvent,
|
||||||
|
triggerEvent,
|
||||||
|
dragger,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return dragger
|
return dragger
|
||||||
.start(event.clientX, event.clientY, this.playgroundConfig)
|
.start(triggerEvent.clientX, triggerEvent.clientY, this.playgroundConfig)
|
||||||
.then(() => dragSuccess);
|
?.then(() => dragSuccess);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -332,6 +331,13 @@ export class WorkflowDragService {
|
|||||||
},
|
},
|
||||||
onDragEnd: async (e) => {
|
onDragEnd: async (e) => {
|
||||||
const dropNode = this._dropNode;
|
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);
|
const dragNode = await this.dropCard(type, e, data, dropNode);
|
||||||
this.clearDrop();
|
this.clearDrop();
|
||||||
if (dragNode) {
|
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);
|
return this.nodeSelectable(entity);
|
||||||
})
|
})
|
||||||
.filter((transform) => {
|
.filter((transform) => this.isContainer(transform.entity));
|
||||||
const { entity } = transform;
|
}
|
||||||
return entity.flowNodeType === FlowNodeBaseType.SUB_CANVAS;
|
|
||||||
});
|
/** 是否容器节点 */
|
||||||
|
private isContainer(node?: WorkflowNodeEntity): boolean {
|
||||||
|
return node?.getNodeMeta<WorkflowNodeMeta>().isContainer ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -519,8 +548,8 @@ export class WorkflowDragService {
|
|||||||
return {
|
return {
|
||||||
hasError: false,
|
hasError: false,
|
||||||
};
|
};
|
||||||
} else if (toNode.flowNodeType === FlowNodeBaseType.SUB_CANVAS) {
|
} else if (this.isContainer(toNode)) {
|
||||||
// 在子画布内进行连线的情况,需忽略
|
// 在容器内进行连线的情况,需忽略
|
||||||
return {
|
return {
|
||||||
hasError: false,
|
hasError: false,
|
||||||
};
|
};
|
||||||
@ -631,7 +660,7 @@ export class WorkflowDragService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.setLineColor(line, this.linesManager.lineColor.drawing);
|
this.setLineColor(line, this.linesManager.lineColor.drawing);
|
||||||
if (toNode && toNode.flowNodeType !== FlowNodeBaseType.SUB_CANVAS) {
|
if (toNode && !this.isContainer(toNode)) {
|
||||||
// 如果鼠标 hover 在 node 中的时候,默认连线到这个 node 的初始位置
|
// 如果鼠标 hover 在 node 中的时候,默认连线到这个 node 的初始位置
|
||||||
const portsData = toNode.getData(WorkflowNodePortsData)!;
|
const portsData = toNode.getData(WorkflowNodePortsData)!;
|
||||||
toPort = portsData.inputPorts[0];
|
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-registry';
|
||||||
export * from './workflow-line';
|
export * from './workflow-line';
|
||||||
export * from './workflow-sub-canvas';
|
export * from './workflow-sub-canvas';
|
||||||
|
export * from './workflow-operation';
|
||||||
|
export * from './workflow-drag';
|
||||||
|
|
||||||
export const URLParams = Symbol('');
|
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; // 默认点位
|
defaultPorts?: WorkflowPorts; // 默认点位
|
||||||
useDynamicPort?: boolean; // 使用动态点位,会计算 data-port-key
|
useDynamicPort?: boolean; // 使用动态点位,会计算 data-port-key
|
||||||
subCanvas?: (node: WorkflowNodeEntity) => WorkflowSubCanvas | undefined;
|
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 { ContainerModule } from 'inversify';
|
||||||
import { FlowDocument, FlowDocumentContribution } from '@flowgram.ai/document';
|
|
||||||
import { bindContributions } from '@flowgram.ai/utils';
|
import { bindContributions } from '@flowgram.ai/utils';
|
||||||
|
import { FlowDocument, FlowDocumentContribution } from '@flowgram.ai/document';
|
||||||
|
|
||||||
import { WorkflowLinesManager } from './workflow-lines-manager';
|
import { WorkflowLinesManager } from './workflow-lines-manager';
|
||||||
import {
|
import {
|
||||||
@ -10,12 +10,13 @@ import {
|
|||||||
import { WorkflowDocumentContribution } from './workflow-document-contribution';
|
import { WorkflowDocumentContribution } from './workflow-document-contribution';
|
||||||
import { WorkflowDocument, WorkflowDocumentProvider } from './workflow-document';
|
import { WorkflowDocument, WorkflowDocumentProvider } from './workflow-document';
|
||||||
import { getUrlParams } from './utils/get-url-params';
|
import { getUrlParams } from './utils/get-url-params';
|
||||||
import { URLParams } from './typings';
|
import { URLParams, WorkflowOperationBaseService } from './typings';
|
||||||
import {
|
import {
|
||||||
WorkflowDragService,
|
WorkflowDragService,
|
||||||
WorkflowHoverService,
|
WorkflowHoverService,
|
||||||
WorkflowSelectService,
|
WorkflowSelectService,
|
||||||
WorkflowResetLayoutService,
|
WorkflowResetLayoutService,
|
||||||
|
WorkflowOperationBaseServiceImpl,
|
||||||
} from './service';
|
} from './service';
|
||||||
import { FreeLayout } from './layout';
|
import { FreeLayout } from './layout';
|
||||||
|
|
||||||
@ -28,6 +29,7 @@ export const WorkflowDocumentContainerModule = new ContainerModule(
|
|||||||
bind(WorkflowSelectService).toSelf().inSingletonScope();
|
bind(WorkflowSelectService).toSelf().inSingletonScope();
|
||||||
bind(WorkflowHoverService).toSelf().inSingletonScope();
|
bind(WorkflowHoverService).toSelf().inSingletonScope();
|
||||||
bind(WorkflowResetLayoutService).toSelf().inSingletonScope();
|
bind(WorkflowResetLayoutService).toSelf().inSingletonScope();
|
||||||
|
bind(WorkflowOperationBaseService).to(WorkflowOperationBaseServiceImpl).inSingletonScope();
|
||||||
bind(URLParams)
|
bind(URLParams)
|
||||||
.toDynamicValue(() => getUrlParams())
|
.toDynamicValue(() => getUrlParams())
|
||||||
.inSingletonScope();
|
.inSingletonScope();
|
||||||
@ -37,7 +39,7 @@ export const WorkflowDocumentContainerModule = new ContainerModule(
|
|||||||
});
|
});
|
||||||
rebind(FlowDocument).toService(WorkflowDocument);
|
rebind(FlowDocument).toService(WorkflowDocument);
|
||||||
bind(WorkflowDocumentProvider)
|
bind(WorkflowDocumentProvider)
|
||||||
.toDynamicValue(ctx => () => ctx.container.get(WorkflowDocument))
|
.toDynamicValue((ctx) => () => ctx.container.get(WorkflowDocument))
|
||||||
.inSingletonScope();
|
.inSingletonScope();
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@ -4,8 +4,13 @@ import { TransformData } from '@flowgram.ai/core';
|
|||||||
|
|
||||||
import { type WorkflowLinesManager } from './workflow-lines-manager';
|
import { type WorkflowLinesManager } from './workflow-lines-manager';
|
||||||
import { initFormDataFromJSON, toFormJSON } from './utils/flow-node-form-data';
|
import { initFormDataFromJSON, toFormJSON } from './utils/flow-node-form-data';
|
||||||
import { LineColor, LineRenderType, WorkflowNodeJSON, WorkflowNodeMeta } from './typings';
|
import {
|
||||||
import { onDragLineEndParams } from './service';
|
LineColor,
|
||||||
|
LineRenderType,
|
||||||
|
onDragLineEndParams,
|
||||||
|
WorkflowNodeJSON,
|
||||||
|
WorkflowNodeMeta,
|
||||||
|
} from './typings';
|
||||||
import {
|
import {
|
||||||
type WorkflowLineEntity,
|
type WorkflowLineEntity,
|
||||||
type WorkflowLinePortInfo,
|
type WorkflowLinePortInfo,
|
||||||
|
|||||||
@ -367,11 +367,11 @@ export class WorkflowDocument extends FlowDocument {
|
|||||||
const endNodeId = allNode.find((node) => node.isNodeEnd)!.id;
|
const endNodeId = allNode.find((node) => node.isNodeEnd)!.id;
|
||||||
|
|
||||||
// 子画布内节点无需开始/结束
|
// 子画布内节点无需开始/结束
|
||||||
const nodeInSubCanvas = allNode
|
const nodeInContainer = allNode
|
||||||
.filter((node) => node.parent?.flowNodeType === FlowNodeBaseType.SUB_CANVAS)
|
.filter((node) => node.parent?.getNodeMeta<WorkflowNodeMeta>().isContainer)
|
||||||
.map((node) => node.id);
|
.map((node) => node.id);
|
||||||
|
|
||||||
const associatedCache = new Set([endNodeId, ...nodeInSubCanvas]);
|
const associatedCache = new Set([endNodeId, ...nodeInContainer]);
|
||||||
const bfs = (nodeId: string) => {
|
const bfs = (nodeId: string) => {
|
||||||
if (associatedCache.has(nodeId)) {
|
if (associatedCache.has(nodeId)) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -1,11 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { inject, injectable } from 'inversify';
|
import { inject, injectable } from 'inversify';
|
||||||
import {
|
import { domUtils } from '@flowgram.ai/utils';
|
||||||
FlowNodeEntity,
|
import { Rectangle } from '@flowgram.ai/utils';
|
||||||
FlowNodeRenderData,
|
import { FlowNodeEntity, FlowNodeRenderData, FlowNodeTransformData } from '@flowgram.ai/document';
|
||||||
FlowNodeTransformData,
|
|
||||||
} from '@flowgram.ai/document';
|
|
||||||
import {
|
import {
|
||||||
CommandRegistry,
|
CommandRegistry,
|
||||||
EditorState,
|
EditorState,
|
||||||
@ -16,8 +14,6 @@ import {
|
|||||||
observeEntity,
|
observeEntity,
|
||||||
observeEntityDatas,
|
observeEntityDatas,
|
||||||
} from '@flowgram.ai/core';
|
} from '@flowgram.ai/core';
|
||||||
import { domUtils } from '@flowgram.ai/utils';
|
|
||||||
import { Rectangle } from '@flowgram.ai/utils';
|
|
||||||
|
|
||||||
import { FlowRendererKey, FlowRendererRegistry } from '../flow-renderer-registry';
|
import { FlowRendererKey, FlowRendererRegistry } from '../flow-renderer-registry';
|
||||||
import { FlowSelectConfigEntity, SelectorBoxConfigEntity } from '../entities';
|
import { FlowSelectConfigEntity, SelectorBoxConfigEntity } from '../entities';
|
||||||
@ -32,6 +28,7 @@ export interface SelectorBoxPopoverProps {
|
|||||||
|
|
||||||
export interface FlowSelectorBoundsLayerOptions extends LayerOptions {
|
export interface FlowSelectorBoundsLayerOptions extends LayerOptions {
|
||||||
ignoreOneSelect?: boolean;
|
ignoreOneSelect?: boolean;
|
||||||
|
ignoreChildrenLength?: boolean;
|
||||||
boundsPadding?: number; // 边框留白,默认 10
|
boundsPadding?: number; // 边框留白,默认 10
|
||||||
disableBackground?: boolean; // 禁用背景框
|
disableBackground?: boolean; // 禁用背景框
|
||||||
backgroundClassName?: string; // 节点下边
|
backgroundClassName?: string; // 节点下边
|
||||||
@ -130,6 +127,7 @@ export class FlowSelectorBoundsLayer extends Layer<FlowSelectorBoundsLayerOption
|
|||||||
render(): JSX.Element {
|
render(): JSX.Element {
|
||||||
const {
|
const {
|
||||||
ignoreOneSelect,
|
ignoreOneSelect,
|
||||||
|
ignoreChildrenLength,
|
||||||
SelectorBoxPopover: SelectorBoxPopoverFromOpts,
|
SelectorBoxPopover: SelectorBoxPopoverFromOpts,
|
||||||
disableBackground,
|
disableBackground,
|
||||||
CustomBoundsRenderer,
|
CustomBoundsRenderer,
|
||||||
@ -148,7 +146,7 @@ export class FlowSelectorBoundsLayer extends Layer<FlowSelectorBoundsLayerOption
|
|||||||
(ignoreOneSelect &&
|
(ignoreOneSelect &&
|
||||||
selectedNodes.length === 1 &&
|
selectedNodes.length === 1 &&
|
||||||
// 选中的节点不包含多个子节点
|
// 选中的节点不包含多个子节点
|
||||||
(selectedNodes[0] as FlowNodeEntity).childrenLength <= 1)
|
(ignoreChildrenLength || (selectedNodes[0] as FlowNodeEntity).childrenLength <= 1))
|
||||||
) {
|
) {
|
||||||
domUtils.setStyle(bg, {
|
domUtils.setStyle(bg, {
|
||||||
display: 'none',
|
display: 'none',
|
||||||
|
|||||||
@ -105,6 +105,32 @@ describe('history-operation-service moveNode', () => {
|
|||||||
expect(getNodeChildrenIds(root)).toEqual(['start_0', 'dynamicSplit_0', 'end_0']);
|
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 () => {
|
it('move node with parent and index', async () => {
|
||||||
const root = flowDocument.getNode('root');
|
const root = flowDocument.getNode('root');
|
||||||
flowDocument.addFromNode('dynamicSplit_0', {
|
flowDocument.addFromNode('dynamicSplit_0', {
|
||||||
|
|||||||
@ -236,6 +236,7 @@ export function createFreeLayoutPreset(
|
|||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
ignoreOneSelect: true, // 自由布局不选择单个节点
|
ignoreOneSelect: true, // 自由布局不选择单个节点
|
||||||
|
ignoreChildrenLength: true, // 自由布局忽略子节点数量
|
||||||
...(opts.selectBox || {}),
|
...(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,
|
WorkflowDocument,
|
||||||
WorkflowResetLayoutService,
|
WorkflowResetLayoutService,
|
||||||
WorkflowDragService,
|
WorkflowDragService,
|
||||||
|
WorkflowOperationBaseService,
|
||||||
} from '@flowgram.ai/free-layout-core';
|
} from '@flowgram.ai/free-layout-core';
|
||||||
import { FlowNodeFormData } from '@flowgram.ai/form-core';
|
import { FlowNodeFormData } from '@flowgram.ai/form-core';
|
||||||
import { FormManager } 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 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 { HistoryEntityManager } from './history-entity-manager';
|
||||||
import { DragNodesHandler } from './handlers/drag-nodes-handler';
|
import { DragNodesHandler } from './handlers/drag-nodes-handler';
|
||||||
import { ChangeNodeDataHandler } from './handlers/change-node-data-handler';
|
import { ChangeNodeDataHandler } from './handlers/change-node-data-handler';
|
||||||
@ -40,6 +42,9 @@ export class FreeHistoryManager {
|
|||||||
|
|
||||||
private _toDispose: DisposableCollection = new DisposableCollection();
|
private _toDispose: DisposableCollection = new DisposableCollection();
|
||||||
|
|
||||||
|
@inject(WorkflowOperationBaseService)
|
||||||
|
private _operationService: WorkflowOperationBaseService;
|
||||||
|
|
||||||
onInit(ctx: PluginContext, opts: FreeHistoryPluginOptions) {
|
onInit(ctx: PluginContext, opts: FreeHistoryPluginOptions) {
|
||||||
const document = ctx.get<WorkflowDocument>(WorkflowDocument);
|
const document = ctx.get<WorkflowDocument>(WorkflowDocument);
|
||||||
const historyService = ctx.get<HistoryService>(HistoryService);
|
const historyService = ctx.get<HistoryService>(HistoryService);
|
||||||
@ -101,6 +106,39 @@ export class FreeHistoryManager {
|
|||||||
{ noApply: true }
|
{ 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 { FlowNodeTransformData } from '@flowgram.ai/document';
|
||||||
import { type PluginContext, TransformData } from '@flowgram.ai/core';
|
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 { type DragNodeOperationValue, FreeOperationType } from '../types';
|
||||||
import { baseOperationMeta } from './base';
|
import { baseOperationMeta } from './base';
|
||||||
@ -9,7 +9,7 @@ import { baseOperationMeta } from './base';
|
|||||||
export const dragNodesOperationMeta: OperationMeta<DragNodeOperationValue, PluginContext, void> = {
|
export const dragNodesOperationMeta: OperationMeta<DragNodeOperationValue, PluginContext, void> = {
|
||||||
...baseOperationMeta,
|
...baseOperationMeta,
|
||||||
type: FreeOperationType.dragNodes,
|
type: FreeOperationType.dragNodes,
|
||||||
inverse: op => ({
|
inverse: (op) => ({
|
||||||
...op,
|
...op,
|
||||||
value: {
|
value: {
|
||||||
...op.value,
|
...op.value,
|
||||||
@ -35,7 +35,7 @@ export const dragNodesOperationMeta: OperationMeta<DragNodeOperationValue, Plugi
|
|||||||
});
|
});
|
||||||
// 嵌套情况下需将子节点 transform 设为 dirty
|
// 嵌套情况下需将子节点 transform 设为 dirty
|
||||||
if (node.collapsedChildren?.length > 0) {
|
if (node.collapsedChildren?.length > 0) {
|
||||||
node.collapsedChildren.forEach(childNode => {
|
node.collapsedChildren.forEach((childNode) => {
|
||||||
const childNodeTransformData =
|
const childNodeTransformData =
|
||||||
childNode.getData<FlowNodeTransformData>(FlowNodeTransformData);
|
childNode.getData<FlowNodeTransformData>(FlowNodeTransformData);
|
||||||
childNodeTransformData.fireChange();
|
childNodeTransformData.fireChange();
|
||||||
@ -43,5 +43,4 @@ export const dragNodesOperationMeta: OperationMeta<DragNodeOperationValue, Plugi
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
shouldMerge: () => false,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { resetLayoutOperationMeta } from './reset-layout';
|
import { resetLayoutOperationMeta } from './reset-layout';
|
||||||
|
import { moveChildNodesOperationMeta } from './move-child-nodes';
|
||||||
import { dragNodesOperationMeta } from './drag-nodes';
|
import { dragNodesOperationMeta } from './drag-nodes';
|
||||||
import { deleteNodeOperationMeta } from './delete-node';
|
import { deleteNodeOperationMeta } from './delete-node';
|
||||||
import { deleteLineOperationMeta } from './delete-line';
|
import { deleteLineOperationMeta } from './delete-line';
|
||||||
@ -14,4 +15,5 @@ export const operationMetas = [
|
|||||||
changeNodeDataOperationMeta,
|
changeNodeDataOperationMeta,
|
||||||
resetLayoutOperationMeta,
|
resetLayoutOperationMeta,
|
||||||
dragNodesOperationMeta,
|
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',
|
changeNodeData = 'changeNodeData',
|
||||||
resetLayout = 'resetLayout',
|
resetLayout = 'resetLayout',
|
||||||
dragNodes = 'dragNodes',
|
dragNodes = 'dragNodes',
|
||||||
|
moveChildNodes = 'moveChildNodes',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AddOrDeleteLineOperationValue extends WorkflowLinePortInfo {
|
export interface AddOrDeleteLineOperationValue extends WorkflowLinePortInfo {
|
||||||
|
|||||||
@ -1,6 +1,17 @@
|
|||||||
/* eslint-disable complexity */
|
/* eslint-disable complexity */
|
||||||
import { inject, injectable } from 'inversify';
|
import { inject, injectable } from 'inversify';
|
||||||
|
import { type IPoint } from '@flowgram.ai/utils';
|
||||||
import { SelectorBoxConfigEntity } from '@flowgram.ai/renderer';
|
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 { FlowNodeTransformData } from '@flowgram.ai/document';
|
||||||
import {
|
import {
|
||||||
EditorState,
|
EditorState,
|
||||||
@ -12,17 +23,6 @@ import {
|
|||||||
observeEntityDatas,
|
observeEntityDatas,
|
||||||
type LayerOptions,
|
type LayerOptions,
|
||||||
} from '@flowgram.ai/core';
|
} 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';
|
import { getSelectionBounds } from './selection-utils';
|
||||||
const PORT_BG_CLASS_NAME = 'workflow-port-bg';
|
const PORT_BG_CLASS_NAME = 'workflow-port-bg';
|
||||||
@ -79,9 +79,9 @@ export class HoverLayer extends Layer<HoverLayerOptions> {
|
|||||||
autorun(): void {
|
autorun(): void {
|
||||||
const { activatedNode } = this.selectionService;
|
const { activatedNode } = this.selectionService;
|
||||||
this.nodeTransformsWithSort = this.nodeTransforms
|
this.nodeTransformsWithSort = this.nodeTransforms
|
||||||
.filter(n => n.entity.id !== 'root')
|
.filter((n) => n.entity.id !== 'root')
|
||||||
.reverse() // 后创建的排在前面
|
.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(
|
const selectionBounds = getSelectionBounds(
|
||||||
this.selectionService.selection,
|
this.selectionService.selection,
|
||||||
// 这里只考虑多选模式,单选模式已经下沉到 use-node-render 中
|
// 这里只考虑多选模式,单选模式已经下沉到 use-node-render 中
|
||||||
true,
|
true
|
||||||
);
|
);
|
||||||
if (selectionBounds.width > 0 && selectionBounds.contains(mousePos.x, mousePos.y)) {
|
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 (!dragSuccess) {
|
||||||
// 拖拽没有成功触发了点击
|
// 拖拽没有成功触发了点击
|
||||||
if (hoveredNode && hoveredNode instanceof WorkflowNodeEntity) {
|
if (hoveredNode && hoveredNode instanceof WorkflowNodeEntity) {
|
||||||
if (e.metaKey || e.shiftKey || e.ctrlKey) {
|
// 追加选择
|
||||||
|
if (e.shiftKey) {
|
||||||
this.selectionService.toggleSelect(hoveredNode);
|
this.selectionService.toggleSelect(hoveredNode);
|
||||||
} else {
|
} else {
|
||||||
this.selectionService.selectNode(hoveredNode);
|
this.selectionService.selectNode(hoveredNode);
|
||||||
@ -188,8 +189,8 @@ export class HoverLayer extends Layer<HoverLayerOptions> {
|
|||||||
const portHovered = this.linesManager.getPortFromMousePos(mousePos);
|
const portHovered = this.linesManager.getPortFromMousePos(mousePos);
|
||||||
|
|
||||||
const lineDomNodes = this.playgroundNode.querySelectorAll(LINE_CLASS_NAME);
|
const lineDomNodes = this.playgroundNode.querySelectorAll(LINE_CLASS_NAME);
|
||||||
const checkTargetFromLine = [...lineDomNodes].some(lineDom =>
|
const checkTargetFromLine = [...lineDomNodes].some((lineDom) =>
|
||||||
lineDom.contains(target as HTMLElement),
|
lineDom.contains(target as HTMLElement)
|
||||||
);
|
);
|
||||||
// 默认 只有 output 点位可以 hover
|
// 默认 只有 output 点位可以 hover
|
||||||
if (portHovered) {
|
if (portHovered) {
|
||||||
@ -212,13 +213,13 @@ export class HoverLayer extends Layer<HoverLayerOptions> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nodeHovered = nodeTransforms.find((trans: FlowNodeTransformData) =>
|
const nodeHovered = nodeTransforms.find((trans: FlowNodeTransformData) =>
|
||||||
trans.bounds.contains(mousePos.x, mousePos.y),
|
trans.bounds.contains(mousePos.x, mousePos.y)
|
||||||
)?.entity as WorkflowNodeEntity;
|
)?.entity as WorkflowNodeEntity;
|
||||||
|
|
||||||
// 判断当前鼠标位置所在元素是否在节点内部
|
// 判断当前鼠标位置所在元素是否在节点内部
|
||||||
const nodeDomNodes = this.playgroundNode.querySelectorAll(NODE_CLASS_NAME);
|
const nodeDomNodes = this.playgroundNode.querySelectorAll(NODE_CLASS_NAME);
|
||||||
const checkTargetFromNode = [...nodeDomNodes].some(nodeDom =>
|
const checkTargetFromNode = [...nodeDomNodes].some((nodeDom) =>
|
||||||
nodeDom.contains(target as HTMLElement),
|
nodeDom.contains(target as HTMLElement)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (nodeHovered || checkTargetFromNode) {
|
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 { 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;
|
const BOUNDS_PADDING = 2;
|
||||||
|
|
||||||
export function getSelectionBounds(
|
export function getSelectionBounds(
|
||||||
selection: Entity[],
|
selection: Entity[],
|
||||||
ignoreOneSelect?: boolean, // 忽略单选
|
ignoreOneSelect: boolean = true // 忽略单选
|
||||||
): Rectangle {
|
): Rectangle {
|
||||||
const selectedNodes = selection.filter(node => node instanceof FlowNodeEntity);
|
const selectedNodes = selection.filter((node) => node instanceof WorkflowNodeEntity);
|
||||||
|
|
||||||
if (!selectedNodes?.length) {
|
|
||||||
return Rectangle.EMPTY;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 选中单个的时候不显示
|
// 选中单个的时候不显示
|
||||||
if (
|
return selectedNodes.length > (ignoreOneSelect ? 1 : 0)
|
||||||
ignoreOneSelect &&
|
? Rectangle.enlarge(selectedNodes.map((n) => n.getData(FlowNodeTransformData)!.bounds)).pad(
|
||||||
selectedNodes.length === 1 &&
|
BOUNDS_PADDING
|
||||||
// 选中的节点不包含多个子节点
|
)
|
||||||
(selectedNodes[0] as FlowNodeEntity).childrenLength <= 1
|
: Rectangle.EMPTY;
|
||||||
) {
|
|
||||||
return Rectangle.EMPTY;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Rectangle.enlarge(selectedNodes.map(n => n.getData(FlowNodeTransformData)!.bounds)).pad(
|
|
||||||
BOUNDS_PADDING,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,12 +8,12 @@ import {
|
|||||||
WorkflowPortEntity,
|
WorkflowPortEntity,
|
||||||
WorkflowNodePortsData,
|
WorkflowNodePortsData,
|
||||||
WorkflowNodeEntity,
|
WorkflowNodeEntity,
|
||||||
|
WorkflowNodeMeta,
|
||||||
} from '@flowgram.ai/free-layout-core';
|
} from '@flowgram.ai/free-layout-core';
|
||||||
import { WorkflowSelectService } from '@flowgram.ai/free-layout-core';
|
import { WorkflowSelectService } from '@flowgram.ai/free-layout-core';
|
||||||
import { WorkflowNodeJSON } from '@flowgram.ai/free-layout-core';
|
import { WorkflowNodeJSON } from '@flowgram.ai/free-layout-core';
|
||||||
import { FreeOperationType, HistoryService } from '@flowgram.ai/free-history-plugin';
|
import { FreeOperationType, HistoryService } from '@flowgram.ai/free-history-plugin';
|
||||||
import { FlowNodeTransformData } from '@flowgram.ai/document';
|
import { FlowNodeTransformData } from '@flowgram.ai/document';
|
||||||
import { FlowNodeBaseType } from '@flowgram.ai/document';
|
|
||||||
import { PlaygroundConfigEntity } from '@flowgram.ai/core';
|
import { PlaygroundConfigEntity } from '@flowgram.ai/core';
|
||||||
import { TransformData } from '@flowgram.ai/core';
|
import { TransformData } from '@flowgram.ai/core';
|
||||||
|
|
||||||
@ -293,7 +293,7 @@ export class WorkflowNodePanelService {
|
|||||||
}
|
}
|
||||||
const fromNode = fromPort?.node;
|
const fromNode = fromPort?.node;
|
||||||
const fromContainer = fromNode?.parent;
|
const fromContainer = fromNode?.parent;
|
||||||
if (fromNode?.flowNodeType === FlowNodeBaseType.SUB_CANVAS) {
|
if (this.isContainer(fromNode)) {
|
||||||
// 子画布内部输入连线
|
// 子画布内部输入连线
|
||||||
return fromNode;
|
return fromNode;
|
||||||
}
|
}
|
||||||
@ -303,7 +303,7 @@ export class WorkflowNodePanelService {
|
|||||||
/** 获取端口矩形 */
|
/** 获取端口矩形 */
|
||||||
private getPortBox(port: WorkflowPortEntity, offset: IPoint = { x: 0, y: 0 }): Rectangle {
|
private getPortBox(port: WorkflowPortEntity, offset: IPoint = { x: 0, y: 0 }): Rectangle {
|
||||||
const node = port.node;
|
const node = port.node;
|
||||||
if (node.flowNodeType === FlowNodeBaseType.SUB_CANVAS) {
|
if (this.isContainer(node)) {
|
||||||
// 子画布内部端口需要虚拟节点
|
// 子画布内部端口需要虚拟节点
|
||||||
const { point } = port;
|
const { point } = port;
|
||||||
if (port.portType === 'input') {
|
if (port.portType === 'input') {
|
||||||
@ -424,7 +424,7 @@ export class WorkflowNodePanelService {
|
|||||||
|
|
||||||
/** 获取后续节点 */
|
/** 获取后续节点 */
|
||||||
private getSubsequentNodes(node: WorkflowNodeEntity): WorkflowNodeEntity[] {
|
private getSubsequentNodes(node: WorkflowNodeEntity): WorkflowNodeEntity[] {
|
||||||
if (node.flowNodeType === FlowNodeBaseType.SUB_CANVAS) {
|
if (this.isContainer(node)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const brothers = node.parent?.collapsedChildren ?? [];
|
const brothers = node.parent?.collapsedChildren ?? [];
|
||||||
@ -436,7 +436,7 @@ export class WorkflowNodePanelService {
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!line.to?.id ||
|
!line.to?.id ||
|
||||||
line.to.flowNodeType === FlowNodeBaseType.SUB_CANVAS // 子画布内部成环
|
this.isContainer(line.to) // 子画布内部成环
|
||||||
) {
|
) {
|
||||||
return;
|
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 { 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 { FlowNodeTransformData } from '@flowgram.ai/document';
|
||||||
import { FlowNodeBaseType } from '@flowgram.ai/document';
|
import { FlowNodeBaseType } from '@flowgram.ai/document';
|
||||||
import { EntityManager, PlaygroundConfigEntity, TransformData } from '@flowgram.ai/core';
|
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 { isEqual, isGreaterThan, isLessThan, isLessThanOrEqual, isNumber } from './utils';
|
||||||
import type {
|
import type {
|
||||||
@ -44,6 +44,8 @@ export class WorkflowSnapService {
|
|||||||
|
|
||||||
public readonly onSnap = this.snapEmitter.event;
|
public readonly onSnap = this.snapEmitter.event;
|
||||||
|
|
||||||
|
private _disabled = false;
|
||||||
|
|
||||||
public init(params: Partial<WorkflowSnapServiceOptions> = {}): void {
|
public init(params: Partial<WorkflowSnapServiceOptions> = {}): void {
|
||||||
this.options = {
|
this.options = {
|
||||||
...SnapDefaultOptions,
|
...SnapDefaultOptions,
|
||||||
@ -53,18 +55,46 @@ export class WorkflowSnapService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public dispose(): void {
|
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 {
|
private mountListener(): void {
|
||||||
const dragAdjusterDisposer = this.dragService.registerPosAdjuster(params =>
|
const dragAdjusterDisposer = this.dragService.registerPosAdjuster((params) => {
|
||||||
this.snapping({
|
const { selectedNodes: targetNodes, position } = params;
|
||||||
targetNodes: params.selectedNodes,
|
const isMultiSnapping = this.options.enableMultiSnapping ? false : targetNodes.length !== 1;
|
||||||
position: params.position,
|
if (this._disabled || !this.options.enableEdgeSnapping || isMultiSnapping) {
|
||||||
}),
|
return {
|
||||||
);
|
x: 0,
|
||||||
const dragEndDisposer = this.dragService.onNodesDrag(event => {
|
y: 0,
|
||||||
if (event.type !== 'onDragEnd') {
|
};
|
||||||
|
}
|
||||||
|
return this.snapping({
|
||||||
|
targetNodes,
|
||||||
|
position,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const dragEndDisposer = this.dragService.onNodesDrag((event) => {
|
||||||
|
if (event.type !== 'onDragEnd' || this._disabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.options.enableGridSnapping) {
|
if (this.options.enableGridSnapping) {
|
||||||
@ -81,24 +111,15 @@ export class WorkflowSnapService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private snapping(params: { targetNodes: WorkflowNodeEntity[]; position: IPoint }): IPoint {
|
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;
|
const targetBounds = this.getBounds(targetNodes);
|
||||||
|
|
||||||
if (!this.options.enableEdgeSnapping || isMultiSnapping) {
|
|
||||||
return {
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedBounds = this.getBounds(targetNodes);
|
|
||||||
|
|
||||||
const targetRect = new Rectangle(
|
const targetRect = new Rectangle(
|
||||||
position.x,
|
position.x,
|
||||||
position.y,
|
position.y,
|
||||||
selectedBounds.width,
|
targetBounds.width,
|
||||||
selectedBounds.height,
|
targetBounds.height
|
||||||
);
|
);
|
||||||
|
|
||||||
const snapNodeRects = this.getSnapNodeRects({
|
const snapNodeRects = this.getSnapNodeRects({
|
||||||
@ -127,7 +148,7 @@ export class WorkflowSnapService {
|
|||||||
position.x + offset.x,
|
position.x + offset.x,
|
||||||
position.y + offset.y,
|
position.y + offset.y,
|
||||||
targetRect.width,
|
targetRect.width,
|
||||||
targetRect.height,
|
targetRect.height
|
||||||
);
|
);
|
||||||
|
|
||||||
this.snapEmitter.fire({
|
this.snapEmitter.fire({
|
||||||
@ -155,23 +176,23 @@ export class WorkflowSnapService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 找到最近的线条
|
// 找到最近的线条
|
||||||
const topYClosestLine = snapLines.horizontal.find(line =>
|
const topYClosestLine = snapLines.horizontal.find((line) =>
|
||||||
isLessThanOrEqual(Math.abs(line.y - targetRect.top), edgeThreshold),
|
isLessThanOrEqual(Math.abs(line.y - targetRect.top), edgeThreshold)
|
||||||
);
|
);
|
||||||
const bottomYClosestLine = snapLines.horizontal.find(line =>
|
const bottomYClosestLine = snapLines.horizontal.find((line) =>
|
||||||
isLessThanOrEqual(Math.abs(line.y - targetRect.bottom), edgeThreshold),
|
isLessThanOrEqual(Math.abs(line.y - targetRect.bottom), edgeThreshold)
|
||||||
);
|
);
|
||||||
const leftXClosestLine = snapLines.vertical.find(line =>
|
const leftXClosestLine = snapLines.vertical.find((line) =>
|
||||||
isLessThanOrEqual(Math.abs(line.x - targetRect.left), edgeThreshold),
|
isLessThanOrEqual(Math.abs(line.x - targetRect.left), edgeThreshold)
|
||||||
);
|
);
|
||||||
const rightXClosestLine = snapLines.vertical.find(line =>
|
const rightXClosestLine = snapLines.vertical.find((line) =>
|
||||||
isLessThanOrEqual(Math.abs(line.x - targetRect.right), edgeThreshold),
|
isLessThanOrEqual(Math.abs(line.x - targetRect.right), edgeThreshold)
|
||||||
);
|
);
|
||||||
const midYClosestLine = snapLines.midHorizontal.find(line =>
|
const midYClosestLine = snapLines.midHorizontal.find((line) =>
|
||||||
isLessThanOrEqual(Math.abs(line.y - targetRect.center.y), edgeThreshold),
|
isLessThanOrEqual(Math.abs(line.y - targetRect.center.y), edgeThreshold)
|
||||||
);
|
);
|
||||||
const midXClosestLine = snapLines.midVertical.find(line =>
|
const midXClosestLine = snapLines.midVertical.find((line) =>
|
||||||
isLessThanOrEqual(Math.abs(line.x - targetRect.center.x), edgeThreshold),
|
isLessThanOrEqual(Math.abs(line.x - targetRect.center.x), edgeThreshold)
|
||||||
);
|
);
|
||||||
|
|
||||||
// 计算最近坐标
|
// 计算最近坐标
|
||||||
@ -227,11 +248,11 @@ export class WorkflowSnapService {
|
|||||||
x: snappedPosition.x - rect.x,
|
x: snappedPosition.x - rect.x,
|
||||||
y: snappedPosition.y - rect.y,
|
y: snappedPosition.y - rect.y,
|
||||||
};
|
};
|
||||||
targetNodes.forEach(node =>
|
targetNodes.forEach((node) =>
|
||||||
this.updateNodePositionWithOffset({
|
this.updateNodePositionWithOffset({
|
||||||
node,
|
node,
|
||||||
offset,
|
offset,
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -256,7 +277,7 @@ export class WorkflowSnapService {
|
|||||||
const midHorizontalLines: SnapMidHorizontalLine[] = [];
|
const midHorizontalLines: SnapMidHorizontalLine[] = [];
|
||||||
const midVerticalLines: SnapMidVerticalLine[] = [];
|
const midVerticalLines: SnapMidVerticalLine[] = [];
|
||||||
|
|
||||||
snapNodeRects.forEach(snapNodeRect => {
|
snapNodeRects.forEach((snapNodeRect) => {
|
||||||
const nodeBounds = snapNodeRect.rect;
|
const nodeBounds = snapNodeRect.rect;
|
||||||
const nodeCenter = nodeBounds.center;
|
const nodeCenter = nodeBounds.center;
|
||||||
// 边缘横线
|
// 边缘横线
|
||||||
@ -310,11 +331,11 @@ export class WorkflowSnapService {
|
|||||||
const targetCenter = targetRect.center;
|
const targetCenter = targetRect.center;
|
||||||
const targetContainerId = targetNodes[0].parent?.id ?? this.document.root.id;
|
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);
|
disabledNodeIds.push(FlowNodeBaseType.ROOT);
|
||||||
const availableNodes = this.nodes
|
const availableNodes = this.nodes
|
||||||
.filter(n => n.parent?.id === targetContainerId)
|
.filter((n) => n.parent?.id === targetContainerId)
|
||||||
.filter(n => !disabledNodeIds.includes(n.id))
|
.filter((n) => !disabledNodeIds.includes(n.id))
|
||||||
.sort((nodeA, nodeB) => {
|
.sort((nodeA, nodeB) => {
|
||||||
const nodeCenterA = nodeA.getData(FlowNodeTransformData)!.bounds.center;
|
const nodeCenterA = nodeA.getData(FlowNodeTransformData)!.bounds.center;
|
||||||
const nodeCenterB = nodeB.getData(FlowNodeTransformData)!.bounds.center;
|
const nodeCenterB = nodeB.getData(FlowNodeTransformData)!.bounds.center;
|
||||||
@ -340,7 +361,7 @@ export class WorkflowSnapService {
|
|||||||
const availableNodes = this.getAvailableNodes(params);
|
const availableNodes = this.getAvailableNodes(params);
|
||||||
const viewRect = this.viewRect();
|
const viewRect = this.viewRect();
|
||||||
return availableNodes
|
return availableNodes
|
||||||
.map(node => {
|
.map((node) => {
|
||||||
const snapNodeRect: SnapNodeRect = {
|
const snapNodeRect: SnapNodeRect = {
|
||||||
id: node.id,
|
id: node.id,
|
||||||
rect: node.getData(FlowNodeTransformData).bounds,
|
rect: node.getData(FlowNodeTransformData).bounds,
|
||||||
@ -367,7 +388,7 @@ export class WorkflowSnapService {
|
|||||||
if (nodes.length === 0) {
|
if (nodes.length === 0) {
|
||||||
return Rectangle.EMPTY;
|
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 {
|
private updateNodePositionWithOffset(params: { node: WorkflowNodeEntity; offset: IPoint }): void {
|
||||||
@ -379,7 +400,7 @@ export class WorkflowSnapService {
|
|||||||
};
|
};
|
||||||
if (node.collapsedChildren?.length > 0) {
|
if (node.collapsedChildren?.length > 0) {
|
||||||
// 嵌套情况下需将子节点 transform 设为 dirty
|
// 嵌套情况下需将子节点 transform 设为 dirty
|
||||||
node.collapsedChildren.forEach(childNode => {
|
node.collapsedChildren.forEach((childNode) => {
|
||||||
const childNodeTransformData =
|
const childNodeTransformData =
|
||||||
childNode.getData<FlowNodeTransformData>(FlowNodeTransformData);
|
childNode.getData<FlowNodeTransformData>(FlowNodeTransformData);
|
||||||
childNodeTransformData.fireChange();
|
childNodeTransformData.fireChange();
|
||||||
@ -451,7 +472,7 @@ export class WorkflowSnapService {
|
|||||||
const rightAlignX = alignRects.right[0].rect.left - alignSpacing.right;
|
const rightAlignX = alignRects.right[0].rect.left - alignSpacing.right;
|
||||||
const isAlignRight = isLessThanOrEqual(
|
const isAlignRight = isLessThanOrEqual(
|
||||||
Math.abs(targetRect.right - rightAlignX),
|
Math.abs(targetRect.right - rightAlignX),
|
||||||
alignThreshold,
|
alignThreshold
|
||||||
);
|
);
|
||||||
if (isAlignRight) {
|
if (isAlignRight) {
|
||||||
rightX = rightAlignX - targetRect.width;
|
rightX = rightAlignX - targetRect.width;
|
||||||
@ -463,7 +484,7 @@ export class WorkflowSnapService {
|
|||||||
const leftAlignX = alignRects.left[0].rect.right + alignSpacing.midHorizontal;
|
const leftAlignX = alignRects.left[0].rect.right + alignSpacing.midHorizontal;
|
||||||
const isAlignMidHorizontal = isLessThanOrEqual(
|
const isAlignMidHorizontal = isLessThanOrEqual(
|
||||||
Math.abs(targetRect.left - leftAlignX),
|
Math.abs(targetRect.left - leftAlignX),
|
||||||
alignThreshold,
|
alignThreshold
|
||||||
);
|
);
|
||||||
if (isAlignMidHorizontal) {
|
if (isAlignMidHorizontal) {
|
||||||
midX = leftAlignX;
|
midX = leftAlignX;
|
||||||
@ -475,7 +496,7 @@ export class WorkflowSnapService {
|
|||||||
const topAlignY = alignRects.top[0].rect.bottom + alignSpacing.midVertical;
|
const topAlignY = alignRects.top[0].rect.bottom + alignSpacing.midVertical;
|
||||||
const isAlignMidVertical = isLessThanOrEqual(
|
const isAlignMidVertical = isLessThanOrEqual(
|
||||||
Math.abs(targetRect.top - topAlignY),
|
Math.abs(targetRect.top - topAlignY),
|
||||||
alignThreshold,
|
alignThreshold
|
||||||
);
|
);
|
||||||
if (isAlignMidVertical) {
|
if (isAlignMidVertical) {
|
||||||
midY = topAlignY;
|
midY = topAlignY;
|
||||||
@ -552,7 +573,7 @@ export class WorkflowSnapService {
|
|||||||
const leftHorizontalRects: AlignRect[] = [];
|
const leftHorizontalRects: AlignRect[] = [];
|
||||||
const rightHorizontalRects: AlignRect[] = [];
|
const rightHorizontalRects: AlignRect[] = [];
|
||||||
|
|
||||||
snapNodeRects.forEach(snapNodeRect => {
|
snapNodeRects.forEach((snapNodeRect) => {
|
||||||
const nodeRect = snapNodeRect.rect;
|
const nodeRect = snapNodeRect.rect;
|
||||||
const { isVerticalIntersection, isHorizontalIntersection, isIntersection } =
|
const { isVerticalIntersection, isHorizontalIntersection, isIntersection } =
|
||||||
this.intersection(nodeRect, targetRect);
|
this.intersection(nodeRect, targetRect);
|
||||||
@ -612,7 +633,7 @@ export class WorkflowSnapService {
|
|||||||
}
|
}
|
||||||
const { isVerticalIntersection, isHorizontalIntersection, isIntersection } = this.intersection(
|
const { isVerticalIntersection, isHorizontalIntersection, isIntersection } = this.intersection(
|
||||||
rectA,
|
rectA,
|
||||||
rectB,
|
rectB
|
||||||
);
|
);
|
||||||
if (isIntersection) {
|
if (isIntersection) {
|
||||||
return;
|
return;
|
||||||
@ -620,13 +641,13 @@ export class WorkflowSnapService {
|
|||||||
if (isHorizontal && isHorizontalIntersection && !isVerticalIntersection) {
|
if (isHorizontal && isHorizontalIntersection && !isVerticalIntersection) {
|
||||||
const betweenSpacing = Math.min(
|
const betweenSpacing = Math.min(
|
||||||
Math.abs(rectA.left - rectB.right),
|
Math.abs(rectA.left - rectB.right),
|
||||||
Math.abs(rectA.right - rectB.left),
|
Math.abs(rectA.right - rectB.left)
|
||||||
);
|
);
|
||||||
return (betweenSpacing - targetRect.width) / 2;
|
return (betweenSpacing - targetRect.width) / 2;
|
||||||
} else if (!isHorizontal && isVerticalIntersection && !isHorizontalIntersection) {
|
} else if (!isHorizontal && isVerticalIntersection && !isHorizontalIntersection) {
|
||||||
const betweenSpacing = Math.min(
|
const betweenSpacing = Math.min(
|
||||||
Math.abs(rectA.top - rectB.bottom),
|
Math.abs(rectA.top - rectB.bottom),
|
||||||
Math.abs(rectA.bottom - rectB.top),
|
Math.abs(rectA.bottom - rectB.top)
|
||||||
);
|
);
|
||||||
return (betweenSpacing - targetRect.height) / 2;
|
return (betweenSpacing - targetRect.height) / 2;
|
||||||
}
|
}
|
||||||
@ -646,7 +667,7 @@ export class WorkflowSnapService {
|
|||||||
|
|
||||||
const { isVerticalIntersection, isHorizontalIntersection, isIntersection } = this.intersection(
|
const { isVerticalIntersection, isHorizontalIntersection, isIntersection } = this.intersection(
|
||||||
rectA,
|
rectA,
|
||||||
rectB,
|
rectB
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isIntersection) {
|
if (isIntersection) {
|
||||||
@ -663,7 +684,7 @@ export class WorkflowSnapService {
|
|||||||
|
|
||||||
private intersection(
|
private intersection(
|
||||||
rectA: Rectangle,
|
rectA: Rectangle,
|
||||||
rectB: Rectangle,
|
rectB: Rectangle
|
||||||
): {
|
): {
|
||||||
isHorizontalIntersection: boolean;
|
isHorizontalIntersection: boolean;
|
||||||
isVerticalIntersection: boolean;
|
isVerticalIntersection: boolean;
|
||||||
|
|||||||
@ -607,6 +607,12 @@
|
|||||||
"versionPolicyName": "publishPolicy",
|
"versionPolicyName": "publishPolicy",
|
||||||
"tags": ["level-1","team-flow"]
|
"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",
|
"packageName": "@flowgram.ai/group-plugin",
|
||||||
"projectFolder": "packages/plugins/group-plugin",
|
"projectFolder": "packages/plugins/group-plugin",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user