Forráskód Böngészése

feat: enhance panel manager (#985)

July 2 hónapja
szülő
commit
7094b90039
28 módosított fájl, 369 hozzáadás és 104 törlés
  1. 4 8
      apps/demo-free-layout/src/components/base-node/node-wrapper.tsx
  2. 1 1
      apps/demo-free-layout/src/components/problem-panel/index.ts
  3. 60 16
      apps/demo-free-layout/src/components/problem-panel/problem-panel.tsx
  4. 46 0
      apps/demo-free-layout/src/components/problem-panel/use-watch-validate.ts
  5. 0 2
      apps/demo-free-layout/src/components/sidebar/index.tsx
  6. 4 10
      apps/demo-free-layout/src/components/sidebar/node-form-panel.tsx
  7. 3 5
      apps/demo-free-layout/src/components/testrun/testrun-button/index.tsx
  8. 0 2
      apps/demo-free-layout/src/components/testrun/testrun-panel/index.tsx
  9. 4 11
      apps/demo-free-layout/src/components/testrun/testrun-panel/test-run-panel.tsx
  10. 0 2
      apps/demo-free-layout/src/editor.tsx
  11. 3 4
      apps/demo-free-layout/src/form-components/form-header/index.tsx
  12. 4 8
      apps/demo-free-layout/src/hooks/use-editor-props.tsx
  13. 1 0
      apps/demo-free-layout/src/plugins/index.ts
  14. 10 0
      apps/demo-free-layout/src/plugins/panel-manager-plugin/constants.ts
  15. 44 0
      apps/demo-free-layout/src/plugins/panel-manager-plugin/hooks.ts
  16. 44 0
      apps/demo-free-layout/src/plugins/panel-manager-plugin/index.tsx
  17. 1 0
      apps/demo-free-layout/src/services/index.ts
  18. 55 0
      apps/demo-free-layout/src/services/validate-service.ts
  19. 3 0
      common/config/rush/pnpm-lock.yaml
  20. 1 0
      packages/plugins/panel-manager-plugin/package.json
  21. 12 15
      packages/plugins/panel-manager-plugin/src/components/panel-layer/css.ts
  22. 1 1
      packages/plugins/panel-manager-plugin/src/components/panel-layer/index.ts
  23. 28 14
      packages/plugins/panel-manager-plugin/src/components/panel-layer/panel-layer.tsx
  24. 1 1
      packages/plugins/panel-manager-plugin/src/create-panel-manager-plugin.ts
  25. 31 0
      packages/plugins/panel-manager-plugin/src/hooks/use-global-css.ts
  26. 1 1
      packages/plugins/panel-manager-plugin/src/index.ts
  27. 5 2
      packages/plugins/panel-manager-plugin/src/services/panel-config.ts
  28. 2 1
      packages/plugins/panel-manager-plugin/src/services/panel-layer.ts

+ 4 - 8
apps/demo-free-layout/src/components/base-node/node-wrapper.tsx

@@ -5,15 +5,14 @@
 
 import React, { useState } from 'react';
 
-import { usePanelManager } from '@flowgram.ai/panel-manager-plugin';
 import { WorkflowPortRender } from '@flowgram.ai/free-layout-editor';
 import { useClientContext } from '@flowgram.ai/free-layout-editor';
 
 import { FlowNodeMeta } from '../../typings';
+import { useNodeFormPanel } from '../../plugins/panel-manager-plugin/hooks';
 import { useNodeRenderContext, usePortClick } from '../../hooks';
 import { scrollToView } from './utils';
 import { NodeWrapperStyle } from './styles';
-import { nodeFormPanelFactory } from '../sidebar';
 
 export interface NodeWrapperProps {
   isScrollToView?: boolean;
@@ -35,8 +34,7 @@ export const NodeWrapper: React.FC<NodeWrapperProps> = (props) => {
   const onPortClick = usePortClick();
   const meta = node.getNodeMeta<FlowNodeMeta>();
 
-  const panelManager = usePanelManager();
-
+  const { open } = useNodeFormPanel();
   const portsRender = ports.map((p) => (
     <WorkflowPortRender key={p.id} entity={p} onClick={!readonly ? onPortClick : undefined} />
   ));
@@ -58,10 +56,8 @@ export const NodeWrapper: React.FC<NodeWrapperProps> = (props) => {
         onClick={(e) => {
           selectNode(e);
           if (!isDragging) {
-            panelManager.open(nodeFormPanelFactory.key, 'right', {
-              props: {
-                nodeId: nodeRender.node.id,
-              },
+            open({
+              nodeId: nodeRender.node.id,
             });
             // 可选:将 isScrollToView 设为 true,可以让节点选中后滚动到画布中间
             // Optional: Set isScrollToView to true to scroll the node to the center of the canvas after it is selected.

+ 1 - 1
apps/demo-free-layout/src/components/problem-panel/index.ts

@@ -3,4 +3,4 @@
  * SPDX-License-Identifier: MIT
  */
 
-export { PROBLEM_PANEL, problemPanelFactory, ProblemButton } from './problem-panel';
+export { ProblemButton } from './problem-panel';

+ 60 - 16
apps/demo-free-layout/src/components/problem-panel/problem-panel.tsx

@@ -3,13 +3,20 @@
  * SPDX-License-Identifier: MIT
  */
 
-import { PanelFactory, usePanelManager } from '@flowgram.ai/panel-manager-plugin';
-import { IconButton } from '@douyinfe/semi-ui';
+import { useService, WorkflowSelectService } from '@flowgram.ai/free-layout-editor';
+import { IconButton, Spin, Typography, Avatar } from '@douyinfe/semi-ui';
 import { IconUploadError, IconClose } from '@douyinfe/semi-icons';
-export const PROBLEM_PANEL = 'problem-panel';
+
+import { useProblemPanel, useNodeFormPanel } from '../../plugins/panel-manager-plugin/hooks';
+import { useWatchValidate } from './use-watch-validate';
 
 export const ProblemPanel = () => {
-  const panelManager = usePanelManager();
+  const { results, loading } = useWatchValidate();
+
+  const selectService = useService(WorkflowSelectService);
+
+  const { close: closePanel } = useProblemPanel();
+  const { open: openNodeFormPanel } = useNodeFormPanel();
 
   return (
     <div
@@ -21,34 +28,71 @@ export const ProblemPanel = () => {
         border: '1px solid rgba(82,100,154, 0.13)',
       }}
     >
-      <div style={{ display: 'flex', height: '50px', alignItems: 'center', justifyContent: 'end' }}>
+      <div
+        style={{
+          display: 'flex',
+          height: '50px',
+          alignItems: 'center',
+          justifyContent: 'space-between',
+          padding: '0 12px',
+        }}
+      >
+        <div style={{ display: 'flex', alignItems: 'center', columnGap: '4px', height: '100%' }}>
+          <Typography.Text strong>Problem</Typography.Text>
+          {loading && <Spin size="small" style={{ lineHeight: '0' }} />}
+        </div>
         <IconButton
           type="tertiary"
           theme="borderless"
           icon={<IconClose />}
-          onClick={() => panelManager.close(PROBLEM_PANEL)}
+          onClick={() => closePanel()}
         />
       </div>
-      <div>problem panel</div>
+      <div style={{ padding: '12px', display: 'flex', flexDirection: 'column', rowGap: '4px' }}>
+        {results.map((i) => (
+          <div
+            key={i.node.id}
+            style={{
+              display: 'flex',
+              alignItems: 'center',
+              border: '1px solid #999',
+              borderRadius: '4px',
+              padding: '0 4px',
+              cursor: 'pointer',
+            }}
+            onClick={() => {
+              selectService.selectNodeAndScrollToView(i.node);
+              openNodeFormPanel({ nodeId: i.node.id });
+            }}
+          >
+            <Avatar
+              style={{ flexShrink: '0' }}
+              src={i.node.getNodeRegistry().info.icon}
+              size="24px"
+              shape="square"
+            />
+            <div style={{ marginLeft: '8px' }}>
+              <Typography.Text>{i.node.form?.values.title}</Typography.Text>
+              <br />
+              <Typography.Text type="danger">
+                {i.feedbacks.map((i) => i.feedbackText).join(', ')}
+              </Typography.Text>
+            </div>
+          </div>
+        ))}
+      </div>
     </div>
   );
 };
 
-export const problemPanelFactory: PanelFactory<void> = {
-  key: PROBLEM_PANEL,
-  defaultSize: 200,
-  render: () => <ProblemPanel />,
-};
-
 export const ProblemButton = () => {
-  const panelManager = usePanelManager();
-
+  const { open } = useProblemPanel();
   return (
     <IconButton
       type="tertiary"
       theme="borderless"
       icon={<IconUploadError />}
-      onClick={() => panelManager.open(PROBLEM_PANEL, 'bottom')}
+      onClick={() => open()}
     />
   );
 };

+ 46 - 0
apps/demo-free-layout/src/components/problem-panel/use-watch-validate.ts

@@ -0,0 +1,46 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { useCallback, useEffect, useState } from 'react';
+
+import { debounce } from 'lodash-es';
+import { useService, WorkflowDocument } from '@flowgram.ai/free-layout-editor';
+
+import { ValidateService, type ValidateResult } from '../../services/validate-service';
+
+const DEBOUNCE_TIME = 1000;
+
+export const useWatchValidate = () => {
+  const [results, setResults] = useState<ValidateResult[]>([]);
+  const [loading, setLoading] = useState(false);
+
+  const validateService = useService(ValidateService);
+  const workflowDocument = useService(WorkflowDocument);
+
+  const debounceValidate = useCallback(
+    debounce(async () => {
+      const res = await validateService.validateNodes();
+      validateService.validateLines();
+      setResults(res);
+      setLoading(false);
+    }, DEBOUNCE_TIME),
+    [validateService]
+  );
+
+  const validate = () => {
+    setLoading(true);
+    debounceValidate();
+  };
+
+  useEffect(() => {
+    validate();
+    const disposable = workflowDocument.onContentChange(() => {
+      validate();
+    });
+    return () => disposable.dispose();
+  }, []);
+
+  return { results, loading };
+};

+ 0 - 2
apps/demo-free-layout/src/components/sidebar/index.tsx

@@ -2,5 +2,3 @@
  * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
  * SPDX-License-Identifier: MIT
  */
-
-export { nodeFormPanelFactory } from './node-form-panel';

+ 4 - 10
apps/demo-free-layout/src/components/sidebar/node-form-panel.tsx

@@ -5,7 +5,6 @@
 
 import { useCallback, useEffect, startTransition } from 'react';
 
-import { type PanelFactory, usePanelManager } from '@flowgram.ai/panel-manager-plugin';
 import {
   PlaygroundEntityContext,
   useRefresh,
@@ -13,6 +12,7 @@ import {
 } from '@flowgram.ai/free-layout-editor';
 
 import { FlowNodeMeta } from '../../typings';
+import { useNodeFormPanel } from '../../plugins/panel-manager-plugin/hooks';
 import { IsSidebarContext } from '../../context';
 import { SidebarNodeRenderer } from './sidebar-node-renderer';
 
@@ -21,13 +21,13 @@ export interface NodeFormPanelProps {
 }
 
 export const NodeFormPanel: React.FC<NodeFormPanelProps> = ({ nodeId }) => {
-  const panelManager = usePanelManager();
   const { selection, playground, document } = useClientContext();
   const refresh = useRefresh();
+  const { close: closePanel } = useNodeFormPanel();
   const handleClose = useCallback(() => {
     // Sidebar delayed closing
     startTransition(() => {
-      panelManager.close(nodeFormPanelFactory.key);
+      closePanel();
     });
   }, []);
   const node = document.getNode(nodeId);
@@ -65,7 +65,7 @@ export const NodeFormPanel: React.FC<NodeFormPanelProps> = ({ nodeId }) => {
   useEffect(() => {
     if (node) {
       const toDispose = node.onDispose(() => {
-        panelManager.close(nodeFormPanelFactory.key);
+        closePanel();
       });
       return () => toDispose.dispose();
     }
@@ -92,9 +92,3 @@ export const NodeFormPanel: React.FC<NodeFormPanelProps> = ({ nodeId }) => {
     </IsSidebarContext.Provider>
   );
 };
-
-export const nodeFormPanelFactory: PanelFactory<NodeFormPanelProps> = {
-  key: 'node-form-panel',
-  defaultSize: 500,
-  render: (props: NodeFormPanelProps) => <NodeFormPanel {...props} />,
-};

+ 3 - 5
apps/demo-free-layout/src/components/testrun/testrun-button/index.tsx

@@ -5,25 +5,23 @@
 
 import { useState, useEffect, useCallback } from 'react';
 
-import { usePanelManager } from '@flowgram.ai/panel-manager-plugin';
 import { useClientContext, FlowNodeEntity } from '@flowgram.ai/free-layout-editor';
 import { Button, Badge } from '@douyinfe/semi-ui';
 import { IconPlay } from '@douyinfe/semi-icons';
 
-import { testRunPanelFactory } from '../testrun-panel/test-run-panel';
+import { useTestRunFormPanel } from '../../../plugins/panel-manager-plugin/hooks';
 
 import styles from './index.module.less';
 
 export function TestRunButton(props: { disabled: boolean }) {
   const [errorCount, setErrorCount] = useState(0);
   const clientContext = useClientContext();
-  const panelManager = usePanelManager();
   const updateValidateData = useCallback(() => {
     const allForms = clientContext.document.getAllNodes().map((node) => node.form);
     const count = allForms.filter((form) => form?.state.invalid).length;
     setErrorCount(count);
   }, [clientContext]);
-
+  const { open: openPanel } = useTestRunFormPanel();
   /**
    * Validate all node and Save
    */
@@ -31,7 +29,7 @@ export function TestRunButton(props: { disabled: boolean }) {
     const allForms = clientContext.document.getAllNodes().map((node) => node.form);
     await Promise.all(allForms.map(async (form) => form?.validate()));
     console.log('>>>>> save data: ', clientContext.document.toJSON());
-    panelManager.open(testRunPanelFactory.key, 'right');
+    openPanel();
   }, [clientContext]);
 
   /**

+ 0 - 2
apps/demo-free-layout/src/components/testrun/testrun-panel/index.tsx

@@ -2,5 +2,3 @@
  * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
  * SPDX-License-Identifier: MIT
  */
-
-export { testRunPanelFactory } from './test-run-panel';

+ 4 - 11
apps/demo-free-layout/src/components/testrun/testrun-panel/test-run-panel.tsx

@@ -7,7 +7,6 @@ import { FC, useState, useEffect } from 'react';
 
 import classnames from 'classnames';
 import { WorkflowInputs, WorkflowOutputs } from '@flowgram.ai/runtime-interface';
-import { type PanelFactory, usePanelManager } from '@flowgram.ai/panel-manager-plugin';
 import { useService } from '@flowgram.ai/free-layout-editor';
 import { Button, Switch } from '@douyinfe/semi-ui';
 import { IconClose, IconPlay, IconSpin } from '@douyinfe/semi-icons';
@@ -16,16 +15,16 @@ import { TestRunJsonInput } from '../testrun-json-input';
 import { TestRunForm } from '../testrun-form';
 import { NodeStatusGroup } from '../node-status-bar/group';
 import { WorkflowRuntimeService } from '../../../plugins/runtime-plugin/runtime-service';
+import { useTestRunFormPanel } from '../../../plugins/panel-manager-plugin/hooks';
 import { IconCancel } from '../../../assets/icon-cancel';
 
 import styles from './index.module.less';
 
-interface TestRunSidePanelProps {}
+export interface TestRunSidePanelProps {}
 
 export const TestRunSidePanel: FC<TestRunSidePanelProps> = () => {
   const runtimeService = useService(WorkflowRuntimeService);
-
-  const panelManager = usePanelManager();
+  const { close: closePanel } = useTestRunFormPanel();
   const [isRunning, setRunning] = useState(false);
   const [values, setValues] = useState<Record<string, unknown>>({});
   const [errors, setErrors] = useState<string[]>();
@@ -65,7 +64,7 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = () => {
     await runtimeService.taskCancel();
     setValues({});
     setRunning(false);
-    panelManager.close(testRunPanelFactory.key);
+    closePanel();
   };
 
   const renderRunning = (
@@ -154,9 +153,3 @@ export const TestRunSidePanel: FC<TestRunSidePanelProps> = () => {
     </div>
   );
 };
-
-export const testRunPanelFactory: PanelFactory<TestRunSidePanelProps> = {
-  key: 'test-run-panel',
-  defaultSize: 400,
-  render: () => <TestRunSidePanel />,
-};

+ 0 - 2
apps/demo-free-layout/src/editor.tsx

@@ -10,7 +10,6 @@ import './styles/index.css';
 import { nodeRegistries } from './nodes';
 import { initialData } from './initial-data';
 import { useEditorProps } from './hooks';
-import { DemoTools } from './components/tools';
 
 export const Editor = () => {
   const editorProps = useEditorProps(initialData, nodeRegistries);
@@ -20,7 +19,6 @@ export const Editor = () => {
         <div className="demo-container">
           <EditorRenderer className="demo-editor" />
         </div>
-        <DemoTools />
       </FreeLayoutEditorProvider>
     </div>
   );

+ 3 - 4
apps/demo-free-layout/src/form-components/form-header/index.tsx

@@ -5,15 +5,14 @@
 
 import { useState, useEffect } from 'react';
 
-import { usePanelManager } from '@flowgram.ai/panel-manager-plugin';
 import { useClientContext, CommandService } from '@flowgram.ai/free-layout-editor';
 import { Button } from '@douyinfe/semi-ui';
 import { IconClose, IconSmallTriangleDown, IconSmallTriangleLeft } from '@douyinfe/semi-icons';
 
 import { toggleLoopExpanded } from '../../utils';
 import { FlowCommandId } from '../../shortcuts';
+import { useNodeFormPanel } from '../../plugins/panel-manager-plugin/hooks';
 import { useIsSidebar, useNodeRenderContext } from '../../hooks';
-import { nodeFormPanelFactory } from '../../components/sidebar';
 import { NodeMenu } from '../../components/node-menu';
 import { getIcon } from './utils';
 import { TitleInput } from './title-input';
@@ -24,16 +23,16 @@ export function FormHeader() {
   const [titleEdit, updateTitleEdit] = useState<boolean>(false);
   const ctx = useClientContext();
   const isSidebar = useIsSidebar();
-  const panelManager = usePanelManager();
   const handleExpand = (e: React.MouseEvent) => {
     toggleExpand();
     e.stopPropagation(); // Disable clicking prevents the sidebar from opening
   };
+  const { close: closePanel } = useNodeFormPanel();
   const handleDelete = () => {
     ctx.get<CommandService>(CommandService).executeCommand(FlowCommandId.DELETE, [node]);
   };
   const handleClose = () => {
-    panelManager.close(nodeFormPanelFactory.key);
+    closePanel();
   };
   useEffect(() => {
     // 折叠 loop 子节点

+ 4 - 8
apps/demo-free-layout/src/hooks/use-editor-props.tsx

@@ -7,7 +7,6 @@
 import { useMemo } from 'react';
 
 import { debounce } from 'lodash-es';
-import { createPanelManagerPlugin } from '@flowgram.ai/panel-manager-plugin';
 import { createMinimapPlugin } from '@flowgram.ai/minimap-plugin';
 import { createFreeStackPlugin } from '@flowgram.ai/free-stack-plugin';
 import { createFreeSnapPlugin } from '@flowgram.ai/free-snap-plugin';
@@ -25,20 +24,18 @@ import { createContainerNodePlugin } from '@flowgram.ai/free-container-plugin';
 import { canContainNode, onDragLineEnd } from '../utils';
 import { FlowNodeRegistry, FlowDocumentJSON } from '../typings';
 import { shortcuts } from '../shortcuts';
-import { CustomService } from '../services';
+import { CustomService, ValidateService } from '../services';
 import { GetGlobalVariableSchema } from '../plugins/variable-panel-plugin';
 import { WorkflowRuntimeService } from '../plugins/runtime-plugin/runtime-service';
 import {
   createRuntimePlugin,
   createContextMenuPlugin,
   createVariablePanelPlugin,
+  createPanelManagerPlugin,
 } from '../plugins';
 import { defaultFormMeta } from '../nodes/default-form-meta';
 import { WorkflowNodeType } from '../nodes';
-import { testRunPanelFactory } from '../components/testrun/testrun-panel';
-import { nodeFormPanelFactory } from '../components/sidebar';
 import { SelectorBoxPopover } from '../components/selector-box-popover';
-import { problemPanelFactory } from '../components/problem-panel';
 import { BaseNode, CommentRender, GroupNodeRender, LineAddButton, NodePanel } from '../components';
 
 export function useEditorProps(
@@ -247,6 +244,7 @@ export function useEditorProps(
        */
       onBind: ({ bind }) => {
         bind(CustomService).toSelf().inSingletonScope();
+        bind(ValidateService).toSelf().inSingletonScope();
       },
       /**
        * Playground init
@@ -385,9 +383,7 @@ export function useEditorProps(
           initialData: initialData.globalVariable,
         }),
         /** Float layout plugin */
-        createPanelManagerPlugin({
-          factories: [nodeFormPanelFactory, testRunPanelFactory, problemPanelFactory],
-        }),
+        createPanelManagerPlugin(),
       ],
     }),
     []

+ 1 - 0
apps/demo-free-layout/src/plugins/index.ts

@@ -6,3 +6,4 @@
 export { createContextMenuPlugin } from './context-menu-plugin';
 export { createRuntimePlugin } from './runtime-plugin';
 export { createVariablePanelPlugin } from './variable-panel-plugin';
+export { createPanelManagerPlugin } from './panel-manager-plugin';

+ 10 - 0
apps/demo-free-layout/src/plugins/panel-manager-plugin/constants.ts

@@ -0,0 +1,10 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+export enum PanelType {
+  NodeFormPanel = 'nodeFormPanel',
+  TestRunFormPanel = 'testRunFormPanel',
+  ProblemPanel = 'problemPanel',
+}

+ 44 - 0
apps/demo-free-layout/src/plugins/panel-manager-plugin/hooks.ts

@@ -0,0 +1,44 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { usePanelManager } from '@flowgram.ai/panel-manager-plugin';
+
+import type { NodeFormPanelProps } from '../../components/sidebar/node-form-panel';
+import { PanelType } from './constants';
+
+export const useNodeFormPanel = () => {
+  const panelManager = usePanelManager();
+
+  const open = (props: NodeFormPanelProps) => {
+    panelManager.open(PanelType.NodeFormPanel, 'right', {
+      props: props,
+    });
+  };
+  const close = () => panelManager.close(PanelType.NodeFormPanel);
+
+  return { open, close };
+};
+
+export const useTestRunFormPanel = () => {
+  const panelManager = usePanelManager();
+
+  const open = () => {
+    panelManager.open(PanelType.TestRunFormPanel, 'right');
+  };
+  const close = () => panelManager.close(PanelType.TestRunFormPanel);
+
+  return { open, close };
+};
+
+export const useProblemPanel = () => {
+  const panelManager = usePanelManager();
+
+  const open = () => {
+    panelManager.open(PanelType.ProblemPanel, 'bottom');
+  };
+  const close = () => panelManager.close(PanelType.ProblemPanel);
+
+  return { open, close };
+};

+ 44 - 0
apps/demo-free-layout/src/plugins/panel-manager-plugin/index.tsx

@@ -0,0 +1,44 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import {
+  createPanelManagerPlugin as create,
+  PanelFactory,
+} from '@flowgram.ai/panel-manager-plugin';
+
+import { DemoTools } from '../../components/tools';
+import {
+  TestRunSidePanel,
+  TestRunSidePanelProps,
+} from '../../components/testrun/testrun-panel/test-run-panel';
+import { NodeFormPanel, NodeFormPanelProps } from '../../components/sidebar/node-form-panel';
+import { ProblemPanel } from '../../components/problem-panel/problem-panel';
+import { PanelType } from './constants';
+
+const nodeFormPanelFactory: PanelFactory<NodeFormPanelProps> = {
+  key: PanelType.NodeFormPanel,
+  defaultSize: 500,
+  render: (props: NodeFormPanelProps) => <NodeFormPanel {...props} />,
+};
+
+const testRunPanelFactory: PanelFactory<TestRunSidePanelProps> = {
+  key: PanelType.TestRunFormPanel,
+  defaultSize: 400,
+  render: () => <TestRunSidePanel />,
+};
+
+const problemPanelFactory: PanelFactory<void> = {
+  key: PanelType.ProblemPanel,
+  defaultSize: 200,
+  render: () => <ProblemPanel />,
+};
+
+export const createPanelManagerPlugin = () =>
+  create({
+    factories: [nodeFormPanelFactory, testRunPanelFactory, problemPanelFactory],
+    layerProps: {
+      children: <DemoTools />,
+    },
+  });

+ 1 - 0
apps/demo-free-layout/src/services/index.ts

@@ -4,3 +4,4 @@
  */
 
 export { CustomService } from './custom-service';
+export { ValidateService } from './validate-service';

+ 55 - 0
apps/demo-free-layout/src/services/validate-service.ts

@@ -0,0 +1,55 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import {
+  inject,
+  injectable,
+  WorkflowLinesManager,
+  FlowNodeEntity,
+  FlowNodeFormData,
+  FormModelV2,
+  WorkflowDocument,
+} from '@flowgram.ai/free-layout-editor';
+
+export interface ValidateResult {
+  node: FlowNodeEntity;
+  feedbacks: any[];
+}
+
+@injectable()
+export class ValidateService {
+  @inject(WorkflowLinesManager)
+  protected readonly linesManager: WorkflowLinesManager;
+
+  @inject(WorkflowDocument) private readonly document: WorkflowDocument;
+
+  validateLines() {
+    const allLines = this.linesManager.getAllLines();
+    allLines.forEach((line) => line.validate());
+  }
+
+  async validateNode(node: FlowNodeEntity) {
+    const feedbacks = await node
+      .getData(FlowNodeFormData)
+      .getFormModel<FormModelV2>()
+      .validateWithFeedbacks();
+    return feedbacks;
+  }
+
+  async validateNodes(): Promise<ValidateResult[]> {
+    const nodes = this.document.getAssociatedNodes();
+    const results = await Promise.all(
+      nodes.map(async (node) => {
+        const feedbacks = await this.validateNode(node);
+        return {
+          feedbacks,
+          node,
+        };
+      })
+    );
+
+    return results.filter((i) => i.feedbacks.length);
+  }
+}

+ 3 - 0
common/config/rush/pnpm-lock.yaml

@@ -3872,6 +3872,9 @@ importers:
       '@flowgram.ai/utils':
         specifier: workspace:*
         version: link:../../common/utils
+      clsx:
+        specifier: ^1.1.1
+        version: 1.2.1
       inversify:
         specifier: ^6.0.1
         version: 6.2.2(reflect-metadata@0.2.2)

+ 1 - 0
packages/plugins/panel-manager-plugin/package.json

@@ -24,6 +24,7 @@
   },
   "dependencies": {
     "inversify": "^6.0.1",
+    "clsx": "^1.1.1",
     "@flowgram.ai/core": "workspace:*",
     "@flowgram.ai/utils": "workspace:*"
   },

+ 12 - 15
packages/plugins/panel-manager-plugin/src/components/panel-layer/css.ts

@@ -7,23 +7,20 @@ export const globalCSS = `
   .gedit-flow-panel-layer * {
     box-sizing: border-box;
   }
+  .gedit-flow-panel-layer-wrap {
+    pointer-events: none;
+    position: absolute;
+    top: 0;
+    left: 0;
+    display: flex;
+    column-gap: 4px;
+    width: 100%;
+    height: 100%;
+    padding: 4px;
+    overflow: hidden;
+  }
 `;
 
-export const panelLayer: React.CSSProperties = {
-  pointerEvents: 'none',
-  position: 'absolute',
-  top: 0,
-  left: 0,
-
-  display: 'flex',
-  columnGap: '4px',
-  width: '100%',
-  height: '100%',
-  padding: '4px',
-  boxSizing: 'border-box',
-  overflow: 'hidden',
-};
-
 export const leftArea: React.CSSProperties = {
   width: '100%',
   minWidth: 0,

+ 1 - 1
packages/plugins/panel-manager-plugin/src/components/panel-layer/index.ts

@@ -3,4 +3,4 @@
  * SPDX-License-Identifier: MIT
  */
 
-export { PanelLayer } from './panel-layer';
+export { PanelLayer, type PanelLayerProps } from './panel-layer';

+ 28 - 14
packages/plugins/panel-manager-plugin/src/components/panel-layer/panel-layer.tsx

@@ -3,22 +3,36 @@
  * SPDX-License-Identifier: MIT
  */
 
+import clsx from 'clsx';
+
+import { useGlobalCSS } from '../../hooks/use-global-css';
 import { FloatPanel } from './float-panel';
-import { panelLayer, leftArea, rightArea, mainArea, bottomArea, globalCSS } from './css';
+import { leftArea, rightArea, mainArea, bottomArea, globalCSS } from './css';
+
+export type PanelLayerProps = React.PropsWithChildren<{
+  className?: string;
+  style?: React.CSSProperties;
+}>;
 
-export const PanelLayer: React.FC<React.PropsWithChildren> = ({ children }) => (
-  <div style={panelLayer}>
-    <style dangerouslySetInnerHTML={{ __html: globalCSS }} />
-    <div className="gedit-flow-panel-left-area" style={leftArea}>
-      <div className="gedit-flow-panel-main-area" style={mainArea}>
-        {children}
+export const PanelLayer: React.FC<PanelLayerProps> = ({ className, style, children }) => {
+  useGlobalCSS({
+    cssText: globalCSS,
+    id: 'flow-panel-layer-css',
+  });
+
+  return (
+    <div className={clsx('gedit-flow-panel-layer-wrap', className)} style={style}>
+      <div className="gedit-flow-panel-left-area" style={leftArea}>
+        <div className="gedit-flow-panel-main-area" style={mainArea}>
+          {children}
+        </div>
+        <div className="gedit-flow-panel-bottom-area" style={bottomArea}>
+          <FloatPanel area="bottom" />
+        </div>
       </div>
-      <div className="gedit-flow-panel-bottom-area" style={bottomArea}>
-        <FloatPanel area="bottom" />
+      <div className="gedit-flow-panel-right-area" style={rightArea}>
+        <FloatPanel area="right" />
       </div>
     </div>
-    <div className="gedit-flow-panel-right-area" style={rightArea}>
-      <FloatPanel area="right" />
-    </div>
-  </div>
-);
+  );
+};

+ 1 - 1
packages/plugins/panel-manager-plugin/src/create-panel-manager-plugin.ts

@@ -13,7 +13,7 @@ export const createPanelManagerPlugin = definePluginCreator<Partial<PanelManager
     bind(PanelManager).to(PanelManager).inSingletonScope();
     bind(PanelManagerConfig).toConstantValue(defineConfig(opt));
   },
-  onInit(ctx, opt) {
+  onInit(ctx) {
     ctx.playground.registerLayer(PanelLayer);
     const panelManager = ctx.container.get<PanelManager>(PanelManager);
     panelManager.init();

+ 31 - 0
packages/plugins/panel-manager-plugin/src/hooks/use-global-css.ts

@@ -0,0 +1,31 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { useEffect } from 'react';
+
+interface UseGlobalCSSOptions {
+  cssText: string;
+  id: string;
+  cleanup?: boolean;
+}
+
+export const useGlobalCSS = ({ cssText, id, cleanup }: UseGlobalCSSOptions) => {
+  useEffect(() => {
+    /** SSR safe */
+    if (typeof document === 'undefined') return;
+
+    if (document.getElementById(id)) return;
+
+    const style = document.createElement('style');
+    style.id = id;
+    style.textContent = cssText;
+    document.head.appendChild(style);
+
+    return () => {
+      const existing = document.getElementById(id);
+      if (existing && cleanup) existing.remove();
+    };
+  }, [id]);
+};

+ 1 - 1
packages/plugins/panel-manager-plugin/src/index.ts

@@ -7,7 +7,7 @@
 export { createPanelManagerPlugin } from './create-panel-manager-plugin';
 
 /** services */
-export { PanelManager } from './services';
+export { PanelManager, type PanelManagerConfig } from './services';
 
 /** react hooks */
 export { usePanelManager } from './hooks/use-panel-manager';

+ 5 - 2
packages/plugins/panel-manager-plugin/src/services/panel-config.ts

@@ -6,13 +6,15 @@
 import { PluginContext } from '@flowgram.ai/core';
 
 import type { PanelFactory, PanelConfig } from '../types';
+import type { PanelLayerProps } from '../components/panel-layer';
 
 export interface PanelManagerConfig {
   factories: PanelFactory<any>[];
   right: PanelConfig;
   bottom: PanelConfig;
-  getPopupContainer: (ctx: PluginContext) => HTMLElement; // default playground.node.parentElement
   autoResize: boolean;
+  layerProps: PanelLayerProps;
+  getPopupContainer: (ctx: PluginContext) => HTMLElement; // default playground.node.parentElement
 }
 
 export const PanelManagerConfig = Symbol('PanelManagerConfig');
@@ -26,8 +28,9 @@ export const defineConfig = (config: Partial<PanelManagerConfig>) => {
       max: 1,
     },
     factories: [],
-    getPopupContainer: (ctx: PluginContext) => ctx.playground.node.parentNode as HTMLElement,
     autoResize: true,
+    layerProps: {},
+    getPopupContainer: (ctx: PluginContext) => ctx.playground.node.parentNode as HTMLElement,
   };
   return {
     ...defaultConfig,

+ 2 - 1
packages/plugins/panel-manager-plugin/src/services/panel-layer.ts

@@ -45,7 +45,8 @@ export class PanelLayer extends Layer {
 
   render(): JSX.Element {
     if (!this.layout) {
-      this.layout = createElement(PanelLayerComp);
+      const { children, ...layoutProps } = this.panelConfig.layerProps;
+      this.layout = createElement(PanelLayerComp, layoutProps, children);
     }
     return ReactDOM.createPortal(this.layout, this.panelRoot);
   }