feat(variable): global variable + variable panel plugin in demo (#435)

* feat: global variable panel

* feat(variable): on any change to on list or any var change

* feat: fix layout add variable panel
This commit is contained in:
Yiwei Mao 2025-07-01 19:30:22 +08:00 committed by GitHub
parent 94bdba02d1
commit 7c6c7ab7a2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 465 additions and 20 deletions

View File

@ -46,6 +46,7 @@
"@flowgram.ai/eslint-config": "workspace:*",
"@rsbuild/core": "^1.2.16",
"@rsbuild/plugin-react": "^1.1.1",
"@rsbuild/plugin-less": "^1.1.1",
"@types/lodash-es": "^4.17.12",
"@types/node": "^18",
"@types/react": "^18",

View File

@ -1,8 +1,9 @@
import { pluginReact } from '@rsbuild/plugin-react';
import { pluginLess } from '@rsbuild/plugin-less';
import { defineConfig } from '@rsbuild/core';
export default defineConfig({
plugins: [pluginReact()],
plugins: [pluginReact(), pluginLess()],
source: {
entry: {
index: './src/app.tsx',

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -17,7 +17,7 @@ import { type FlowNodeRegistry } from '../typings';
import { shortcutGetter } from '../shortcuts';
import { CustomService } from '../services';
import { GroupBoxHeader, GroupNode } from '../plugins/group-plugin';
import { createClipboardPlugin } from '../plugins';
import { createClipboardPlugin, createVariablePanelPlugin } from '../plugins';
import { SelectorBoxPopover } from '../components/selector-box-popover';
import NodeAdder from '../components/node-adder';
import BranchAdder from '../components/branch-adder';
@ -260,6 +260,12 @@ export function useEditorProps(
*
*/
createClipboardPlugin(),
/**
* Variable panel plugin
*
*/
createVariablePanelPlugin({}),
],
}),
[]

View File

@ -1 +1,2 @@
export { createClipboardPlugin } from './clipboard-plugin/create-clipboard-plugin';
export { createVariablePanelPlugin } from './variable-panel-plugin';

View File

@ -0,0 +1,8 @@
import { useVariableTree } from '@flowgram.ai/form-materials';
import { Tree } from '@douyinfe/semi-ui';
export function FullVariableList() {
const treeData = useVariableTree({});
return <Tree treeData={treeData} />;
}

View File

@ -0,0 +1,40 @@
import { useEffect } from 'react';
import { JsonSchemaEditor, JsonSchemaUtils } from '@flowgram.ai/form-materials';
import {
BaseVariableField,
GlobalScope,
useRefresh,
useService,
} from '@flowgram.ai/fixed-layout-editor';
export function GlobalVariableEditor() {
const globalScope = useService(GlobalScope);
const refresh = useRefresh();
const globalVar = globalScope.getVar() as BaseVariableField;
useEffect(() => {
const disposable = globalScope.output.onVariableListChange(() => {
refresh();
});
return () => {
disposable.dispose();
};
}, []);
if (!globalVar) {
return;
}
const value = globalVar.type ? JsonSchemaUtils.astToSchema(globalVar.type) : { type: 'object' };
return (
<JsonSchemaEditor
value={value}
onChange={(_schema) => globalVar.updateType(JsonSchemaUtils.schemaToAST(_schema))}
/>
);
}

View File

@ -0,0 +1,40 @@
.panel-wrapper {
position: relative;
z-index: 9999;
}
.variable-panel-button {
position: absolute;
top: 0;
right: 0;
border-radius: 50%;
width: 50px;
height: 50px;
z-index: 1;
&.close {
width: 30px;
height: 30px;
top: 10px;
right: 10px;
}
}
.panel-container {
width: 500px;
border-radius: 5px;
background-color: #fff;
overflow: hidden;
box-shadow: 4px 4px 4px rgba(0, 0, 0, 0.1);
z-index: 30;
:global(.semi-tabs-bar) {
padding-left: 20px;
}
:global(.semi-tabs-content) {
padding: 20px;
height: 500px;
overflow: auto;
}
}

View File

@ -0,0 +1,40 @@
import { useState } from 'react';
import { Button, Collapsible, Tabs, Tooltip } from '@douyinfe/semi-ui';
import { IconMinus } from '@douyinfe/semi-icons';
import iconVariable from '../../../assets/icon-variable.png';
import { GlobalVariableEditor } from './global-variable-editor';
import { FullVariableList } from './full-variable-list';
import styles from './index.module.less';
export function VariablePanel() {
const [isOpen, setOpen] = useState<boolean>(false);
return (
<div className={styles['panel-wrapper']}>
<Tooltip content="Toggle Variable Panel">
<Button
className={`${styles['variable-panel-button']} ${isOpen ? styles.close : ''}`}
theme={isOpen ? 'borderless' : 'light'}
onClick={() => setOpen((_open) => !_open)}
>
{isOpen ? <IconMinus /> : <img src={iconVariable} width={20} height={20} />}
</Button>
</Tooltip>
<Collapsible isOpen={isOpen}>
<div className={styles['panel-container']}>
<Tabs>
<Tabs.TabPane itemKey="variables" tab="Variable List">
<FullVariableList />
</Tabs.TabPane>
<Tabs.TabPane itemKey="global" tab="Global Editor">
<GlobalVariableEditor />
</Tabs.TabPane>
</Tabs>
</div>
</Collapsible>
</div>
);
}

View File

@ -0,0 +1 @@
export { createVariablePanelPlugin } from './variable-panel-plugin';

View File

@ -0,0 +1,22 @@
import { domUtils, injectable, Layer } from '@flowgram.ai/fixed-layout-editor';
import { VariablePanel } from './components/variable-panel';
@injectable()
export class VariablePanelLayer extends Layer {
onReady(): void {
// Fix variable panel in the right of canvas
this.config.onDataChange(() => {
const { scrollX, scrollY } = this.config.config;
domUtils.setStyle(this.node, {
position: 'absolute',
right: 25 - scrollX,
top: scrollY + 25,
});
});
}
render(): JSX.Element {
return <VariablePanel />;
}
}

View File

@ -0,0 +1,35 @@
import { JsonSchemaUtils } from '@flowgram.ai/form-materials';
import { ASTFactory, definePluginCreator, GlobalScope } from '@flowgram.ai/fixed-layout-editor';
import iconVariable from '../../assets/icon-variable.png';
import { VariablePanelLayer } from './variable-panel-layer';
const fetchMockVariableFromRemote = async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
return {
type: 'object',
properties: {
userId: { type: 'string' },
},
};
};
export const createVariablePanelPlugin = definePluginCreator({
onInit(ctx) {
ctx.playground.registerLayer(VariablePanelLayer);
// Fetch Global Variable
fetchMockVariableFromRemote().then((v) => {
ctx.get(GlobalScope).setVar(
ASTFactory.createVariableDeclaration({
key: 'global',
meta: {
title: 'Global',
icon: iconVariable,
},
type: JsonSchemaUtils.schemaToAST(v),
})
);
});
},
});

View File

@ -1,3 +1,4 @@
declare module '*.svg'
declare module '*.png'
declare module '*.jpg'
declare module '*.module.less'

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -15,7 +15,11 @@ import { FlowNodeRegistry, FlowDocumentJSON } from '../typings';
import { shortcuts } from '../shortcuts';
import { CustomService } from '../services';
import { WorkflowRuntimeService } from '../plugins/runtime-plugin/runtime-service';
import { createRuntimePlugin, createContextMenuPlugin } from '../plugins';
import {
createRuntimePlugin,
createContextMenuPlugin,
createVariablePanelPlugin,
} from '../plugins';
import { defaultFormMeta } from '../nodes/default-form-meta';
import { WorkflowNodeType } from '../nodes';
import { SelectorBoxPopover } from '../components/selector-box-popover';
@ -294,6 +298,12 @@ export function useEditorProps(
// protocol: 'http',
// },
}),
/**
* Variable panel plugin
*
*/
createVariablePanelPlugin({}),
],
}),
[]

