mirror of
https://gitee.com/ByteDance/flowgram.ai.git
synced 2025-07-07 17:43:29 +08:00
feat: demo support sidebar
This commit is contained in:
parent
3397925e4a
commit
b1bb774238
@ -1,9 +1,9 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useContext } from 'react';
|
||||
|
||||
import { FlowNodeEntity, useNodeRender } from '@flowgram.ai/fixed-layout-editor';
|
||||
import { ConfigProvider } from '@douyinfe/semi-ui';
|
||||
|
||||
import { NodeRenderContext } from '../../context';
|
||||
import { NodeRenderContext, SidebarContext } from '../../context';
|
||||
import { BaseNodeStyle, ErrorIcon } from './styles';
|
||||
|
||||
export const BaseNode = ({ node }: { node: FlowNodeEntity }) => {
|
||||
@ -24,6 +24,11 @@ export const BaseNode = ({ node }: { node: FlowNodeEntity }) => {
|
||||
*/
|
||||
const getPopupContainer = useCallback(() => node.renderData.node || document.body, []);
|
||||
|
||||
/**
|
||||
* Sidebar control
|
||||
*/
|
||||
const sidebar = useContext(SidebarContext);
|
||||
|
||||
return (
|
||||
<ConfigProvider getPopupContainer={getPopupContainer}>
|
||||
{form?.state.invalid && <ErrorIcon />}
|
||||
@ -34,6 +39,13 @@ export const BaseNode = ({ node }: { node: FlowNodeEntity }) => {
|
||||
**/
|
||||
onMouseEnter={nodeRender.onMouseEnter}
|
||||
onMouseLeave={nodeRender.onMouseLeave}
|
||||
className={nodeRender.activated ? 'activated' : ''}
|
||||
onClick={() => {
|
||||
if (nodeRender.dragging) {
|
||||
return;
|
||||
}
|
||||
sidebar.setNodeRender(nodeRender);
|
||||
}}
|
||||
style={{
|
||||
/**
|
||||
* Lets you precisely control the style of branch nodes
|
||||
|
||||
@ -12,8 +12,9 @@ export const BaseNodeStyle = styled.div`
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
width: 360px;
|
||||
|
||||
transition: all 0.3s ease;
|
||||
&.activated {
|
||||
border: 1px solid #4e40e5;
|
||||
}
|
||||
`;
|
||||
|
||||
export const ErrorIcon = () => (
|
||||
|
||||
2
apps/demo-fixed-layout/src/components/sidebar/index.tsx
Normal file
2
apps/demo-fixed-layout/src/components/sidebar/index.tsx
Normal file
@ -0,0 +1,2 @@
|
||||
export { SidebarProvider } from './sidebar-provider';
|
||||
export { SidebarRenderer } from './sidebar-renderer';
|
||||
@ -0,0 +1,14 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { NodeRenderReturnType } from '@flowgram.ai/fixed-layout-editor';
|
||||
|
||||
import { SidebarContext } from '../../context';
|
||||
|
||||
export function SidebarProvider({ children }: { children: React.ReactNode }) {
|
||||
const [nodeRender, setNodeRender] = useState<NodeRenderReturnType | undefined>();
|
||||
return (
|
||||
<SidebarContext.Provider value={{ visible: !!nodeRender, nodeRender, setNodeRender }}>
|
||||
{children}
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,75 @@
|
||||
import { useCallback, useContext, useEffect } from 'react';
|
||||
|
||||
import {
|
||||
PlaygroundEntityContext,
|
||||
useRefresh,
|
||||
useClientContext,
|
||||
} from '@flowgram.ai/fixed-layout-editor';
|
||||
import { SideSheet } from '@douyinfe/semi-ui';
|
||||
|
||||
import { SidebarContext, IsSidebarContext, NodeRenderContext } from '../../context';
|
||||
|
||||
export const SidebarRenderer = () => {
|
||||
const { nodeRender, setNodeRender } = useContext(SidebarContext);
|
||||
const { selection, playground } = useClientContext();
|
||||
const refresh = useRefresh();
|
||||
const handleClose = useCallback(() => {
|
||||
setNodeRender(undefined);
|
||||
}, []);
|
||||
/**
|
||||
* Listen readonly
|
||||
*/
|
||||
useEffect(() => {
|
||||
const disposable = playground.config.onReadonlyOrDisabledChange(() => refresh());
|
||||
return () => disposable.dispose();
|
||||
}, [playground]);
|
||||
/**
|
||||
* Listen selection
|
||||
*/
|
||||
useEffect(() => {
|
||||
const toDispose = selection.onSelectionChanged(() => {
|
||||
/**
|
||||
* 如果没有选中任何节点,则自动关闭侧边栏
|
||||
* If no node is selected, the sidebar is automatically closed
|
||||
*/
|
||||
if (selection.selection.length === 0) {
|
||||
handleClose();
|
||||
} else if (selection.selection.length === 1 && selection.selection[0] !== nodeRender?.node) {
|
||||
handleClose();
|
||||
}
|
||||
});
|
||||
return () => toDispose.dispose();
|
||||
}, [selection, handleClose]);
|
||||
/**
|
||||
* Close when node disposed
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (nodeRender) {
|
||||
const toDispose = nodeRender.node.onDispose(() => {
|
||||
setNodeRender(undefined);
|
||||
});
|
||||
return () => toDispose.dispose();
|
||||
}
|
||||
return () => {};
|
||||
}, [nodeRender]);
|
||||
|
||||
if (playground.config.readonly) {
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* Add key to rerender the sidebar when the node changes
|
||||
*/
|
||||
const content = nodeRender ? (
|
||||
<PlaygroundEntityContext.Provider key={nodeRender.node.id} value={nodeRender.node}>
|
||||
<NodeRenderContext.Provider value={nodeRender}>
|
||||
{nodeRender.form?.render()}
|
||||
</NodeRenderContext.Provider>
|
||||
</PlaygroundEntityContext.Provider>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<SideSheet mask={false} visible={!!nodeRender} onCancel={handleClose}>
|
||||
<IsSidebarContext.Provider value={true}>{content}</IsSidebarContext.Provider>
|
||||
</SideSheet>
|
||||
);
|
||||
};
|
||||
@ -37,7 +37,7 @@ export const DemoTools = () => {
|
||||
<IconButton
|
||||
theme="borderless"
|
||||
icon={<IconUndo />}
|
||||
disabled={!tools.canUndo}
|
||||
disabled={!tools.canUndo || playground.config.readonly}
|
||||
onClick={() => tools.undo()}
|
||||
/>
|
||||
</Tooltip>
|
||||
@ -45,7 +45,7 @@ export const DemoTools = () => {
|
||||
<IconButton
|
||||
theme="borderless"
|
||||
icon={<IconRedo />}
|
||||
disabled={!tools.canRedo}
|
||||
disabled={!tools.canRedo || playground.config.readonly}
|
||||
onClick={() => tools.redo()}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
@ -1 +1,2 @@
|
||||
export { NodeRenderContext } from './node-render-context';
|
||||
export { SidebarContext, IsSidebarContext } from './sidebar-context';
|
||||
|
||||
11
apps/demo-fixed-layout/src/context/sidebar-context.ts
Normal file
11
apps/demo-fixed-layout/src/context/sidebar-context.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
import { NodeRenderReturnType } from '@flowgram.ai/fixed-layout-editor';
|
||||
|
||||
export const SidebarContext = React.createContext<{
|
||||
visible: boolean;
|
||||
nodeRender?: NodeRenderReturnType;
|
||||
setNodeRender: (node: NodeRenderReturnType | undefined) => void;
|
||||
}>({ visible: false, setNodeRender: () => {} });
|
||||
|
||||
export const IsSidebarContext = React.createContext<boolean>(false);
|
||||
@ -3,6 +3,7 @@ import { EditorRenderer, FixedLayoutEditorProvider } from '@flowgram.ai/fixed-la
|
||||
import { FlowNodeRegistries } from './nodes';
|
||||
import { initialData } from './initial-data';
|
||||
import { useEditorProps } from './hooks/use-editor-props';
|
||||
import { SidebarProvider, SidebarRenderer } from './components/sidebar';
|
||||
import { DemoTools } from './components';
|
||||
|
||||
import '@flowgram.ai/fixed-layout-editor/index.css';
|
||||
@ -16,8 +17,11 @@ export const Editor = () => {
|
||||
return (
|
||||
<div className="doc-feature-overview">
|
||||
<FixedLayoutEditorProvider {...editorProps}>
|
||||
<EditorRenderer />
|
||||
<DemoTools />
|
||||
<SidebarProvider>
|
||||
<EditorRenderer />
|
||||
<DemoTools />
|
||||
<SidebarRenderer />
|
||||
</SidebarProvider>
|
||||
</FixedLayoutEditorProvider>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import React, { useContext } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { FlowNodeRegistry } from '@flowgram.ai/fixed-layout-editor';
|
||||
|
||||
import { NodeRenderContext } from '../../context';
|
||||
import { useIsSidebar, useNodeRenderContext } from '../../hooks';
|
||||
import { FormTitleDescription, FormWrapper } from './styles';
|
||||
|
||||
/**
|
||||
@ -10,13 +10,14 @@ import { FormTitleDescription, FormWrapper } from './styles';
|
||||
* @constructor
|
||||
*/
|
||||
export function FormContent(props: { children?: React.ReactNode }) {
|
||||
const { expanded, node } = useContext(NodeRenderContext);
|
||||
const { node, expanded } = useNodeRenderContext();
|
||||
const isSidebar = useIsSidebar();
|
||||
const registry = node.getNodeRegistry<FlowNodeRegistry>();
|
||||
return (
|
||||
<FormWrapper>
|
||||
{expanded ? (
|
||||
<>
|
||||
<FormTitleDescription>{registry.info?.description}</FormTitleDescription>
|
||||
{isSidebar && <FormTitleDescription>{registry.info?.description}</FormTitleDescription>}
|
||||
{props.children}
|
||||
</>
|
||||
) : undefined}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useContext } from 'react';
|
||||
import { useContext, useCallback } from 'react';
|
||||
|
||||
import { Field, FieldRenderProps, useClientContext } from '@flowgram.ai/fixed-layout-editor';
|
||||
import { IconButton, Dropdown, Typography, Button } from '@douyinfe/semi-ui';
|
||||
@ -8,6 +8,7 @@ import { IconMore } from '@douyinfe/semi-icons';
|
||||
import { Feedback } from '../feedback';
|
||||
import { FlowNodeRegistry } from '../../typings';
|
||||
import { FlowCommandId } from '../../shortcuts/constants';
|
||||
import { useIsSidebar } from '../../hooks';
|
||||
import { NodeRenderContext } from '../../context';
|
||||
import { getIcon } from './utils';
|
||||
import { Header, Operators, Title } from './styles';
|
||||
@ -18,16 +19,29 @@ function DropdownContent() {
|
||||
const { node, deleteNode } = useContext(NodeRenderContext);
|
||||
const clientContext = useClientContext();
|
||||
const registry = node.getNodeRegistry<FlowNodeRegistry>();
|
||||
const handleCopy = () => {
|
||||
clientContext.playground.commandService.executeCommand(FlowCommandId.COPY, node);
|
||||
};
|
||||
const handleCopy = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
clientContext.playground.commandService.executeCommand(FlowCommandId.COPY, node);
|
||||
e.stopPropagation(); // Disable clicking prevents the sidebar from opening
|
||||
},
|
||||
[clientContext, node]
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
deleteNode();
|
||||
e.stopPropagation(); // Disable clicking prevents the sidebar from opening
|
||||
},
|
||||
[clientContext, node]
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item onClick={handleCopy} disabled={registry.meta!.copyDisable === true}>
|
||||
Copy
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
onClick={deleteNode}
|
||||
onClick={handleDelete}
|
||||
disabled={!!(registry.canDelete?.(clientContext, node) || registry.meta!.deleteDisable)}
|
||||
>
|
||||
Delete
|
||||
@ -39,12 +53,18 @@ function DropdownContent() {
|
||||
export function FormHeader() {
|
||||
const { node, expanded, startDrag, toggleExpand, readonly } = useContext(NodeRenderContext);
|
||||
|
||||
const isSidebar = useIsSidebar();
|
||||
const handleExpand = (e: React.MouseEvent) => {
|
||||
toggleExpand();
|
||||
e.stopPropagation(); // Disable clicking prevents the sidebar from opening
|
||||
};
|
||||
|
||||
return (
|
||||
<Header
|
||||
onMouseDown={(e) => {
|
||||
// trigger drag node
|
||||
startDrag(e);
|
||||
e.stopPropagation();
|
||||
// e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{getIcon(node)}
|
||||
@ -58,13 +78,15 @@ export function FormHeader() {
|
||||
)}
|
||||
</Field>
|
||||
</Title>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={expanded ? <IconSmallTriangleDown /> : <IconSmallTriangleLeft />}
|
||||
size="small"
|
||||
theme="borderless"
|
||||
onClick={toggleExpand}
|
||||
/>
|
||||
{node.renderData.expandable && !isSidebar && (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={expanded ? <IconSmallTriangleDown /> : <IconSmallTriangleLeft />}
|
||||
size="small"
|
||||
theme="borderless"
|
||||
onClick={handleExpand}
|
||||
/>
|
||||
)}
|
||||
{readonly ? undefined : (
|
||||
<Operators>
|
||||
<Dropdown trigger="hover" position="bottomRight" render={<DropdownContent />}>
|
||||
|
||||
@ -1,15 +1,13 @@
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { Field } from '@flowgram.ai/fixed-layout-editor';
|
||||
|
||||
import { FxExpression } from '../fx-expression';
|
||||
import { FormItem } from '../form-item';
|
||||
import { Feedback } from '../feedback';
|
||||
import { JsonSchema } from '../../typings';
|
||||
import { NodeRenderContext } from '../../context';
|
||||
import { useIsSidebar } from '../../hooks';
|
||||
|
||||
export function FormInputs() {
|
||||
const { readonly } = useContext(NodeRenderContext);
|
||||
const readonly = !useIsSidebar();
|
||||
return (
|
||||
<Field<JsonSchema> name="inputs">
|
||||
{({ field: inputsField }) => {
|
||||
@ -31,7 +29,7 @@ export function FormInputs() {
|
||||
<FxExpression
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
disabled={readonly}
|
||||
readonly={readonly}
|
||||
hasError={Object.keys(fieldState?.errors || {}).length > 0}
|
||||
/>
|
||||
<Feedback errors={fieldState?.errors} />
|
||||
|
||||
@ -2,9 +2,14 @@ import { Field } from '@flowgram.ai/fixed-layout-editor';
|
||||
|
||||
import { TypeTag } from '../type-tag';
|
||||
import { JsonSchema } from '../../typings';
|
||||
import { useIsSidebar } from '../../hooks';
|
||||
import { FormOutputsContainer } from './styles';
|
||||
|
||||
export function FormOutputs() {
|
||||
const isSidebar = useIsSidebar();
|
||||
if (isSidebar) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Field<JsonSchema> name={'outputs'}>
|
||||
{({ field }) => {
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import { type SVGProps } from 'react';
|
||||
import React, { type SVGProps } from 'react';
|
||||
|
||||
import { Input, Button } from '@douyinfe/semi-ui';
|
||||
|
||||
import { FlowValueSchema, FlowRefValueSchema } from '../../typings';
|
||||
import { ValueDisplay } from '../value-display';
|
||||
import { FlowRefValueSchema, FlowLiteralValueSchema } from '../../typings';
|
||||
import { VariableSelector } from '../../plugins/sync-variable-plugin/variable-selector';
|
||||
|
||||
export function FxIcon(props: SVGProps<SVGSVGElement>) {
|
||||
@ -18,17 +19,44 @@ export function FxIcon(props: SVGProps<SVGSVGElement>) {
|
||||
);
|
||||
}
|
||||
|
||||
function InputWrap({
|
||||
value,
|
||||
onChange,
|
||||
readonly,
|
||||
hasError,
|
||||
style,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
readonly?: boolean;
|
||||
hasError?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
if (readonly) {
|
||||
return <ValueDisplay value={value} hasError={hasError} />;
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
value={value as string}
|
||||
onChange={onChange}
|
||||
validateStatus={hasError ? 'error' : undefined}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export interface FxExpressionProps {
|
||||
value?: FlowValueSchema;
|
||||
onChange: (value: FlowValueSchema) => void;
|
||||
value?: FlowLiteralValueSchema | FlowRefValueSchema;
|
||||
onChange: (value: FlowLiteralValueSchema | FlowRefValueSchema) => void;
|
||||
literal?: boolean;
|
||||
hasError?: boolean;
|
||||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function FxExpression(props: FxExpressionProps) {
|
||||
const { value, onChange, disabled, literal } = props;
|
||||
if (literal) return <Input value={value as string} onChange={onChange} disabled={disabled} />;
|
||||
const { value, onChange, readonly, literal, icon } = props;
|
||||
if (literal) return <InputWrap value={value as string} onChange={onChange} readonly={readonly} />;
|
||||
const isExpression = typeof value === 'object' && value.type === 'expression';
|
||||
const toggleExpression = () => {
|
||||
if (isExpression) {
|
||||
@ -38,24 +66,26 @@ export function FxExpression(props: FxExpressionProps) {
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div style={{ display: 'flex' }}>
|
||||
<div style={{ display: 'flex', maxWidth: 300 }}>
|
||||
{isExpression ? (
|
||||
<VariableSelector
|
||||
value={value.content}
|
||||
hasError={props.hasError}
|
||||
style={{ flexGrow: 1 }}
|
||||
onChange={(v) => onChange({ type: 'expression', content: v })}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
<InputWrap
|
||||
value={value as string}
|
||||
onChange={onChange}
|
||||
validateStatus={props.hasError ? 'error' : undefined}
|
||||
disabled={disabled}
|
||||
hasError={props.hasError}
|
||||
readonly={readonly}
|
||||
style={{ flexGrow: 1, outline: props.hasError ? '1px solid red' : undefined }}
|
||||
/>
|
||||
)}
|
||||
{!disabled && <Button theme="borderless" icon={<FxIcon />} onClick={toggleExpression} />}
|
||||
{!readonly &&
|
||||
(icon || <Button theme="borderless" icon={<FxIcon />} onClick={toggleExpression} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -6,3 +6,4 @@ export * from './form-header';
|
||||
export * from './form-item';
|
||||
export * from './type-tag';
|
||||
export * from './properties-edit';
|
||||
export * from './value-display';
|
||||
|
||||
@ -0,0 +1,17 @@
|
||||
// import { TypeTag } from '../type-tag'
|
||||
import { ValueDisplayStyle } from './styles';
|
||||
|
||||
export interface ValueDisplayProps {
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
hasError?: boolean;
|
||||
}
|
||||
|
||||
export const ValueDisplay: React.FC<ValueDisplayProps> = (props) => (
|
||||
<ValueDisplayStyle className={props.hasError ? 'has-error' : ''}>
|
||||
{props.value}
|
||||
{props.value === undefined || props.value === '' ? (
|
||||
<span style={{ color: 'var(--semi-color-text-2)' }}>{props.placeholder || '--'}</span>
|
||||
) : null}
|
||||
</ValueDisplayStyle>
|
||||
);
|
||||
@ -0,0 +1,15 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const ValueDisplayStyle = styled.div`
|
||||
background-color: var(--semi-color-fill-0);
|
||||
border-radius: var(--semi-border-radius-small);
|
||||
padding-left: 12px;
|
||||
width: 100%;
|
||||
min-height: 24px;
|
||||
line-height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
&.has-error {
|
||||
outline: red solid 1px;
|
||||
}
|
||||
`;
|
||||
@ -1 +1,3 @@
|
||||
export { useEditorProps } from './use-editor-props';
|
||||
export { useNodeRenderContext } from './use-node-render-context';
|
||||
export { useIsSidebar } from './use-is-sidebar';
|
||||
|
||||
7
apps/demo-fixed-layout/src/hooks/use-is-sidebar.ts
Normal file
7
apps/demo-fixed-layout/src/hooks/use-is-sidebar.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { IsSidebarContext } from '../context';
|
||||
|
||||
export function useIsSidebar() {
|
||||
return useContext(IsSidebarContext);
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { NodeRenderContext } from '../context';
|
||||
|
||||
export function useNodeRenderContext() {
|
||||
return useContext(NodeRenderContext);
|
||||
}
|
||||
@ -12,6 +12,9 @@ export const ConditionNodeRegistry: FlowNodeRegistry = {
|
||||
description:
|
||||
'Connect multiple downstream branches. Only the corresponding branch will be executed if the set conditions are met.',
|
||||
},
|
||||
meta: {
|
||||
expandable: false, // disable expanded
|
||||
},
|
||||
onAdd() {
|
||||
return {
|
||||
id: `condition_${nanoid(5)}`,
|
||||
|
||||
@ -7,27 +7,41 @@ import {
|
||||
} from '@flowgram.ai/fixed-layout-editor';
|
||||
|
||||
import { FlowNodeJSON, JsonSchema } from '../../typings';
|
||||
import { useIsSidebar } from '../../hooks';
|
||||
import { FormHeader, FormContent, FormOutputs, PropertiesEdit } from '../../form-components';
|
||||
|
||||
export const renderForm = ({ form }: FormRenderProps<FlowNodeJSON['data']>) => (
|
||||
<>
|
||||
<FormHeader />
|
||||
<FormContent>
|
||||
<Field
|
||||
name="outputs.properties"
|
||||
render={({
|
||||
field: { value, onChange },
|
||||
fieldState,
|
||||
}: FieldRenderProps<Record<string, JsonSchema>>) => (
|
||||
<>
|
||||
<PropertiesEdit value={value} onChange={onChange} useFx={true} />
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<FormOutputs />
|
||||
</FormContent>
|
||||
</>
|
||||
);
|
||||
export const renderForm = ({ form }: FormRenderProps<FlowNodeJSON['data']>) => {
|
||||
const isSidebar = useIsSidebar();
|
||||
if (isSidebar) {
|
||||
return (
|
||||
<>
|
||||
<FormHeader />
|
||||
<FormContent>
|
||||
<Field
|
||||
name="outputs.properties"
|
||||
render={({
|
||||
field: { value, onChange },
|
||||
fieldState,
|
||||
}: FieldRenderProps<Record<string, JsonSchema>>) => (
|
||||
<>
|
||||
<PropertiesEdit value={value} onChange={onChange} useFx={true} />
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<FormOutputs />
|
||||
</FormContent>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<FormHeader />
|
||||
<FormContent>
|
||||
<FormOutputs />
|
||||
</FormContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const formMeta: FormMeta<FlowNodeJSON['data']> = {
|
||||
render: renderForm,
|
||||
|
||||
@ -10,6 +10,7 @@ export const EndNodeRegistry: FlowNodeRegistry = {
|
||||
isNodeEnd: true, // Mark as end
|
||||
selectable: false, // End node cannot select
|
||||
copyDisable: true, // End node canot copy
|
||||
expandable: false, // disable expanded
|
||||
},
|
||||
info: {
|
||||
icon: iconEnd,
|
||||
|
||||
@ -10,6 +10,9 @@ export const LoopNodeRegistry: FlowNodeRegistry = {
|
||||
description:
|
||||
'Used to repeatedly execute a series of tasks by setting the number of iterations and logic',
|
||||
},
|
||||
meta: {
|
||||
expandable: false, // disable expanded
|
||||
},
|
||||
onAdd() {
|
||||
return {
|
||||
id: `loop_${nanoid(5)}`,
|
||||
|
||||
@ -7,27 +7,40 @@ import {
|
||||
} from '@flowgram.ai/fixed-layout-editor';
|
||||
|
||||
import { FlowNodeJSON, JsonSchema } from '../../typings';
|
||||
import { useIsSidebar } from '../../hooks';
|
||||
import { FormHeader, FormContent, FormOutputs, PropertiesEdit } from '../../form-components';
|
||||
|
||||
export const renderForm = ({ form }: FormRenderProps<FlowNodeJSON['data']>) => (
|
||||
<>
|
||||
<FormHeader />
|
||||
<FormContent>
|
||||
<Field
|
||||
name="outputs.properties"
|
||||
render={({
|
||||
field: { value, onChange },
|
||||
fieldState,
|
||||
}: FieldRenderProps<Record<string, JsonSchema>>) => (
|
||||
<>
|
||||
<PropertiesEdit value={value} onChange={onChange} />
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<FormOutputs />
|
||||
</FormContent>
|
||||
</>
|
||||
);
|
||||
export const renderForm = ({ form }: FormRenderProps<FlowNodeJSON['data']>) => {
|
||||
const isSidebar = useIsSidebar();
|
||||
if (isSidebar) {
|
||||
return (
|
||||
<>
|
||||
<FormHeader />
|
||||
<FormContent>
|
||||
<Field
|
||||
name="outputs.properties"
|
||||
render={({
|
||||
field: { value, onChange },
|
||||
fieldState,
|
||||
}: FieldRenderProps<Record<string, JsonSchema>>) => (
|
||||
<>
|
||||
<PropertiesEdit value={value} onChange={onChange} />
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</FormContent>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<FormHeader />
|
||||
<FormContent>
|
||||
<FormOutputs />
|
||||
</FormContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const formMeta: FormMeta<FlowNodeJSON['data']> = {
|
||||
render: renderForm,
|
||||
|
||||
@ -9,6 +9,7 @@ export const StartNodeRegistry: FlowNodeRegistry = {
|
||||
deleteDisable: true, // Start node cannot delete
|
||||
selectable: false, // Start node cannot select
|
||||
copyDisable: true, // Start node cannot copy
|
||||
expandable: false, // disable expanded
|
||||
},
|
||||
info: {
|
||||
icon: iconStart,
|
||||
|
||||
@ -4,6 +4,7 @@ import { type TreeNodeData } from '@douyinfe/semi-ui/lib/es/tree';
|
||||
import { TreeSelect } from '@douyinfe/semi-ui';
|
||||
|
||||
import { type JsonSchema } from '../../../typings';
|
||||
import { ValueDisplay } from '../../../form-components';
|
||||
import { useVariableTree } from './use-variable-tree';
|
||||
|
||||
export interface VariableSelectorProps {
|
||||
@ -17,18 +18,21 @@ export interface VariableSelectorProps {
|
||||
};
|
||||
hasError?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
export const VariableSelector = ({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
disabled,
|
||||
readonly,
|
||||
style,
|
||||
hasError,
|
||||
}: VariableSelectorProps) => {
|
||||
const { size = 'small', emptyContent, targetSchemas, strongEqualToTargetSchema } = options || {};
|
||||
if (readonly) {
|
||||
return <ValueDisplay value={value as string} hasError={hasError} />;
|
||||
}
|
||||
|
||||
const treeData = useVariableTree<TreeNodeData>({
|
||||
targetSchemas,
|
||||
@ -70,7 +74,6 @@ export const VariableSelector = ({
|
||||
<>
|
||||
<TreeSelect
|
||||
dropdownMatchSelectWidth={false}
|
||||
disabled={disabled}
|
||||
treeData={treeData}
|
||||
size={size}
|
||||
value={value}
|
||||
|
||||
@ -1,15 +0,0 @@
|
||||
import { IconInfoCircle } from '@douyinfe/semi-icons';
|
||||
|
||||
export const ErrorIcon = () => (
|
||||
<IconInfoCircle
|
||||
style={{
|
||||
position: 'absolute',
|
||||
color: 'red',
|
||||
left: -6,
|
||||
top: -6,
|
||||
zIndex: 1,
|
||||
background: 'white',
|
||||
borderRadius: 8,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@ -1,17 +0,0 @@
|
||||
.flowgram-node-render {
|
||||
align-items: flex-start;
|
||||
background-color: #fff;
|
||||
border: 1px solid rgba(6, 7, 9, 0.15);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
position: relative;
|
||||
min-width: 360px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.flowgram-node-render.selected {
|
||||
border: 1px solid var(--coz-stroke-hglt, #4e40e5);
|
||||
}
|
||||
@ -1,15 +1,11 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import {
|
||||
FlowNodeEntity,
|
||||
useNodeRender,
|
||||
WorkflowNodeRenderer,
|
||||
} from '@flowgram.ai/free-layout-editor';
|
||||
import { FlowNodeEntity, useNodeRender } from '@flowgram.ai/free-layout-editor';
|
||||
import { ConfigProvider } from '@douyinfe/semi-ui';
|
||||
|
||||
import { NodeRenderContext } from '../../context';
|
||||
import './index.css';
|
||||
import { ErrorIcon } from './error-icon';
|
||||
import { BaseNodeStyle, ErrorIcon } from './styles';
|
||||
import { NodeWrapper } from './node-wrapper';
|
||||
|
||||
export const BaseNode = ({ node }: { node: FlowNodeEntity }) => {
|
||||
/**
|
||||
@ -30,18 +26,21 @@ export const BaseNode = ({ node }: { node: FlowNodeEntity }) => {
|
||||
const getPopupContainer = useCallback(() => node.renderData.node || document.body, []);
|
||||
|
||||
return (
|
||||
<WorkflowNodeRenderer
|
||||
className={`flowgram-node-render ${nodeRender.selected ? 'selected' : ''}`}
|
||||
node={node}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
outline: form?.state.invalid ? '1px solid red' : 'none',
|
||||
}}
|
||||
>
|
||||
{form?.state.invalid && <ErrorIcon />}
|
||||
<ConfigProvider getPopupContainer={getPopupContainer}>
|
||||
<NodeRenderContext.Provider value={nodeRender}>
|
||||
<ConfigProvider getPopupContainer={getPopupContainer}>{form?.render()}</ConfigProvider>
|
||||
<NodeWrapper>
|
||||
{form?.state.invalid && <ErrorIcon />}
|
||||
<BaseNodeStyle
|
||||
className={nodeRender.selected ? 'selected' : ''}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
outline: form?.state.invalid ? '1px solid red' : 'none',
|
||||
}}
|
||||
>
|
||||
{form?.render()}
|
||||
</BaseNodeStyle>
|
||||
</NodeWrapper>
|
||||
</NodeRenderContext.Provider>
|
||||
</WorkflowNodeRenderer>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@ -0,0 +1,49 @@
|
||||
import React, { useState, useContext } from 'react';
|
||||
|
||||
import { WorkflowPortRender } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { useNodeRenderContext } from '../../hooks';
|
||||
import { SidebarContext } from '../../context';
|
||||
|
||||
export interface NodeWrapperProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used for drag-and-drop/click events and ports rendering of nodes
|
||||
* 用于节点的拖拽/点击事件和点位渲染
|
||||
*/
|
||||
export const NodeWrapper: React.FC<NodeWrapperProps> = (props) => {
|
||||
const nodeRender = useNodeRenderContext();
|
||||
const { selected, startDrag, ports, selectNode, nodeRef, onFocus, onBlur } = nodeRender;
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const sidebar = useContext(SidebarContext);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={nodeRef}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
startDrag(e);
|
||||
setIsDragging(true);
|
||||
}}
|
||||
onClick={(e) => {
|
||||
selectNode(e);
|
||||
if (!isDragging) {
|
||||
sidebar.setNodeRender(nodeRender);
|
||||
}
|
||||
}}
|
||||
onMouseUp={() => setIsDragging(false)}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
data-node-selected={String(selected)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
{ports.map((p) => (
|
||||
<WorkflowPortRender key={p.id} entity={p} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
35
apps/demo-free-layout/src/components/base-node/styles.tsx
Normal file
35
apps/demo-free-layout/src/components/base-node/styles.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import styled from 'styled-components';
|
||||
import { IconInfoCircle } from '@douyinfe/semi-icons';
|
||||
|
||||
export const BaseNodeStyle = styled.div`
|
||||
align-items: flex-start;
|
||||
background-color: #fff;
|
||||
border: 1px solid rgba(6, 7, 9, 0.15);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 6px 0 rgba(0, 0, 0, 0.04), 0 4px 12px 0 rgba(0, 0, 0, 0.02);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
min-width: 360px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
&.selected {
|
||||
border: 1px solid #4e40e5;
|
||||
}
|
||||
`;
|
||||
|
||||
export const ErrorIcon = () => (
|
||||
<IconInfoCircle
|
||||
style={{
|
||||
position: 'absolute',
|
||||
color: 'red',
|
||||
left: -6,
|
||||
top: -6,
|
||||
zIndex: 1,
|
||||
background: 'white',
|
||||
borderRadius: 8,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
2
apps/demo-free-layout/src/components/sidebar/index.tsx
Normal file
2
apps/demo-free-layout/src/components/sidebar/index.tsx
Normal file
@ -0,0 +1,2 @@
|
||||
export { SidebarProvider } from './sidebar-provider';
|
||||
export { SidebarRenderer } from './sidebar-renderer';
|
||||
@ -0,0 +1,14 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { NodeRenderReturnType } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { SidebarContext } from '../../context';
|
||||
|
||||
export function SidebarProvider({ children }: { children: React.ReactNode }) {
|
||||
const [nodeRender, setNodeRender] = useState<NodeRenderReturnType | undefined>();
|
||||
return (
|
||||
<SidebarContext.Provider value={{ visible: !!nodeRender, nodeRender, setNodeRender }}>
|
||||
{children}
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,75 @@
|
||||
import { useCallback, useContext, useEffect } from 'react';
|
||||
|
||||
import {
|
||||
PlaygroundEntityContext,
|
||||
useRefresh,
|
||||
useClientContext,
|
||||
} from '@flowgram.ai/free-layout-editor';
|
||||
import { SideSheet } from '@douyinfe/semi-ui';
|
||||
|
||||
import { SidebarContext, IsSidebarContext, NodeRenderContext } from '../../context';
|
||||
|
||||
export const SidebarRenderer = () => {
|
||||
const { nodeRender, setNodeRender } = useContext(SidebarContext);
|
||||
const { selection, playground } = useClientContext();
|
||||
const refresh = useRefresh();
|
||||
const handleClose = useCallback(() => {
|
||||
setNodeRender(undefined);
|
||||
}, []);
|
||||
/**
|
||||
* Listen readonly
|
||||
*/
|
||||
useEffect(() => {
|
||||
const disposable = playground.config.onReadonlyOrDisabledChange(() => refresh());
|
||||
return () => disposable.dispose();
|
||||
}, [playground]);
|
||||
/**
|
||||
* Listen selection
|
||||
*/
|
||||
useEffect(() => {
|
||||
const toDispose = selection.onSelectionChanged(() => {
|
||||
/**
|
||||
* 如果没有选中任何节点,则自动关闭侧边栏
|
||||
* If no node is selected, the sidebar is automatically closed
|
||||
*/
|
||||
if (selection.selection.length === 0) {
|
||||
handleClose();
|
||||
} else if (selection.selection.length === 1 && selection.selection[0] !== nodeRender?.node) {
|
||||
handleClose();
|
||||
}
|
||||
});
|
||||
return () => toDispose.dispose();
|
||||
}, [selection, handleClose]);
|
||||
/**
|
||||
* Close when node disposed
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (nodeRender) {
|
||||
const toDispose = nodeRender.node.onDispose(() => {
|
||||
setNodeRender(undefined);
|
||||
});
|
||||
return () => toDispose.dispose();
|
||||
}
|
||||
return () => {};
|
||||
}, [nodeRender]);
|
||||
|
||||
if (playground.config.readonly) {
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* Add key to rerender the sidebar when the node changes
|
||||
*/
|
||||
const content = nodeRender ? (
|
||||
<PlaygroundEntityContext.Provider key={nodeRender.node.id} value={nodeRender.node}>
|
||||
<NodeRenderContext.Provider value={nodeRender}>
|
||||
{nodeRender.form?.render()}
|
||||
</NodeRenderContext.Provider>
|
||||
</PlaygroundEntityContext.Provider>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<SideSheet mask={false} visible={!!nodeRender} onCancel={handleClose}>
|
||||
<IsSidebarContext.Provider value={true}>{content}</IsSidebarContext.Provider>
|
||||
</SideSheet>
|
||||
);
|
||||
};
|
||||
@ -52,7 +52,7 @@ export const DemoTools = () => {
|
||||
type="tertiary"
|
||||
theme="borderless"
|
||||
icon={<IconUndo />}
|
||||
disabled={!canUndo}
|
||||
disabled={!canUndo || playground.config.readonly}
|
||||
onClick={() => history.undo()}
|
||||
/>
|
||||
</Tooltip>
|
||||
@ -61,7 +61,7 @@ export const DemoTools = () => {
|
||||
type="tertiary"
|
||||
theme="borderless"
|
||||
icon={<IconRedo />}
|
||||
disabled={!canRedo}
|
||||
disabled={!canRedo || playground.config.readonly}
|
||||
onClick={() => history.redo()}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
@ -1 +1,2 @@
|
||||
export { NodeRenderContext } from './node-render-context';
|
||||
export { SidebarContext, IsSidebarContext } from './sidebar-context';
|
||||
|
||||
11
apps/demo-free-layout/src/context/sidebar-context.ts
Normal file
11
apps/demo-free-layout/src/context/sidebar-context.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
import { NodeRenderReturnType } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
export const SidebarContext = React.createContext<{
|
||||
visible: boolean;
|
||||
nodeRender?: NodeRenderReturnType;
|
||||
setNodeRender: (node: NodeRenderReturnType | undefined) => void;
|
||||
}>({ visible: false, setNodeRender: () => {} });
|
||||
|
||||
export const IsSidebarContext = React.createContext<boolean>(false);
|
||||
@ -6,16 +6,20 @@ import { nodeRegistries } from './nodes';
|
||||
import { initialData } from './initial-data';
|
||||
import { useEditorProps } from './hooks';
|
||||
import { DemoTools } from './components/tools';
|
||||
import { SidebarProvider, SidebarRenderer } from './components/sidebar';
|
||||
|
||||
export const Editor = () => {
|
||||
const editorProps = useEditorProps(initialData, nodeRegistries);
|
||||
return (
|
||||
<div className="doc-free-feature-overview">
|
||||
<FreeLayoutEditorProvider {...editorProps}>
|
||||
<div className="demo-container">
|
||||
<EditorRenderer className="demo-editor" />
|
||||
</div>
|
||||
<DemoTools />
|
||||
<SidebarProvider>
|
||||
<div className="demo-container">
|
||||
<EditorRenderer className="demo-editor" />
|
||||
</div>
|
||||
<DemoTools />
|
||||
<SidebarRenderer />
|
||||
</SidebarProvider>
|
||||
</FreeLayoutEditorProvider>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -2,7 +2,7 @@ import React from 'react';
|
||||
|
||||
import { FlowNodeRegistry } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { useNodeRenderContext } from '../../hooks';
|
||||
import { useIsSidebar, useNodeRenderContext } from '../../hooks';
|
||||
import { FormTitleDescription, FormWrapper } from './styles';
|
||||
|
||||
/**
|
||||
@ -10,13 +10,14 @@ import { FormTitleDescription, FormWrapper } from './styles';
|
||||
* @constructor
|
||||
*/
|
||||
export function FormContent(props: { children?: React.ReactNode }) {
|
||||
const { expanded, node } = useNodeRenderContext();
|
||||
const { node, expanded } = useNodeRenderContext();
|
||||
const isSidebar = useIsSidebar();
|
||||
const registry = node.getNodeRegistry<FlowNodeRegistry>();
|
||||
return (
|
||||
<FormWrapper>
|
||||
{expanded ? (
|
||||
<>
|
||||
<FormTitleDescription>{registry.info?.description}</FormTitleDescription>
|
||||
{isSidebar && <FormTitleDescription>{registry.info?.description}</FormTitleDescription>}
|
||||
{props.children}
|
||||
</>
|
||||
) : undefined}
|
||||
|
||||
@ -1,25 +1,25 @@
|
||||
import { useCallback, useState, type MouseEvent } from 'react';
|
||||
|
||||
import {
|
||||
Command,
|
||||
Field,
|
||||
FieldRenderProps,
|
||||
useClientContext,
|
||||
useService,
|
||||
} from '@flowgram.ai/free-layout-editor';
|
||||
import { NodeIntoContainerService } from '@flowgram.ai/free-container-plugin';
|
||||
import { IconButton, Dropdown, Typography } from '@douyinfe/semi-ui';
|
||||
import { IconMore } from '@douyinfe/semi-icons';
|
||||
import { IconButton, Dropdown, Typography, Button } from '@douyinfe/semi-ui';
|
||||
import { IconMore, IconSmallTriangleDown, IconSmallTriangleLeft } from '@douyinfe/semi-icons';
|
||||
|
||||
import { Feedback } from '../feedback';
|
||||
import { FlowNodeRegistry } from '../../typings';
|
||||
import { useNodeRenderContext } from '../../hooks';
|
||||
import { FlowCommandId } from '../../shortcuts';
|
||||
import { useIsSidebar, useNodeRenderContext } from '../../hooks';
|
||||
import { getIcon } from './utils';
|
||||
import { Header, Operators, Title } from './styles';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
function DropdownButton() {
|
||||
function DropdownContent() {
|
||||
const [key, setKey] = useState(0);
|
||||
const { node, deleteNode } = useNodeRenderContext();
|
||||
const clientContext = useClientContext();
|
||||
@ -41,9 +41,22 @@ function DropdownButton() {
|
||||
[nodeIntoContainerService, node, rerenderMenu]
|
||||
);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
clientContext.playground.commandService.executeCommand(Command.Default.COPY, node);
|
||||
}, [clientContext, node]);
|
||||
const handleCopy = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
clientContext.playground.commandService.executeCommand(FlowCommandId.COPY, node);
|
||||
e.stopPropagation(); // Disable clicking prevents the sidebar from opening
|
||||
},
|
||||
[clientContext, node]
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
deleteNode();
|
||||
e.stopPropagation(); // Disable clicking prevents the sidebar from opening
|
||||
},
|
||||
[clientContext, node]
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
trigger="hover"
|
||||
@ -56,7 +69,7 @@ function DropdownButton() {
|
||||
Copy
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
onClick={deleteNode}
|
||||
onClick={handleDelete}
|
||||
disabled={!!(registry.canDelete?.(clientContext, node) || registry.meta!.deleteDisable)}
|
||||
>
|
||||
Delete
|
||||
@ -76,7 +89,12 @@ function DropdownButton() {
|
||||
}
|
||||
|
||||
export function FormHeader() {
|
||||
const { node, readonly } = useNodeRenderContext();
|
||||
const { node, expanded, toggleExpand, readonly } = useNodeRenderContext();
|
||||
const isSidebar = useIsSidebar();
|
||||
const handleExpand = (e: React.MouseEvent) => {
|
||||
toggleExpand();
|
||||
e.stopPropagation(); // Disable clicking prevents the sidebar from opening
|
||||
};
|
||||
|
||||
return (
|
||||
<Header>
|
||||
@ -91,9 +109,18 @@ export function FormHeader() {
|
||||
)}
|
||||
</Field>
|
||||
</Title>
|
||||
{node.renderData.expandable && !isSidebar && (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={expanded ? <IconSmallTriangleDown /> : <IconSmallTriangleLeft />}
|
||||
size="small"
|
||||
theme="borderless"
|
||||
onClick={handleExpand}
|
||||
/>
|
||||
)}
|
||||
{readonly ? undefined : (
|
||||
<Operators>
|
||||
<DropdownButton />
|
||||
<DropdownContent />
|
||||
</Operators>
|
||||
)}
|
||||
</Header>
|
||||
|
||||
@ -4,10 +4,10 @@ import { FxExpression } from '../fx-expression';
|
||||
import { FormItem } from '../form-item';
|
||||
import { Feedback } from '../feedback';
|
||||
import { JsonSchema } from '../../typings';
|
||||
import { useNodeRenderContext } from '../../hooks';
|
||||
import { useIsSidebar } from '../../hooks';
|
||||
|
||||
export function FormInputs() {
|
||||
const { readonly } = useNodeRenderContext();
|
||||
const readonly = !useIsSidebar();
|
||||
return (
|
||||
<Field<JsonSchema> name="inputs">
|
||||
{({ field: inputsField }) => {
|
||||
@ -29,7 +29,7 @@ export function FormInputs() {
|
||||
<FxExpression
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
disabled={readonly}
|
||||
readonly={readonly}
|
||||
hasError={Object.keys(fieldState?.errors || {}).length > 0}
|
||||
/>
|
||||
<Feedback errors={fieldState?.errors} />
|
||||
|
||||
@ -2,9 +2,14 @@ import { Field } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { TypeTag } from '../type-tag';
|
||||
import { JsonSchema } from '../../typings';
|
||||
import { useIsSidebar } from '../../hooks';
|
||||
import { FormOutputsContainer } from './styles';
|
||||
|
||||
export function FormOutputs() {
|
||||
const isSidebar = useIsSidebar();
|
||||
if (isSidebar) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Field<JsonSchema> name={'outputs'}>
|
||||
{({ field }) => {
|
||||
|
||||
@ -2,6 +2,7 @@ import React, { type SVGProps } from 'react';
|
||||
|
||||
import { Input, Button } from '@douyinfe/semi-ui';
|
||||
|
||||
import { ValueDisplay } from '../value-display';
|
||||
import { FlowRefValueSchema, FlowLiteralValueSchema } from '../../typings';
|
||||
import { VariableSelector } from '../../plugins/sync-variable-plugin/variable-selector';
|
||||
|
||||
@ -18,18 +19,44 @@ export function FxIcon(props: SVGProps<SVGSVGElement>) {
|
||||
);
|
||||
}
|
||||
|
||||
function InputWrap({
|
||||
value,
|
||||
onChange,
|
||||
readonly,
|
||||
hasError,
|
||||
style,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
readonly?: boolean;
|
||||
hasError?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
if (readonly) {
|
||||
return <ValueDisplay value={value} hasError={hasError} />;
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
value={value as string}
|
||||
onChange={onChange}
|
||||
validateStatus={hasError ? 'error' : undefined}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export interface FxExpressionProps {
|
||||
value?: FlowLiteralValueSchema | FlowRefValueSchema;
|
||||
onChange: (value: FlowLiteralValueSchema | FlowRefValueSchema) => void;
|
||||
literal?: boolean;
|
||||
hasError?: boolean;
|
||||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function FxExpression(props: FxExpressionProps) {
|
||||
const { value, onChange, disabled, literal, icon } = props;
|
||||
if (literal) return <Input value={value as string} onChange={onChange} disabled={disabled} />;
|
||||
const { value, onChange, readonly, literal, icon } = props;
|
||||
if (literal) return <InputWrap value={value as string} onChange={onChange} readonly={readonly} />;
|
||||
const isExpression = typeof value === 'object' && value.type === 'expression';
|
||||
const toggleExpression = () => {
|
||||
if (isExpression) {
|
||||
@ -46,18 +73,18 @@ export function FxExpression(props: FxExpressionProps) {
|
||||
hasError={props.hasError}
|
||||
style={{ flexGrow: 1 }}
|
||||
onChange={(v) => onChange({ type: 'expression', content: v })}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
<InputWrap
|
||||
value={value as string}
|
||||
onChange={onChange}
|
||||
validateStatus={props.hasError ? 'error' : undefined}
|
||||
disabled={disabled}
|
||||
hasError={props.hasError}
|
||||
readonly={readonly}
|
||||
style={{ flexGrow: 1, outline: props.hasError ? '1px solid red' : undefined }}
|
||||
/>
|
||||
)}
|
||||
{!disabled &&
|
||||
{!readonly &&
|
||||
(icon || <Button theme="borderless" icon={<FxIcon />} onClick={toggleExpression} />)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -6,3 +6,4 @@ export * from './form-header';
|
||||
export * from './form-item';
|
||||
export * from './type-tag';
|
||||
export * from './properties-edit';
|
||||
export * from './value-display';
|
||||
|
||||
@ -0,0 +1,17 @@
|
||||
// import { TypeTag } from '../type-tag'
|
||||
import { ValueDisplayStyle } from './styles';
|
||||
|
||||
export interface ValueDisplayProps {
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
hasError?: boolean;
|
||||
}
|
||||
|
||||
export const ValueDisplay: React.FC<ValueDisplayProps> = (props) => (
|
||||
<ValueDisplayStyle className={props.hasError ? 'has-error' : ''}>
|
||||
{props.value}
|
||||
{props.value === undefined || props.value === '' ? (
|
||||
<span style={{ color: 'var(--semi-color-text-2)' }}>{props.placeholder || '--'}</span>
|
||||
) : null}
|
||||
</ValueDisplayStyle>
|
||||
);
|
||||
@ -0,0 +1,15 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const ValueDisplayStyle = styled.div`
|
||||
background-color: var(--semi-color-fill-0);
|
||||
border-radius: var(--semi-border-radius-small);
|
||||
padding-left: 12px;
|
||||
width: 100%;
|
||||
min-height: 24px;
|
||||
line-height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
&.has-error {
|
||||
outline: red solid 1px;
|
||||
}
|
||||
`;
|
||||
@ -1,2 +1,3 @@
|
||||
export { useEditorProps } from './use-editor-props';
|
||||
export { useNodeRenderContext } from './use-node-render-context';
|
||||
export { useIsSidebar } from './use-is-sidebar';
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
/* eslint-disable no-console */
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { debounce } from 'lodash-es';
|
||||
import { createMinimapPlugin } from '@flowgram.ai/minimap-plugin';
|
||||
import { createFreeSnapPlugin } from '@flowgram.ai/free-snap-plugin';
|
||||
import {
|
||||
@ -68,11 +69,22 @@ export function useEditorProps(
|
||||
},
|
||||
/**
|
||||
* Check whether the line can be deleted
|
||||
* 判断是否删除连线
|
||||
* 判断是否能删除连线
|
||||
*/
|
||||
canDeleteLine(ctx, line, newLineInfo, silent) {
|
||||
return true;
|
||||
},
|
||||
/**
|
||||
* Check whether the node can be deleted
|
||||
* 判断是否能删除节点
|
||||
*/
|
||||
canDeleteNode(ctx, node) {
|
||||
return true;
|
||||
},
|
||||
/**
|
||||
* Drag the end of the line to create an add panel (feature optional)
|
||||
* 拖拽线条结束需要创建一个添加面板 (功能可选)
|
||||
*/
|
||||
async onDragLineEnd(ctx, params) {
|
||||
const nodePanelService = ctx.get(WorkflowNodePanelService);
|
||||
const { fromPort, toPort, mousePos, line, originLine } = params;
|
||||
@ -127,13 +139,19 @@ export function useEditorProps(
|
||||
/**
|
||||
* Content change
|
||||
*/
|
||||
onContentChange(ctx, event) {
|
||||
// console.log('Auto Save: ', event, ctx.document.toJSON());
|
||||
},
|
||||
onContentChange: debounce((ctx, event) => {
|
||||
console.log('Auto Save: ', event, ctx.document.toJSON());
|
||||
}, 1000),
|
||||
/**
|
||||
* Shortcuts
|
||||
*/
|
||||
shortcuts,
|
||||
/**
|
||||
* Playground init
|
||||
*/
|
||||
onInit() {
|
||||
console.log('--- Playground init ---');
|
||||
},
|
||||
/**
|
||||
* Playground render
|
||||
*/
|
||||
@ -197,9 +215,17 @@ export function useEditorProps(
|
||||
alignLineWidth: 1,
|
||||
alignCrossWidth: 8,
|
||||
}),
|
||||
/**
|
||||
* NodeAddPanel render plugin
|
||||
* 节点添加面板渲染插件
|
||||
*/
|
||||
createFreeNodePanelPlugin({
|
||||
renderer: NodePanel,
|
||||
}),
|
||||
/**
|
||||
* This is used for the rendering of the loop node sub-canvas
|
||||
* 这个用于 loop 节点子画布的渲染
|
||||
*/
|
||||
createContainerNodePlugin({}),
|
||||
],
|
||||
}),
|
||||
|
||||
7
apps/demo-free-layout/src/hooks/use-is-sidebar.ts
Normal file
7
apps/demo-free-layout/src/hooks/use-is-sidebar.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { useContext } from 'react';
|
||||
|
||||
import { IsSidebarContext } from '../context';
|
||||
|
||||
export function useIsSidebar() {
|
||||
return useContext(IsSidebarContext);
|
||||
}
|
||||
@ -2,4 +2,6 @@ import { useContext } from 'react';
|
||||
|
||||
import { NodeRenderContext } from '../context';
|
||||
|
||||
export const useNodeRenderContext = () => useContext(NodeRenderContext);
|
||||
export function useNodeRenderContext() {
|
||||
return useContext(NodeRenderContext);
|
||||
}
|
||||
|
||||
@ -151,7 +151,9 @@ export const initialData: FlowDocumentJSON = {
|
||||
},
|
||||
data: {
|
||||
title: 'Loop_2',
|
||||
inputsValues: {},
|
||||
inputsValues: {
|
||||
loopTimes: 2,
|
||||
},
|
||||
inputs: {
|
||||
type: 'object',
|
||||
required: ['loopTimes'],
|
||||
|
||||
@ -1,3 +0,0 @@
|
||||
export function ConditionInput() {
|
||||
return null;
|
||||
}
|
||||
@ -4,7 +4,7 @@ import { Button } from '@douyinfe/semi-ui';
|
||||
import { IconPlus, IconCrossCircleStroked } from '@douyinfe/semi-icons';
|
||||
|
||||
import { FlowLiteralValueSchema, FlowRefValueSchema } from '../../../typings';
|
||||
import { useNodeRenderContext } from '../../../hooks';
|
||||
import { useIsSidebar } from '../../../hooks';
|
||||
import { FxExpression } from '../../../form-components/fx-expression';
|
||||
import { FormItem } from '../../../form-components';
|
||||
import { Feedback } from '../../../form-components';
|
||||
@ -16,7 +16,7 @@ interface ConditionValue {
|
||||
}
|
||||
|
||||
export function ConditionInputs() {
|
||||
const { readonly } = useNodeRenderContext();
|
||||
const readonly = !useIsSidebar();
|
||||
return (
|
||||
<FieldArray name="inputsValues.conditions">
|
||||
{({ field }) => (
|
||||
@ -36,7 +36,7 @@ export function ConditionInputs() {
|
||||
/>
|
||||
}
|
||||
hasError={Object.keys(childState?.errors || {}).length > 0}
|
||||
disabled={readonly}
|
||||
readonly={readonly}
|
||||
/>
|
||||
<Feedback errors={childState?.errors} invalid={childState?.invalid} />
|
||||
<ConditionPort data-port-id={childField.value.key} data-port-type="output" />
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { FormRenderProps, FormMeta, ValidateTrigger } from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { FlowNodeJSON } from '../../typings';
|
||||
import { FormHeader, FormContent, FormOutputs } from '../../form-components';
|
||||
import { FormHeader, FormContent } from '../../form-components';
|
||||
import { ConditionInputs } from './condition-inputs';
|
||||
|
||||
export const renderForm = ({ form }: FormRenderProps<FlowNodeJSON>) => (
|
||||
@ -9,7 +9,6 @@ export const renderForm = ({ form }: FormRenderProps<FlowNodeJSON>) => (
|
||||
<FormHeader />
|
||||
<FormContent>
|
||||
<ConditionInputs />
|
||||
<FormOutputs />
|
||||
</FormContent>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -15,10 +15,7 @@ export const ConditionNodeRegistry: FlowNodeRegistry = {
|
||||
defaultPorts: [{ type: 'input' }],
|
||||
// Condition Outputs use dynamic port
|
||||
useDynamicPort: true,
|
||||
size: {
|
||||
width: 360,
|
||||
height: 305,
|
||||
},
|
||||
expandable: false, // disable expanded
|
||||
},
|
||||
formMeta,
|
||||
onAdd() {
|
||||
|
||||
@ -7,27 +7,41 @@ import {
|
||||
} from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { FlowNodeJSON, JsonSchema } from '../../typings';
|
||||
import { useIsSidebar } from '../../hooks';
|
||||
import { FormHeader, FormContent, FormOutputs, PropertiesEdit } from '../../form-components';
|
||||
|
||||
export const renderForm = ({ form }: FormRenderProps<FlowNodeJSON>) => (
|
||||
<>
|
||||
<FormHeader />
|
||||
<FormContent>
|
||||
<Field
|
||||
name="outputs.properties"
|
||||
render={({
|
||||
field: { value, onChange },
|
||||
fieldState,
|
||||
}: FieldRenderProps<Record<string, JsonSchema>>) => (
|
||||
<>
|
||||
<PropertiesEdit value={value} onChange={onChange} useFx={true} />
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<FormOutputs />
|
||||
</FormContent>
|
||||
</>
|
||||
);
|
||||
export const renderForm = ({ form }: FormRenderProps<FlowNodeJSON>) => {
|
||||
const isSidebar = useIsSidebar();
|
||||
if (isSidebar) {
|
||||
return (
|
||||
<>
|
||||
<FormHeader />
|
||||
<FormContent>
|
||||
<Field
|
||||
name="outputs.properties"
|
||||
render={({
|
||||
field: { value, onChange },
|
||||
fieldState,
|
||||
}: FieldRenderProps<Record<string, JsonSchema>>) => (
|
||||
<>
|
||||
<PropertiesEdit value={value} onChange={onChange} useFx={true} />
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<FormOutputs />
|
||||
</FormContent>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<FormHeader />
|
||||
<FormContent>
|
||||
<FormOutputs />
|
||||
</FormContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const formMeta: FormMeta<FlowNodeJSON> = {
|
||||
render: renderForm,
|
||||
|
||||
@ -38,6 +38,7 @@ export const LoopNodeRegistry: FlowNodeRegistry = {
|
||||
// 鼠标开始时所在位置不包括当前节点时才可选中
|
||||
return !transform.bounds.contains(mousePos.x, mousePos.y);
|
||||
},
|
||||
expandable: false, // disable expanded
|
||||
},
|
||||
onAdd() {
|
||||
return {
|
||||
@ -45,7 +46,9 @@ export const LoopNodeRegistry: FlowNodeRegistry = {
|
||||
type: 'loop',
|
||||
data: {
|
||||
title: `Loop_${++index}`,
|
||||
inputsValues: {},
|
||||
inputsValues: {
|
||||
loopTimes: 2,
|
||||
},
|
||||
inputs: {
|
||||
type: 'object',
|
||||
required: ['loopTimes'],
|
||||
|
||||
@ -1,15 +1,30 @@
|
||||
import { FormRenderProps, FlowNodeJSON } from '@flowgram.ai/free-layout-editor';
|
||||
import { SubCanvasRender } from '@flowgram.ai/free-container-plugin';
|
||||
|
||||
import { useIsSidebar } from '../../hooks';
|
||||
import { FormHeader, FormContent, FormInputs, FormOutputs } from '../../form-components';
|
||||
|
||||
export const LoopFormRender = ({ form }: FormRenderProps<FlowNodeJSON>) => (
|
||||
<>
|
||||
<FormHeader />
|
||||
<FormContent>
|
||||
<FormInputs />
|
||||
<SubCanvasRender />
|
||||
<FormOutputs />
|
||||
</FormContent>
|
||||
</>
|
||||
);
|
||||
export const LoopFormRender = ({ form }: FormRenderProps<FlowNodeJSON>) => {
|
||||
const isSidebar = useIsSidebar();
|
||||
if (isSidebar) {
|
||||
return (
|
||||
<>
|
||||
<FormHeader />
|
||||
<FormContent>
|
||||
<FormInputs />
|
||||
<FormOutputs />
|
||||
</FormContent>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<FormHeader />
|
||||
<FormContent>
|
||||
<FormInputs />
|
||||
<SubCanvasRender />
|
||||
<FormOutputs />
|
||||
</FormContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -7,27 +7,40 @@ import {
|
||||
} from '@flowgram.ai/free-layout-editor';
|
||||
|
||||
import { FlowNodeJSON, JsonSchema } from '../../typings';
|
||||
import { useIsSidebar } from '../../hooks';
|
||||
import { FormHeader, FormContent, FormOutputs, PropertiesEdit } from '../../form-components';
|
||||
|
||||
export const renderForm = ({ form }: FormRenderProps<FlowNodeJSON>) => (
|
||||
<>
|
||||
<FormHeader />
|
||||
<FormContent>
|
||||
<Field
|
||||
name="outputs.properties"
|
||||
render={({
|
||||
field: { value, onChange },
|
||||
fieldState,
|
||||
}: FieldRenderProps<Record<string, JsonSchema>>) => (
|
||||
<>
|
||||
<PropertiesEdit value={value} onChange={onChange} />
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<FormOutputs />
|
||||
</FormContent>
|
||||
</>
|
||||
);
|
||||
export const renderForm = ({ form }: FormRenderProps<FlowNodeJSON>) => {
|
||||
const isSidebar = useIsSidebar();
|
||||
if (isSidebar) {
|
||||
return (
|
||||
<>
|
||||
<FormHeader />
|
||||
<FormContent>
|
||||
<Field
|
||||
name="outputs.properties"
|
||||
render={({
|
||||
field: { value, onChange },
|
||||
fieldState,
|
||||
}: FieldRenderProps<Record<string, JsonSchema>>) => (
|
||||
<>
|
||||
<PropertiesEdit value={value} onChange={onChange} />
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</FormContent>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<FormHeader />
|
||||
<FormContent>
|
||||
<FormOutputs />
|
||||
</FormContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const formMeta: FormMeta<FlowNodeJSON> = {
|
||||
render: renderForm,
|
||||
|
||||
@ -4,6 +4,7 @@ import { type TreeNodeData } from '@douyinfe/semi-ui/lib/es/tree';
|
||||
import { TreeSelect } from '@douyinfe/semi-ui';
|
||||
|
||||
import { type JsonSchema } from '../../../typings';
|
||||
import { ValueDisplay } from '../../../form-components';
|
||||
import { useVariableTree } from './use-variable-tree';
|
||||
|
||||
export interface VariableSelectorProps {
|
||||
@ -17,18 +18,21 @@ export interface VariableSelectorProps {
|
||||
};
|
||||
hasError?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
export const VariableSelector = ({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
disabled,
|
||||
readonly,
|
||||
style,
|
||||
hasError,
|
||||
}: VariableSelectorProps) => {
|
||||
const { size = 'small', emptyContent, targetSchemas, strongEqualToTargetSchema } = options || {};
|
||||
if (readonly) {
|
||||
return <ValueDisplay value={value as string} hasError={hasError} />;
|
||||
}
|
||||
|
||||
const treeData = useVariableTree<TreeNodeData>({
|
||||
targetSchemas,
|
||||
@ -70,7 +74,6 @@ export const VariableSelector = ({
|
||||
<>
|
||||
<TreeSelect
|
||||
dropdownMatchSelectWidth={false}
|
||||
disabled={disabled}
|
||||
treeData={treeData}
|
||||
size={size}
|
||||
value={value}
|
||||
|
||||
@ -25,7 +25,7 @@ export function shortcuts(shortcutsRegistry: ShortcutsRegistry, ctx: FreeLayoutP
|
||||
shortcutsRegistry.addHandlers({
|
||||
commandId: FlowCommandId.COPY,
|
||||
shortcuts: ['meta c', 'ctrl c'],
|
||||
execute: async (e) => {
|
||||
execute: async (node) => {
|
||||
const document = ctx.get<WorkflowDocument>(WorkflowDocument);
|
||||
const selectService = ctx.get<WorkflowSelectService>(WorkflowSelectService);
|
||||
|
||||
@ -35,11 +35,11 @@ export function shortcuts(shortcutsRegistry: ShortcutsRegistry, ctx: FreeLayoutP
|
||||
content: 'Text copied',
|
||||
});
|
||||
});
|
||||
|
||||
return e;
|
||||
}
|
||||
|
||||
const { selectedNodes } = selectService;
|
||||
let selectedNodes = node instanceof WorkflowNodeEntity ? [node] : [];
|
||||
if (selectedNodes.length == 0) {
|
||||
selectedNodes = selectService.selectedNodes;
|
||||
}
|
||||
|
||||
if (selectedNodes.length === 0) {
|
||||
return;
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
"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": "exit 0",
|
||||
"test:cov": "exit 0",
|
||||
"ts-check": "tsc --noEmit",
|
||||
"watch": "npm run build:fast -- --dts-resolve --watch --ignore-watch dist"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user