Merge pull request #6 from louisyoungx/feat/free-demo-node-panel

1. add node panel component
2. register node panel plugin
3. beautify minimap style
4. add node button access node panel
5. support add node through button on the line
This commit is contained in:
Louis Young 2025-02-28 11:48:42 +08:00 committed by GitHub
commit 0db1c612ed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 805 additions and 216 deletions

View File

@ -1,34 +1,22 @@
import { useState } from 'react';
import { Button } from '@douyinfe/semi-ui';
import { Popover } from '@douyinfe/semi-ui';
import { IconPlus } from '@douyinfe/semi-icons';
import { NodeList } from './node-list';
import { useAddNode } from './use-add-node';
export const AddNode = (props: { disabled: boolean }) => {
const [visible, setVisible] = useState(false);
const addNode = useAddNode();
return (
<Popover
visible={visible}
onVisibleChange={props.disabled ? () => {} : setVisible}
content={!props.disabled && <NodeList />}
placement="right"
trigger="click"
popupAlign={{ offset: [30, 0] }}
overlayStyle={{
padding: 0,
}}
>
<Button
icon={<IconPlus />}
color="highlight"
style={{ backgroundColor: 'rgba(171,181,255,0.3)', borderRadius: '8px' }}
disabled={props.disabled}
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
addNode(rect);
}}
>
Add Node
</Button>
</Popover>
);
};

View File

@ -0,0 +1,72 @@
import { useCallback } from 'react';
import { WorkflowNodePanelService } from '@flowgram.ai/free-node-panel-plugin';
import {
useService,
WorkflowDocument,
usePlayground,
getAntiOverlapPosition,
PositionSchema,
WorkflowNodeEntity,
WorkflowSelectService,
} from '@flowgram.ai/free-layout-editor';
const useGetPanelPosition = () => {
const playground = usePlayground();
return useCallback(
(targetBoundingRect: DOMRect): PositionSchema =>
playground.config.getPosFromMouseEvent({
clientX: targetBoundingRect.left + 64,
clientY: targetBoundingRect.top - 7,
}),
[playground]
);
};
const useSelectNode = () => {
const selectService = useService(WorkflowSelectService);
return useCallback(
(node?: WorkflowNodeEntity) => {
if (!node) {
return;
}
selectService.selectNode(node);
},
[selectService]
);
};
export const useAddNode = () => {
const workflowDocument = useService(WorkflowDocument);
const nodePanelService = useService<WorkflowNodePanelService>(WorkflowNodePanelService);
const playground = usePlayground();
const getPanelPosition = useGetPanelPosition();
const select = useSelectNode();
return useCallback(
async (targetBoundingRect: DOMRect): Promise<void> => {
const panelPosition = getPanelPosition(targetBoundingRect);
await nodePanelService.call({
panelPosition,
customPosition: ({ selectPosition }) => {
const nodeWidth = 360;
const nodePanelOffset = 150 / playground.config.zoom;
const customPositionX = panelPosition.x + nodeWidth / 2 + nodePanelOffset;
const customNodePosition = getAntiOverlapPosition(workflowDocument, {
x: customPositionX,
y: selectPosition.y,
});
return {
x: customNodePosition.x,
y: customNodePosition.y,
};
},
enableSelectPosition: true,
enableMultiAdd: true,
afterAddNode: select,
});
},
[getPanelPosition, nodePanelService, playground.config.zoom, workflowDocument, select]
);
};

View File

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

View File

@ -0,0 +1,26 @@
export const IconPlusCircle = () => (
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<g id="add">
<path
id="background"
fill="#ffffff"
fillRule="evenodd"
stroke="none"
d="M 24 12 C 24 5.372583 18.627417 0 12 0 C 5.372583 0 -0 5.372583 -0 12 C -0 18.627417 5.372583 24 12 24 C 18.627417 24 24 18.627417 24 12 Z"
/>
<path
id="content"
fill="currentColor"
fillRule="evenodd"
stroke="none"
d="M 22 12.005 C 22 6.482153 17.522848 2.004999 12 2.004999 C 6.477152 2.004999 2 6.482153 2 12.005 C 2 17.527847 6.477152 22.004999 12 22.004999 C 17.522848 22.004999 22 17.527847 22 12.005 Z"
/>
<path
id="cross"
fill="#ffffff"
stroke="none"
d="M 11.411996 16.411797 C 11.411996 16.736704 11.675362 17 12.00023 17 C 12.325109 17 12.588474 16.736704 12.588474 16.411797 L 12.588474 12.58826 L 16.41201 12.58826 C 16.736919 12.58826 17.000216 12.324894 17.000216 12.000015 C 17.000216 11.675147 16.736919 11.411781 16.41201 11.411781 L 12.588474 11.411781 L 12.588474 7.588234 C 12.588474 7.263367 12.325109 7 12.00023 7 C 11.675362 7 11.411996 7.263367 11.411996 7.588234 L 11.411996 11.411781 L 7.588449 11.411781 C 7.263581 11.411781 7.000215 11.675147 7.000215 12.000015 C 7.000215 12.324894 7.263581 12.58826 7.588449 12.58826 L 11.411996 12.58826 L 11.411996 16.411797 Z"
/>
</g>
</svg>
);

