Add custom tools plugin following Flowgram demo pattern

Created DemoTools plugin with comprehensive toolbar:
- Plugin structure following @flowgram.ai/panel-manager-plugin pattern
- Positioned at top-center matching Flowgram demo screenshot
- Integrated with Flowgram's history, playground, and viewport APIs
- Tools include: fit view, grid view, zoom controls, lock/unlock, comments
- Undo/Redo with real-time state management
- Add Node dropdown with all node types
- Test Run button (green)
- Added 13 new translation keys (zh-CN + en-US)
- Removed old bottom-left tools component
- Plugin automatically renders via layer system

Co-authored-by: whyour <22700758+whyour@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2025-11-23 11:06:14 +00:00
parent 54e2468c7a
commit a1b21e81f6
7 changed files with 405 additions and 4 deletions

View File

@ -623,5 +623,17 @@
"scenario_script_node": "Script Execution", "scenario_script_node": "Script Execution",
"scenario_condition_node": "Condition", "scenario_condition_node": "Condition",
"scenario_delay_node": "Delay", "scenario_delay_node": "Delay",
"scenario_loop_node": "Loop" "scenario_loop_node": "Loop",
"scenario_fit_view": "Fit View",
"scenario_grid_view": "Grid View",
"scenario_zoom_in": "Zoom In",
"scenario_zoom_out": "Zoom Out",
"scenario_fit_canvas": "Fit Canvas",
"scenario_lock": "Lock",
"scenario_unlock": "Unlock",
"scenario_comments": "Comments",
"scenario_undo": "Undo",
"scenario_redo": "Redo",
"scenario_alerts": "Alerts",
"scenario_test_run": "Test Run"
} }

View File

@ -623,5 +623,17 @@
"scenario_script_node": "脚本执行", "scenario_script_node": "脚本执行",
"scenario_condition_node": "条件判断", "scenario_condition_node": "条件判断",
"scenario_delay_node": "延迟", "scenario_delay_node": "延迟",
"scenario_loop_node": "循环" "scenario_loop_node": "循环",
"scenario_fit_view": "适应视图",
"scenario_grid_view": "网格视图",
"scenario_zoom_in": "放大",
"scenario_zoom_out": "缩小",
"scenario_fit_canvas": "适应画布",
"scenario_lock": "锁定",
"scenario_unlock": "解锁",
"scenario_comments": "注释",
"scenario_undo": "撤销",
"scenario_redo": "重做",
"scenario_alerts": "提醒",
"scenario_test_run": "测试运行"
} }

View File

