spring-ai-alibaba-graph/spring-ai-alibaba-graph-studio/graph-ui/src/pages/Graph/Design/map.tsx (249 lines of code) (raw):
/*
* Copyright 2024-2026 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
Background,
MiniMap,
OnSelectionChangeParams,
ReactFlow,
useEdgesState,
useNodesState,
useReactFlow,
} from '@xyflow/react';
import {
type MouseEvent as ReactMouseEvent,
MutableRefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
copy,
generateNodeFromKey,
NODE_TITLE,
NODE_TYPE,
paste,
} from '@/components/Nodes/Common/manageNodes';
import FileToolBarNode from '@/pages/Graph/Design/types/FileToolBarNode';
import {
CopyOutlined,
FileAddOutlined,
SnippetsOutlined,
} from '@ant-design/icons';
import { useIntl } from '@umijs/max';
import '@xyflow/react/dist/style.css';
import { Menu } from 'antd';
import './index.less';
import { ContextMenuType, IGraphMenuItems, ISelections, TODO } from './types';
import './xyTheme.less';
const nodeTypes = {
base: FileToolBarNode,
};
const initialNodes: any = [
{
id: '1',
type: NODE_TYPE.START,
sourcePosition: 'right',
targetPosition: 'left',
data: {
label: NODE_TITLE[NODE_TYPE.START],
form: {
name: 1,
},
},
position: { x: 0, y: 0 },
},
{
id: '2',
sourcePosition: 'right',
targetPosition: 'left',
type: NODE_TYPE.START,
data: {
label: NODE_TITLE[NODE_TYPE.START],
form: {
name: '表单数据',
},
},
position: { x: 0, y: 100 },
},
];
const initialEdges = [
{
id: 'e12',
type: 'smoothstep',
source: '1',
target: '2',
animated: true,
},
];
const getLayoutedElements = (nodes: any, edges: any) => {
return { nodes, edges };
};
export const LayoutFlow = () => {
const intl = useIntl();
const graphSubMenuItems = [
{
key: NODE_TYPE.START,
label: intl.formatMessage({ id: 'page.graph.start' }),
},
{
key: NODE_TYPE.LLM,
label: intl.formatMessage({ id: 'page.graph.llm' }),
},
];
const handleContext = (e: MouseEvent) => {
e.preventDefault();
};
useEffect(() => {
document.addEventListener('contextmenu', handleContext);
return () => {
document.removeEventListener('contextmenu', handleContext);
};
}, []);
const reactFlowInstance = useReactFlow();
const { fitView } = reactFlowInstance;
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const [selections, setSelections] = useState<ISelections>();
const onLayout = useCallback(() => {
const layouted = getLayoutedElements(nodes, edges);
setNodes([...layouted.nodes]);
setEdges([...layouted.edges]);
window.requestAnimationFrame(() => {
fitView();
});
}, [nodes, edges]);
const ref: MutableRefObject<any> = useRef(null);
const [graphContextMenu, setGraphContextMenu] =
useState<ContextMenuType>(null);
const onGraphContextMenu = useCallback(
(event: ReactMouseEvent) => {
// Prevent native context menu from showing
event.preventDefault();
// Calculate position of the context menu. We want to make sure it
// doesn't get positioned off-screen.
const pane = ref.current.getBoundingClientRect();
setGraphContextMenu({
top: (event.clientY < pane.height && event.clientY) || event.clientY,
left: (event.clientX < pane.width && event.clientX) || event.clientX,
right: (event.clientX >= pane.width && pane.width - event.clientX) || 0,
bottom:
(event.clientY >= pane.height && pane.height - event.clientY) || 0,
});
console.log(pane);
},
[setGraphContextMenu],
);
const clearGraphContextMenu = useCallback(() => {
setGraphContextMenu(null);
}, [setGraphContextMenu]);
const onSelectionChange = useCallback(
(selections: OnSelectionChangeParams) => {
console.log(selections);
setSelections(selections as ISelections);
},
[setSelections],
);
const getMenuOperationPosition = (e: TODO) => {
const domEvent = e.domEvent as unknown as ReactMouseEvent<
HTMLElement,
MouseEvent
>;
const { x, y } = reactFlowInstance.getViewport();
const scale = reactFlowInstance.getZoom();
const clientX = domEvent.clientX;
const clientY = domEvent.clientY;
const nodePositionX = (clientX - x - 600) / scale;
const nodePositionY = (clientY - y - 200) / scale;
return {
x: nodePositionX,
y: nodePositionY,
};
};
const graphMenuItems: IGraphMenuItems = useMemo(
() => [
{
key: 'create',
icon: <FileAddOutlined />,
label: intl.formatMessage({ id: 'page.graph.createNode' }),
children: graphSubMenuItems.map((item) => ({
label: item?.label ?? '',
key: item?.key,
})),
onClick: (e) => {
const { x, y } = getMenuOperationPosition(e);
const newNode = generateNodeFromKey(e.key as NODE_TYPE, {
x,
y,
});
setNodes([...nodes, newNode]);
},
},
{
key: 'copy',
icon: <CopyOutlined />,
label: intl.formatMessage({ id: 'page.graph.copy' }),
disabled: selections?.nodes.length === 0,
onClick: (e) => {
const { nodes } = selections ?? {};
if (nodes?.length) {
copy(nodes[0], 'node');
}
console.log(e);
},
},
{
key: 'paste',
icon: <SnippetsOutlined />,
label: intl.formatMessage({ id: 'page.graph.paste' }),
onClick: async (e) => {
const { x, y } = getMenuOperationPosition(e);
const newNode = await paste({ x, y });
if (newNode) {
setNodes([...nodes, newNode]);
}
},
},
// {
// key: 'importFromDSL',
// icon: <UploadOutlined />,
// label: '导入 DSL',
// onClick: (e) => {
// console.log(e);
// },
// },
],
[nodes, edges, selections],
);
return (
<ReactFlow
ref={ref}
nodes={nodes}
onLoad={onLayout}
edges={edges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onContextMenu={onGraphContextMenu}
onClick={clearGraphContextMenu}
onSelectionChange={onSelectionChange}
fitView
>
<Background />
<MiniMap></MiniMap>
{graphContextMenu &&
`${graphContextMenu.left}_${graphContextMenu.right}_${graphContextMenu.top}_${graphContextMenu.bottom}`}
{graphContextMenu && (
<Menu
onClick={(e) => {
console.log('click', e);
const menuTargetKey = e.keyPath[e.keyPath.length - 1];
const menuTarget = graphMenuItems.find(
(i) => i?.key === menuTargetKey,
);
menuTarget?.onClick(e);
}}
className="graph-menu"
style={{
left: graphContextMenu.left,
top: graphContextMenu.top,
}}
defaultSelectedKeys={['1']}
defaultOpenKeys={['sub1']}
items={graphMenuItems}
/>
)}
</ReactFlow>
);
};