View File

@ -0,0 +1,8 @@
.line-add-button {
position: absolute;
transform: translate(-50%, -60%);
width: 24px;
height: 24px;
cursor: pointer;
color: inherit;
}

View File

@ -0,0 +1,53 @@
import { WorkflowNodePanelService } from '@flowgram.ai/free-node-panel-plugin';
import { LineRenderProps } from '@flowgram.ai/free-lines-plugin';
import { useService } from '@flowgram.ai/free-layout-editor';
import './index.less';
import { useVisible } from './use-visible';
import { IconPlusCircle } from './button';
export const LineAddButton = (props: LineRenderProps) => {
const { line, selected, color } = props;
const visible = useVisible({ line, selected, color });
const nodePanelService = useService<WorkflowNodePanelService>(WorkflowNodePanelService);
if (!visible) {
return <></>;
}
const { fromPort, toPort } = line;
return (
<div
className="line-add-button"
style={{
left: '50%',
top: '50%',
color,
}}
data-testid="sdk.workflow.canvas.line.add"
data-line-id={line.id}
onClick={async () => {
const node = await nodePanelService.call({
panelPosition: {
x: (line.position.from.x + line.position.to.x) / 2,
y: (line.position.from.y + line.position.to.y) / 2,
},
fromPort,
toPort,
enableBuildLine: true,
enableAutoOffset: true,
panelProps: {
enableScrollClose: true,
},
});
if (!node) {
return;
}
line.dispose();
}}
>
<IconPlusCircle />
</div>
);
};

View File

@ -0,0 +1,35 @@
import { LineColors, usePlayground, WorkflowLineEntity } from '@flowgram.ai/free-layout-editor';
import './index.less';
export const useVisible = (params: {
line: WorkflowLineEntity;
selected?: boolean;
color?: string;
}): boolean => {
const playground = usePlayground();
const { line, selected = false, color } = params;
if (line.disposed) {
// 在 dispose 后,再去获取 line.to | line.from 会导致错误创建端口
return false;
}
if (playground.config.readonly) {
return false;
}
if (!selected && color !== LineColors.HOVER) {
return false;
}
if (
line.fromPort.portID === 'loop-output-to-function' &&
line.toPort?.portID === 'loop-function-input'
) {
return false;
}
if (
line.fromPort.portID === 'batch-output-to-function' &&
line.toPort?.portID === 'batch-function-input'
) {
return false;
}
return true;
};

View File

@ -0,0 +1,48 @@
import { FC } from 'react';
import { NodePanelRenderProps } from '@flowgram.ai/free-node-panel-plugin';
import { Popover } from '@douyinfe/semi-ui';
import { NodePlaceholder } from './node-placeholder';
import { NodeList } from './node-list';
export const NodePanel: FC<NodePanelRenderProps> = (props) => {
const { onSelect, position, onClose, panelProps } = props;
const { enableNodePlaceholder } = panelProps;
return (
<Popover
trigger="click"
visible={true}
onVisibleChange={(v) => (v ? null : onClose())}
content={<NodeList onSelect={onSelect} />}
placement="right"
popupAlign={{ offset: [30, 0] }}
overlayStyle={{
padding: 0,
}}
>
<div
style={
enableNodePlaceholder
? {
position: 'absolute',
top: position.y - 61.5,
left: position.x,
width: 300,
height: 123,
}
: {
position: 'absolute',
top: position.y,
left: position.x,
width: 0,
height: 0,
}
}
>
{enableNodePlaceholder && <NodePlaceholder />}
</div>
</Popover>
);
};