View File

@ -1,2 +1,3 @@
export { createContextMenuPlugin } from './context-menu-plugin';
export { createRuntimePlugin } from './runtime-plugin';
export { createVariablePanelPlugin } from './variable-panel-plugin';

View File

@ -0,0 +1,8 @@
import { useVariableTree } from '@flowgram.ai/form-materials';
import { Tree } from '@douyinfe/semi-ui';
export function FullVariableList() {
const treeData = useVariableTree({});
return <Tree treeData={treeData} />;
}

View File

@ -0,0 +1,40 @@
import { useEffect } from 'react';
import {
BaseVariableField,
GlobalScope,
useRefresh,
useService,
} from '@flowgram.ai/free-layout-editor';
import { JsonSchemaEditor, JsonSchemaUtils } from '@flowgram.ai/form-materials';
export function GlobalVariableEditor() {
const globalScope = useService(GlobalScope);
const refresh = useRefresh();
const globalVar = globalScope.getVar() as BaseVariableField;
useEffect(() => {
const disposable = globalScope.output.onVariableListChange(() => {
refresh();
});
return () => {
disposable.dispose();
};
}, []);
if (!globalVar) {
return;
}
const value = globalVar.type ? JsonSchemaUtils.astToSchema(globalVar.type) : { type: 'object' };
return (
<JsonSchemaEditor
value={value}
onChange={(_schema) => globalVar.updateType(JsonSchemaUtils.schemaToAST(_schema))}
/>
);
}