@ -4,7 +4,6 @@ import { DockedPanelLayer } from '@flowgram.ai/panel-manager-plugin';
import '@flowgram.ai/free-layout-editor/index.css'; import '@flowgram.ai/free-layout-editor/index.css';
import { nodeRegistries } from './nodes'; import { nodeRegistries } from './nodes';
import { useEditorProps } from './hooks/use-editor-props'; import { useEditorProps } from './hooks/use-editor-props';
import { FlowgramTools } from './components/tools';
import './editor.less'; import './editor.less';
export interface FlowgramEditorProps { export interface FlowgramEditorProps {
@ -47,7 +46,6 @@ const FlowgramEditor = forwardRef<FlowgramEditorRef, FlowgramEditorProps>(
<div className="flowgram-editor-wrapper"> <div className="flowgram-editor-wrapper">
<EditorRenderer className="flowgram-editor" /> <EditorRenderer className="flowgram-editor" />
<DockedPanelLayer /> <DockedPanelLayer />
<FlowgramTools />
</div> </div>
</FreeLayoutEditorProvider> </FreeLayoutEditorProvider>
</div> </div>

View File

@ -7,6 +7,7 @@ import { createMinimapPlugin } from '@flowgram.ai/minimap-plugin';
import { createPanelManagerPlugin } from '@flowgram.ai/panel-manager-plugin'; import { createPanelManagerPlugin } from '@flowgram.ai/panel-manager-plugin';
import { createHistoryNodePlugin } from '@flowgram.ai/history-node-plugin'; import { createHistoryNodePlugin } from '@flowgram.ai/history-node-plugin';
import { FlowNodeRegistry } from '../nodes/http'; import { FlowNodeRegistry } from '../nodes/http';
import { createToolsPlugin } from '../plugins/tools-plugin';
export function useEditorProps( export function useEditorProps(
initialData: any, initialData: any,
@ -39,6 +40,7 @@ export function useEditorProps(
factories: [], factories: [],
layerProps: {}, layerProps: {},
}), }),
createToolsPlugin(),
], ],
onChange: (data) => { onChange: (data) => {
console.log('Workflow changed:', data); console.log('Workflow changed:', data);

View File

@ -0,0 +1,266 @@
import React, { useState, useEffect } from 'react';
import {
usePlaygroundTools,
useHistoryService,
useNodeService,
} from '@flowgram.ai/free-layout-editor';
import {
DesktopOutlined,
AppstoreOutlined,
ZoomInOutlined,
ZoomOutOutlined,
FullscreenOutlined,
LockOutlined,
UnlockOutlined,
CommentOutlined,
UndoOutlined,
RedoOutlined,
ExclamationCircleOutlined,
PlusOutlined,
PlayCircleOutlined,
} from '@ant-design/icons';
import { Dropdown, Menu, Button, Tooltip } from 'antd';
import intl from 'react-intl-universal';
import { NODE_TYPES } from '../../nodes/constants';
import './styles.less';
export const DemoTools: React.FC = () => {
const playgroundTools = usePlaygroundTools();
const historyService = useHistoryService();
const nodeService = useNodeService();
const [zoom, setZoom] = useState(100);
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const [isLocked, setIsLocked] = useState(false);
useEffect(() => {
if (playgroundTools) {
const updateZoom = () => {
const currentZoom = playgroundTools.getZoom();
setZoom(Math.round(currentZoom * 100));
};
updateZoom();
// Listen for zoom changes
const unsubscribe = playgroundTools.onZoomChange?.(updateZoom);
return () => unsubscribe?.();
}
}, [playgroundTools]);
useEffect(() => {
if (historyService) {
const updateHistory = () => {
setCanUndo(historyService.canUndo());
setCanRedo(historyService.canRedo());
};
updateHistory();
const unsubscribe = historyService.onChange?.(updateHistory);
return () => unsubscribe?.();
}
}, [historyService]);
const handleZoom = (value: number) => {
if (playgroundTools) {
playgroundTools.setZoom(value / 100);
}
};
const handleZoomIn = () => {
if (playgroundTools) {
const current = playgroundTools.getZoom();
playgroundTools.setZoom(Math.min(current + 0.1, 2));
}
};
const handleZoomOut = () => {
if (playgroundTools) {
const current = playgroundTools.getZoom();
playgroundTools.setZoom(Math.max(current - 0.1, 0.5));
}
};
const handleFitView = () => {
if (playgroundTools?.viewport) {
playgroundTools.viewport.fitView();
}
};
const handleUndo = () => {
if (historyService && canUndo) {
historyService.undo();
}
};
const handleRedo = () => {
if (historyService && canRedo) {
historyService.redo();
}
};
const handleAddNode = (type: string) => {
if (nodeService && playgroundTools?.viewport) {
const center = playgroundTools.viewport.getCenter();
nodeService.createNode({
type,
position: center,
});
}
};
const zoomMenu = (
<Menu
onClick={({ key }) => handleZoom(Number(key))}
items={[
{ key: '50', label: '50%' },
{ key: '75', label: '75%' },
{ key: '100', label: '100%' },
{ key: '125', label: '125%' },
{ key: '150', label: '150%' },
{ key: '200', label: '200%' },
]}
/>
);
const addNodeMenu = (
<Menu
onClick={({ key }) => handleAddNode(key)}
items={[
{ key: NODE_TYPES.HTTP, label: intl.get('scenario_http_node'), icon: <PlusOutlined /> },
{ key: NODE_TYPES.SCRIPT, label: intl.get('scenario_script_node'), icon: <PlusOutlined /> },
{ key: NODE_TYPES.CONDITION, label: intl.get('scenario_condition_node'), icon: <PlusOutlined /> },
{ key: NODE_TYPES.DELAY, label: intl.get('scenario_delay_node'), icon: <PlusOutlined /> },
{ key: NODE_TYPES.LOOP, label: intl.get('scenario_loop_node'), icon: <PlusOutlined /> },
]}
/>
);
return (
<div className="demo-tools">
<div className="demo-tools-section">
<Tooltip title={intl.get('scenario_fit_view')}>
<Button
type="text"
icon={<DesktopOutlined />}
onClick={handleFitView}
className="demo-tools-button"
/>
</Tooltip>
<Tooltip title={intl.get('scenario_grid_view')}>
<Button
type="text"
icon={<AppstoreOutlined />}
className="demo-tools-button"
/>
</Tooltip>
<div className="demo-tools-divider" />
<Dropdown overlay={zoomMenu} trigger={['click']}>
<Button type="text" className="demo-tools-button demo-tools-zoom">
{zoom}%
</Button>
</Dropdown>
<Tooltip title={intl.get('scenario_zoom_in')}>
<Button
type="text"
icon={<ZoomInOutlined />}
onClick={handleZoomIn}
className="demo-tools-button"
/>
</Tooltip>
<Tooltip title={intl.get('scenario_zoom_out')}>
<Button
type="text"
icon={<ZoomOutOutlined />}
onClick={handleZoomOut}
className="demo-tools-button"
/>
</Tooltip>
<Tooltip title={intl.get('scenario_fit_canvas')}>
<Button
type="text"
icon={<FullscreenOutlined />}
onClick={handleFitView}
className="demo-tools-button"
/>
</Tooltip>
<div className="demo-tools-divider" />
<Tooltip title={isLocked ? intl.get('scenario_unlock') : intl.get('scenario_lock')}>
<Button
type="text"
icon={isLocked ? <LockOutlined /> : <UnlockOutlined />}
onClick={() => setIsLocked(!isLocked)}
className="demo-tools-button"
/>
</Tooltip>
<Tooltip title={intl.get('scenario_comments')}>
<Button
type="text"
icon={<CommentOutlined />}
className="demo-tools-button"
/>
</Tooltip>
<div className="demo-tools-divider" />
<Tooltip title={intl.get('scenario_undo')}>
<Button
type="text"
icon={<UndoOutlined />}
onClick={handleUndo}
disabled={!canUndo}
className="demo-tools-button"
/>
</Tooltip>
<Tooltip title={intl.get('scenario_redo')}>
<Button
type="text"
icon={<RedoOutlined />}
onClick={handleRedo}
disabled={!canRedo}
className="demo-tools-button"
/>
</Tooltip>
<div className="demo-tools-divider" />
<Tooltip title={intl.get('scenario_alerts')}>
<Button
type="text"
icon={<ExclamationCircleOutlined />}
className="demo-tools-button"
/>
</Tooltip>
<Dropdown overlay={addNodeMenu} trigger={['click']}>
<Button
type="primary"
icon={<PlusOutlined />}
className="demo-tools-add-button"
>
{intl.get('scenario_add_node')}
</Button>
</Dropdown>
<Button
type="primary"
icon={<PlayCircleOutlined />}
className="demo-tools-run-button"
style={{ backgroundColor: '#52c41a', borderColor: '#52c41a' }}
>
{intl.get('scenario_test_run')}
</Button>
</div>
</div>
);
};

View File

@ -0,0 +1,10 @@
import React from 'react';
import { createPlugin } from '@flowgram.ai/free-layout-editor';
import { DemoTools } from './DemoTools';
export const createToolsPlugin = () => {
return createPlugin({
name: 'tools-plugin',
layer: () => <DemoTools />,
});
};

View File

@ -0,0 +1,101 @@
.demo-tools {
position: absolute;
top: 16px;
left: 50%;
transform: translateX(-50%);
z-index: 1000;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
padding: 4px 8px;
display: flex;
align-items: center;
gap: 4px;
.demo-tools-section {
display: flex;
align-items: center;
gap: 4px;
}
.demo-tools-button {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: rgba(0, 0, 0, 0.65);
cursor: pointer;
border-radius: 4px;
transition: all 0.2s;
&:hover:not(:disabled) {
background: rgba(0, 0, 0, 0.06);
color: rgba(0, 0, 0, 0.85);
}
&:disabled {
color: rgba(0, 0, 0, 0.25);
cursor: not-allowed;
}
}
.demo-tools-zoom {
min-width: 56px;
font-size: 14px;
font-weight: 500;
}
.demo-tools-divider {
width: 1px;
height: 24px;
background: rgba(0, 0, 0, 0.1);
margin: 0 4px;
}
.demo-tools-add-button {
height: 36px;
padding: 0 16px;
border-radius: 4px;
font-weight: 500;
display: flex;
align-items: center;
gap: 6px;
}
.demo-tools-run-button {
height: 36px;
padding: 0 16px;
border-radius: 4px;
font-weight: 500;
display: flex;
align-items: center;
gap: 6px;
margin-left: 4px;
}
}
// Dark mode support
html[data-theme='dark'] .demo-tools {
background: #1f1f1f;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.45);
.demo-tools-button {
color: rgba(255, 255, 255, 0.65);
&:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.85);
}
&:disabled {
color: rgba(255, 255, 255, 0.25);
}
}
.demo-tools-divider {
background: rgba(255, 255, 255, 0.15);
}
}