View File

@ -1,4 +1,7 @@
import React, { FC } from 'react';
import styled from 'styled-components';
import { NodePanelRenderProps } from '@flowgram.ai/free-node-panel-plugin';
import { useClientContext } from '@flowgram.ai/free-layout-editor';
import { FlowNodeRegistry } from '../../typings';
@ -24,7 +27,14 @@ const NodeLabel = styled.div`
margin-left: 10px;
`;
function Node(props: { label: string; icon: JSX.Element; onClick: () => void; disabled: boolean }) {
interface NodeProps {
label: string;
icon: JSX.Element;
onClick: React.MouseEventHandler<HTMLDivElement>;
disabled: boolean;
}
function Node(props: NodeProps) {
return (
<NodeWrap
onClick={props.disabled ? undefined : props.onClick}
@ -44,16 +54,18 @@ const NodesWrap = styled.div`
}
`;
export function NodeList() {
const context = useClientContext();
const clientContext = useClientContext();
const handleClick = (registry: FlowNodeRegistry) => {
const nodeJSON = registry.onAdd?.(context);
if (nodeJSON) {
const node = clientContext.document.createWorkflowNode(nodeJSON);
// Select New Node
clientContext.selection.selection = [node];
interface NodeListProps {
onSelect: NodePanelRenderProps['onSelect'];
}
export const NodeList: FC<NodeListProps> = (props) => {
const { onSelect } = props;
const context = useClientContext();
const handleClick = (e: React.MouseEvent, registry: FlowNodeRegistry) => {
onSelect({
nodeType: registry.type as string,
selectEvent: e,
});
};
return (
<NodesWrap style={{ width: 80 * 2 + 20 }}>
@ -63,9 +75,9 @@ export function NodeList() {
disabled={!(registry.canAdd?.(context) ?? true)}
icon={<img style={{ width: 10, height: 10, borderRadius: 4 }} src={registry.info.icon} />}
label={registry.type as string}
onClick={() => handleClick(registry)}
onClick={(e) => handleClick(e, registry)}
/>
))}
</NodesWrap>
);
}
};

View File

@ -0,0 +1,48 @@
export const NodePlaceholder = () => (
<svg width="300" height="123" viewBox="0 0 300 123" xmlns="http://www.w3.org/2000/svg">
<g id="g1">
<path
id="path1"
fill="#ffffff"
stroke="none"
d="M 0 13.795815 C 0 6.856102 5.625739 1.23037 12.565445 1.23037 L 287.43454 1.23037 C 294.37381 1.23037 300 6.856102 300 13.795815 L 300 109.60733 C 300 116.547066 294.37381 122.172775 287.43454 122.172775 L 12.565445 122.172775 C 5.625754 122.172775 0 116.547066 0 109.60733 L 0 13.795815 Z"
/>
<path
id="path2"
fill="none"
stroke="#060709"
strokeWidth="1.570681"
strokeOpacity="0.1"
d="M 0.78534 13.795815 C 0.78534 7.289825 6.059466 2.015709 12.565445 2.015709 L 287.43454 2.015709 C 293.940308 2.015709 299.214661 7.289825 299.214661 13.795815 L 299.214661 109.60733 C 299.214661 116.113243 293.940308 121.387436 287.43454 121.387436 L 12.565445 121.387436 C 6.059482 121.387436 0.78534 116.113243 0.78534 109.60733 L 0.78534 13.795815 Z"
/>
<path
id="path3"
fill="#d9d9d9"
stroke="none"
opacity="0.32"
d="M 51.832462 70.340317 C 51.832462 66.003036 55.348591 62.486912 59.685863 62.486912 L 273.298431 62.486912 C 277.635071 62.486912 281.151825 66.003036 281.151825 70.340317 C 281.151825 74.677589 277.635071 78.193718 273.298431 78.193718 L 59.685863 78.193718 C 55.348591 78.193718 51.832462 74.677589 51.832462 70.340317 Z"
/>
<path
id="path4"
fill="#d9d9d9"
stroke="none"
opacity="0.32"
d="M 51.832462 29.502617 C 51.832462 25.165344 55.348591 21.649216 59.685863 21.649216 L 138.219894 21.649216 C 142.557175 21.649216 146.073303 25.165344 146.073303 29.502617 C 146.073303 33.83989 142.557175 37.356018 138.219894 37.356018 L 59.685863 37.356018 C 55.348591 37.356018 51.832462 33.83989 51.832462 29.502617 Z"
/>
<path
id="path5"
fill="#d9d9d9"
stroke="none"
opacity="0.32"
d="M 14.136126 29.502617 C 14.136126 25.165344 17.65225 21.649216 21.989529 21.649216 L 36.125656 21.649216 C 40.462933 21.649216 43.979057 25.165344 43.979057 29.502617 C 43.979057 33.83989 40.462933 37.356018 36.125656 37.356018 L 21.989529 37.356018 C 17.65225 37.356018 14.136126 33.83989 14.136126 29.502617 Z"
/>
<path
id="path6"
fill="#d9d9d9"
stroke="none"
opacity="0.32"
d="M 51.832462 93.900528 C 51.832462 89.563248 55.348591 86.047119 59.685863 86.047119 L 193.19371 86.047119 C 197.530365 86.047119 201.047119 89.563248 201.047119 93.900528 C 201.047119 98.237801 197.530365 101.753929 193.19371 101.753929 L 59.685863 101.753929 C 55.348591 101.753929 51.832462 98.237801 51.832462 93.900528 Z"
/>
</g>
</svg>
);

View File

@ -21,7 +21,7 @@ export const DemoTools = () => {
const { history, playground } = useClientContext();
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const [minimapVisible, setMinimapVisible] = useState(false);
const [minimapVisible, setMinimapVisible] = useState(true);
useEffect(() => {
const disposable = history.undoRedoService.onChange(() => {
setCanUndo(history.canUndo());

View File

@ -3,6 +3,8 @@ import { useMemo } from 'react';
import { createMinimapPlugin } from '@flowgram.ai/minimap-plugin';
import { createFreeSnapPlugin } from '@flowgram.ai/free-snap-plugin';
import { createFreeNodePanelPlugin } from '@flowgram.ai/free-node-panel-plugin';
import { createFreeLinesPlugin } from '@flowgram.ai/free-lines-plugin';
import { FreeLayoutProps } from '@flowgram.ai/free-layout-editor';
import { FlowNodeRegistry, FlowDocumentJSON } from '../typings';
@ -10,7 +12,7 @@ import { shortcuts } from '../shortcuts';
import { createVariablePlugin } from '../plugins';
import { defaultFormMeta } from '../nodes/default-form-meta';
import { SelectorBoxPopover } from '../components/selector-box-popover';
import { BaseNode } from '../components';
import { BaseNode, LineAddButton, NodePanel } from '../components';
export function useEditorProps(
initialData: FlowDocumentJSON,
@ -123,6 +125,13 @@ export function useEditorProps(
console.log('---- Playground Dispose ----');
},
plugins: () => [
/**
* Line render plugin
* 线
*/
createFreeLinesPlugin({
renderInsideLine: LineAddButton,
}),
/**
* Minimap plugin
*
@ -133,18 +142,18 @@ export function useEditorProps(
canvasWidth: 182,
canvasHeight: 102,
canvasPadding: 50,
canvasBackground: 'rgba(245, 245, 245, 1)',
canvasBackground: 'rgba(242, 243, 245, 1)',
canvasBorderRadius: 10,
viewportBackground: 'rgba(235, 235, 235, 1)',
viewportBackground: 'rgba(255, 255, 255, 1)',
viewportBorderRadius: 4,
viewportBorderColor: 'rgba(201, 201, 201, 1)',
viewportBorderColor: 'rgba(6, 7, 9, 0.10)',
viewportBorderWidth: 1,
viewportBorderDashLength: 2,
nodeColor: 'rgba(255, 255, 255, 1)',
viewportBorderDashLength: undefined,
nodeColor: 'rgba(0, 0, 0, 0.10)',
nodeBorderRadius: 2,
nodeBorderWidth: 0.145,
nodeBorderColor: 'rgba(6, 7, 9, 0.10)',
overlayColor: 'rgba(255, 255, 255, 0)',
overlayColor: 'rgba(255, 255, 255, 0.55)',
},
inactiveDebounceTime: 1,
}),
@ -164,6 +173,9 @@ export function useEditorProps(
alignLineWidth: 1,
alignCrossWidth: 8,
}),
createFreeNodePanelPlugin({
renderer: NodePanel,
}),
],
}),
[]

View File

@ -15,6 +15,10 @@ export const ConditionNodeRegistry: FlowNodeRegistry = {
defaultPorts: [{ type: 'input' }],
// Condition Outputs use dynamic port
useDynamicPort: true,
size: {
width: 360,
height: 305,
},
},
formMeta,
onAdd() {

View File

@ -8,6 +8,10 @@ export const EndNodeRegistry: FlowNodeRegistry = {
deleteDisable: true,
copyDisable: true,
defaultPorts: [{ type: 'input' }],
size: {
width: 360,
height: 211,
},
},
info: {
icon: iconEnd,

View File

@ -11,6 +11,12 @@ export const LLMNodeRegistry: FlowNodeRegistry = {
description:
'Call the large language model and use variables and prompt words to generate responses.',
},
meta: {
size: {
width: 360,
height: 94,
},
},
onAdd() {
return {
id: `llm_${nanoid(5)}`,

View File

@ -9,6 +9,10 @@ export const StartNodeRegistry: FlowNodeRegistry = {
deleteDisable: true,
copyDisable: true,
defaultPorts: [{ type: 'output' }],
size: {
width: 360,
height: 211,
},
},
info: {
icon: iconStart,

File diff suppressed because it is too large Load Diff

View File

@ -198,9 +198,7 @@ export function createFreeLayoutPreset(
/**
* 线
*/
createFreeLinesPlugin({
renderElement: renderElement as FreeLinesPluginOptions['renderElement'],
}),
createFreeLinesPlugin({}),
/**
* hover
*/

View File

@ -28,6 +28,7 @@
"dependencies": {
"@flowgram.ai/core": "workspace:*",
"@flowgram.ai/free-layout-core": "workspace:*",
"@flowgram.ai/free-stack-plugin": "workspace:*",
"@flowgram.ai/renderer": "workspace:*",
"@flowgram.ai/utils": "workspace:*",
"bezier-js": "^6.1.4",

View File

@ -9,13 +9,6 @@ export const createFreeLinesPlugin = definePluginCreator({
onInit: (ctx: PluginContext, opts: FreeLinesPluginOptions) => {
ctx.playground.registerLayer(WorkflowLinesLayer, {
...opts,
renderElement: () => {
if (typeof opts.renderElement === 'function') {
return opts.renderElement(ctx);
} else {
return opts.renderElement;
}
},
});
},
onReady: (ctx: PluginContext, opts: FreeLinesPluginOptions) => {

View File

@ -3,6 +3,7 @@ import React, { ReactNode, useLayoutEffect, useState } from 'react';
import { inject, injectable } from 'inversify';
import { domUtils } from '@flowgram.ai/utils';
import { StackingContextManager } from '@flowgram.ai/free-stack-plugin';
import {
nanoid,
WorkflowDocument,
@ -35,6 +36,8 @@ export class WorkflowLinesLayer extends Layer<LinesLayerOptions> {
@inject(WorkflowDocument) protected workflowDocument: WorkflowDocument;
@inject(StackingContextManager) protected stackContext: StackingContextManager;
private layerID = nanoid();
private mountedLines: Map<
@ -166,14 +169,6 @@ export class WorkflowLinesLayer extends Layer<LinesLayerOptions> {
}
private get renderElement(): HTMLElement {
if (typeof this.options.renderElement === 'function') {
const element = this.options.renderElement();
if (element) {
return element;
}
} else if (typeof this.options.renderElement !== 'undefined') {
return this.options.renderElement as HTMLElement;
}
return this.node;
return this.stackContext.node;
}
}

View File

@ -5,7 +5,6 @@ import type {
WorkflowLineRenderContributionFactory,
} from '@flowgram.ai/free-layout-core';
import { LineRenderType } from '@flowgram.ai/free-layout-core';
import type { PluginContext } from '@flowgram.ai/core';
export interface LineRenderProps {
key: string;
@ -19,12 +18,10 @@ export interface LineRenderProps {
}
export interface LinesLayerOptions {
renderElement?: HTMLElement | (() => HTMLElement | undefined);
renderInsideLine?: FC<LineRenderProps>;
}
export interface FreeLinesPluginOptions extends Omit<LinesLayerOptions, 'renderElement'> {
renderElement?: HTMLElement | ((ctx: PluginContext) => HTMLElement | undefined);
export interface FreeLinesPluginOptions extends LinesLayerOptions {
contributions?: WorkflowLineRenderContributionFactory[];
defaultLineType?: LineRenderType;
}