View File

@ -0,0 +1,39 @@
.panel-wrapper {
position: relative;
}
.variable-panel-button {
position: absolute;
top: 0;
right: 0;
border-radius: 50%;
width: 50px;
height: 50px;
z-index: 1;
&.close {
width: 30px;
height: 30px;
top: 10px;
right: 10px;
}
}
.panel-container {
width: 500px;
border-radius: 5px;
background-color: #fff;
overflow: hidden;
box-shadow: 4px 4px 4px rgba(0, 0, 0, 0.1);
z-index: 30;
:global(.semi-tabs-bar) {
padding-left: 20px;
}
:global(.semi-tabs-content) {
padding: 20px;
height: 500px;
overflow: auto;
}
}

View File

@ -0,0 +1,40 @@
import { useState } from 'react';
import { Button, Collapsible, Tabs, Tooltip } from '@douyinfe/semi-ui';
import { IconMinus } from '@douyinfe/semi-icons';
import iconVariable from '../../../assets/icon-variable.png';
import { GlobalVariableEditor } from './global-variable-editor';
import { FullVariableList } from './full-variable-list';
import styles from './index.module.less';
export function VariablePanel() {
const [isOpen, setOpen] = useState<boolean>(false);
return (
<div className={styles['panel-wrapper']}>
<Tooltip content="Toggle Variable Panel">
<Button
className={`${styles['variable-panel-button']} ${isOpen ? styles.close : ''}`}
theme={isOpen ? 'borderless' : 'light'}
onClick={() => setOpen((_open) => !_open)}
>
{isOpen ? <IconMinus /> : <img src={iconVariable} width={20} height={20} />}
</Button>
</Tooltip>
<Collapsible isOpen={isOpen}>
<div className={styles['panel-container']}>
<Tabs>
<Tabs.TabPane itemKey="variables" tab="Variable List">
<FullVariableList />
</Tabs.TabPane>
<Tabs.TabPane itemKey="global" tab="Global Editor">
<GlobalVariableEditor />
</Tabs.TabPane>
</Tabs>
</div>
</Collapsible>
</div>
);
}

View File

@ -0,0 +1 @@
export { createVariablePanelPlugin } from './variable-panel-plugin';

View File

@ -0,0 +1,22 @@
import { domUtils, injectable, Layer } from '@flowgram.ai/free-layout-editor';
import { VariablePanel } from './components/variable-panel';
@injectable()
export class VariablePanelLayer extends Layer {
onReady(): void {
// Fix variable panel in the right of canvas
this.config.onDataChange(() => {
const { scrollX, scrollY } = this.config.config;
domUtils.setStyle(this.node, {
position: 'absolute',
right: 25 - scrollX,
top: scrollY + 25,
});
});
}
render(): JSX.Element {
return <VariablePanel />;
}
}

View File

@ -0,0 +1,35 @@
import { ASTFactory, definePluginCreator, GlobalScope } from '@flowgram.ai/free-layout-editor';
import { JsonSchemaUtils } from '@flowgram.ai/form-materials';
import iconVariable from '../../assets/icon-variable.png';
import { VariablePanelLayer } from './variable-panel-layer';
const fetchMockVariableFromRemote = async () => {
await new Promise((resolve) => setTimeout(resolve, 1000));
return {
type: 'object',
properties: {
userId: { type: 'string' },
},
};
};
export const createVariablePanelPlugin = definePluginCreator({
onInit(ctx) {
ctx.playground.registerLayer(VariablePanelLayer);
// Fetch Global Variable
fetchMockVariableFromRemote().then((v) => {
ctx.get(GlobalScope).setVar(
ASTFactory.createVariableDeclaration({
key: 'global',
meta: {
title: 'Global',
icon: iconVariable,
},
type: JsonSchemaUtils.schemaToAST(v),
})
);
});
},
});

View File

@ -1,3 +1,4 @@
declare module '*.svg'
declare module '*.png'
declare module '*.jpg'
declare module '*.module.less'

View File

@ -102,6 +102,9 @@ importers:
'@rsbuild/core':
specifier: ^1.2.16
version: 1.2.19
'@rsbuild/plugin-less':
specifier: ^1.1.1
version: 1.1.1(@rsbuild/core@1.2.19)
'@rsbuild/plugin-react':
specifier: ^1.1.1
version: 1.1.1(@rsbuild/core@1.2.19)

View File

@ -25,6 +25,8 @@ interface PropTypes {
export type VariableSelectorProps = PropTypes;
export { useVariableTree };
export const VariableSelector = ({
value,
config = {},

View File

@ -1,6 +1,6 @@
import React, { useCallback } from 'react';
import { useScopeAvailable, ASTMatch, BaseVariableField } from '@flowgram.ai/editor';
import { ASTMatch, BaseVariableField, useAvailableVariables } from '@flowgram.ai/editor';
import { TreeNodeData } from '@douyinfe/semi-ui/lib/es/tree';
import { Icon } from '@douyinfe/semi-ui';
@ -16,7 +16,7 @@ export function useVariableTree(params: {
}): TreeNodeData[] {
const { includeSchema, excludeSchema } = params;
const available = useScopeAvailable();
const variables = useAvailableVariables();
const getVariableTypeIcon = useCallback((variable: VariableField) => {
if (variable.meta?.icon) {
@ -92,7 +92,7 @@ export function useVariableTree(params: {
};
};
return [...available.variables.slice(0).reverse()]
return [...variables.slice(0).reverse()]
.map((_variable) => renderVariable(_variable as VariableField))
.filter(Boolean) as TreeNodeData[];
}

View File

@ -109,7 +109,7 @@ export namespace JsonSchemaUtils {
type: 'object',
properties: drilldown
? Object.fromEntries(
Object.entries(typeAST.properties).map(([key, value]) => [key, astToSchema(value)!])
typeAST.properties.map((property) => [property.key, astToSchema(property.type)!])
)
: {},
};

View File

@ -18,7 +18,7 @@ export function useAvailableVariables(): VariableDeclaration[] {
useEffect(() => {
// 没有作用域时,监听全局变量表
if (!scope) {
const disposable = variableEngine.globalVariableTable.onAnyChange(() => {
const disposable = variableEngine.globalVariableTable.onListOrAnyVarChange(() => {
refresh();
});

View File

@ -70,8 +70,8 @@ export class ScopeAvailableData {
);
/**
*
* @param observer
* listen to any variable update in list
* @param observer
* @returns
*/
onAnyVariableChange(observer: (changedVariable: VariableDeclaration) => void) {
@ -79,7 +79,7 @@ export class ScopeAvailableData {
}
/**
*
* listen to variable list change
* @param observer
* @returns
*/
@ -87,22 +87,34 @@ export class ScopeAvailableData {
return subsToDisposable(this.variables$.subscribe(observer));
}
/**
* @deprecated
*/
protected onDataChangeEmitter = new Emitter<VariableDeclaration[]>();
protected onListOrAnyVarChangeEmitter = new Emitter<VariableDeclaration[]>();
/**
* +
* @deprecated use available.onListOrAnyVarChange instead
*/
public onDataChange = this.onDataChangeEmitter.event;
/**
* listen to variable list change + any variable drilldown change
*/
public onListOrAnyVarChange = this.onListOrAnyVarChangeEmitter.event;
constructor(public readonly scope: Scope) {
this.scope.toDispose.pushAll([
this.onVariableListChange((_variables) => {
this._variables = _variables;
this.memo.clear();
this.onDataChangeEmitter.fire(this._variables);
this.onListOrAnyVarChangeEmitter.fire(this._variables);
}),
this.onAnyVariableChange(() => {
this.onDataChangeEmitter.fire(this._variables);
this.onListOrAnyVarChangeEmitter.fire(this._variables);
}),
Disposable.create(() => {
this.refresh$.complete();

View File

@ -24,14 +24,34 @@ export class ScopeOutputData {
return this.scope.variableEngine.globalVariableTable;
}
/**
* @deprecated use onListOrAnyVarChange instead
*/
get onDataChange() {
return this.variableTable.onDataChange.bind(this.variableTable);
}
/**
* listen to variable list change
*/
get onVariableListChange() {
return this.variableTable.onVariableListChange.bind(this.variableTable);
}
/**
* listen to any variable update in list
*/
get onAnyVariableChange() {
return this.variableTable.onAnyVariableChange.bind(this.variableTable);
}
/**
* listen to variable list change + any variable update in list
*/
get onListOrAnyVarChange() {
return this.variableTable.onListOrAnyVarChange.bind(this.variableTable);
}
protected _hasChanges = false;
constructor(public readonly scope: Scope) {

View File

@ -25,6 +25,7 @@ export interface IVariableTable extends Disposable {
// addVariableToTable(variable: VariableDeclaration): void;
// removeVariableFromTable(key: string): void;
dispose(): void;
onVariableListChange(observer: (variables: VariableDeclaration[]) => void): Disposable;
onAnyVariableChange(observer: (changedVariable: VariableDeclaration) => void): Disposable;
onAnyChange(observer: () => void): Disposable;
onListOrAnyVarChange(observer: () => void): Disposable;
}

View File

@ -29,8 +29,8 @@ export class VariableTable implements IVariableTable {
);
/**
*
* @param observer
* listen to any variable update in list
* @param observer
* @returns
*/
onAnyVariableChange(observer: (changedVariable: VariableDeclaration) => void) {
@ -38,15 +38,27 @@ export class VariableTable implements IVariableTable {
}
/**
*
* listen to variable list change
* @param observer
* @returns
*/
onVariableListChange(observer: (variables: VariableDeclaration[]) => void) {
return subsToDisposable(this.variables$.subscribe(observer));
}
/**
* listen to variable list change + any variable update in list
* @param observer
*/
onAnyChange(observer: () => void) {
onListOrAnyVarChange(observer: () => void) {
const disposables = new DisposableCollection();
disposables.pushAll([this.onDataChange(observer), this.onAnyVariableChange(observer)]);
disposables.pushAll([this.onVariableListChange(observer), this.onAnyVariableChange(observer)]);
return disposables;
}
/**
* @deprecated use onListOrAnyVarChange instead
*/
public onDataChange = this.onDataChangeEmitter.event;
protected _version: number = 0;
@ -54,6 +66,7 @@ export class VariableTable implements IVariableTable {
fireChange() {
this._version++;
this.onDataChangeEmitter.fire();
this.variables$.next(this.variables);
this.parentTable?.fireChange();
}
@ -108,7 +121,6 @@ export class VariableTable implements IVariableTable {
if (this.parentTable) {
(this.parentTable as VariableTable).addVariableToTable(variable);
}
this.variables$.next(this.variables);
}
/**
@ -120,13 +132,15 @@ export class VariableTable implements IVariableTable {
if (this.parentTable) {
(this.parentTable as VariableTable).removeVariableFromTable(key);
}
this.variables$.next(this.variables);
}
dispose(): void {
this.variableKeys.forEach((_key) =>
(this.parentTable as VariableTable)?.removeVariableFromTable(_key)
);
this.parentTable?.fireChange();
this.variables$.complete();
this.variables$.unsubscribe();
this.onDataChangeEmitter.dispose();
}
}