安装依赖
pnpm i @xyflow/react
import {
Controls,
Edge,
MiniMap,
Node,
ReactFlow,
ReactFlowJsonObject,
ReactFlowProvider,
useReactFlow,
} from '@xyflow/react'
import '@xyflow/react/dist/style.css'
import { useRequest } from 'ahooks'
import { Button, Space } from 'antd'
import { useCallback } from 'react'
import { CustomNodesType, NODE_SPACING } from './constant'
import { LayoutNodesProvider, useLayoutNodes } from './context'
import Block from './nodes/Block'
import BlockPanel from './nodes/BlockPanel'
import Box from './nodes/Box'
import Wrapper, { createWrapper } from './nodes/Wrapper'
const nodeTypes = {
[CustomNodesType.Wrapper]: Wrapper,
[CustomNodesType.Box]: Box,
[CustomNodesType.Block]: Block,
}
function LayoutEdit() {
const [nodes, setNodes, onNodesChange] = useLayoutNodes()
const onAddWrapper = useCallback(() => {
const last = nodes[nodes.length - 1]
setNodes((arr) =>
arr.concat(
createWrapper({
position: {
x: last ? last.position.x + (last.width || 0) + NODE_SPACING : NODE_SPACING,
y: last?.position.y || 0,
},
}),
),
)
}, [nodes, setNodes])
const reactFlow = useReactFlow()
useRequest(async () => {
try {
const data: ReactFlowJsonObject<Node, Edge> = JSON.parse(localStorage.getItem('layout-data') || '{}')
reactFlow.setNodes(data.nodes)
reactFlow.setViewport(data.viewport)
} catch (error) {
console.log(error)
}
})
const onSave = useCallback(() => {
const data = reactFlow.toObject()
localStorage.setItem('layout-data', JSON.stringify(data))
}, [reactFlow])
return (
<>
<h1>拉线布局编辑</h1>
<Space>
<Button onClick={onAddWrapper}>添加容器</Button>
<Button onClick={onSave}>保存</Button>
</Space>
<section style={{ height: 600, backgroundColor: '#f0f0f0' }}>
<ReactFlow
fitView
nodeTypes={nodeTypes}
nodes={nodes}
onNodesChange={onNodesChange}
proOptions={{ hideAttribution: true }}
>
<BlockPanel />
<MiniMap />
<Controls />
</ReactFlow>
</section>
</>
)
}
export default function LayoutContextProvider() {
return (
<LayoutNodesProvider>
<ReactFlowProvider>
<LayoutEdit />
</ReactFlowProvider>
</LayoutNodesProvider>
)
}
export enum CustomNodesType {
Wrapper = 'wrapper-node',
Box = 'box-node',
Block = 'block-node',
}
export const NODE_SPACING = 10
export const uid = () => Math.random().toString(36).slice(2, 8)
import { Node, OnNodesChange, useNodesState } from '@xyflow/react'
import { createContext, useContext } from 'react'
const LayoutNodesContext = createContext<
[nodes: Node[], setNodes: React.Dispatch<React.SetStateAction<Node[]>>, onNodesChange: OnNodesChange<Node>]
>([[], () => {}, () => {}])
export function LayoutNodesProvider(props: React.PropsWithChildren) {
const { children } = props
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([])
return <LayoutNodesContext.Provider value={[nodes, setNodes, onNodesChange]}>{children}</LayoutNodesContext.Provider>
}
export function useLayoutNodes() {
return useContext(LayoutNodesContext)
}
import { useCallback, useMemo } from 'react'
import { useLayoutNodes } from './context'
export function useSelectedNodeData<Data extends Record<string, any>>(
nodeType: string,
): [hasSelected: boolean, data: Data | undefined, (values: Data) => void] {
const [nodes, setNodes] = useLayoutNodes()
const selected = useMemo(() => nodes.find((n) => n.type === nodeType && n.selected), [nodeType, nodes])
const hasSelected = useMemo(() => !!selected, [selected])
const data = useMemo(() => selected?.data || {}, [selected]) as unknown as Data | undefined
const setData = useCallback(
(values: Data) => {
if (selected) {
setNodes((arr) => arr.map((n) => (n.id === selected.id ? { ...n, data: { ...n.data, ...values } } : n)))
}
},
[selected, setNodes],
)
return [hasSelected, data, setData]
}
import { NodeResizer, NodeToolbar, Position, useNodeId, type Node, type NodeProps } from '@xyflow/react'
import { memo, useCallback } from 'react'
import { CustomNodesType, NODE_SPACING, uid } from '../constant'
import { useLayoutNodes } from '../context'
import { createBox } from '../nodes/Box'
export type WrapperProps = NodeProps
function Wrapper(props: WrapperProps) {
const [nodes, setNodes] = useLayoutNodes()
const nodeId = useNodeId()
const onAddBox = useCallback(() => {
if (nodeId) {
const children = nodes.filter((n) => n.parentId === nodeId)
const last = children[children.length - 1] as Node | null
setNodes((arr) =>
arr.concat(
createBox({
parentId: nodeId,
position: {
x: last?.position.x || 0,
y: last ? last?.position.y + (last.height || 0) + NODE_SPACING : NODE_SPACING,
},
}),
),
)
}
}, [nodeId, nodes, setNodes])
return (
<>
<NodeResizer handleStyle={{ width: 6, height: 6 }} minWidth={200} minHeight={200} isVisible={props.selected} />
<NodeToolbar isVisible={props.selected} position={Position.Top}>
<button onClick={onAddBox}>添加盒子</button>
</NodeToolbar>
<h1 style={{ textAlign: 'center' }}>Wrapper</h1>
</>
)
}
export default memo(Wrapper)
export function createWrapper(options?: Partial<Node>): Node {
return {
id: uid(),
type: CustomNodesType.Wrapper,
data: {},
position: { x: 0, y: 0 },
width: 320,
height: 200,
style: { backgroundColor: '#fca5a5' },
selected: false,
...options,
}
}
import { NodeProps, NodeResizer, NodeToolbar, Position, useNodeId, type Node } from '@xyflow/react'
import { memo, useCallback } from 'react'
import { CustomNodesType, NODE_SPACING, uid } from '../constant'
import { useLayoutNodes } from '../context'
import { createBlock } from './Block'
export type BoxProps = NodeProps
function Box(props: BoxProps) {
const [nodes, setNodes] = useLayoutNodes()
const nodeId = useNodeId()
const onAddBlock = useCallback(() => {
if (nodeId) {
const children = nodes.filter((n) => n.parentId === nodeId)
const last = children[children.length - 1] as Node | null
setNodes((arr) =>
arr.concat(
createBlock({
parentId: nodeId,
position: {
x: last ? last.position.x + (last.width || 0) + NODE_SPACING : NODE_SPACING,
y: last?.position.y || 0,
},
}),
),
)
}
}, [nodeId, nodes, setNodes])
return (
<>
<NodeResizer handleStyle={{ width: 6, height: 6 }} minWidth={200} minHeight={60} isVisible={props.selected} />
<NodeToolbar isVisible={props.selected} position={Position.Top}>
<button onClick={onAddBlock}>添加块</button>
</NodeToolbar>
<h1>Box</h1>
</>
)
}
export default memo(Box)
export function createBox(options?: Partial<Node>): Node {
return {
id: uid(),
type: CustomNodesType.Box,
data: {},
position: { x: 0, y: 0 },
width: 240,
height: 80,
style: { backgroundColor: '#d9f99d' },
selected: false,
extent: 'parent',
...options,
}
}
import { NodeProps, NodeResizer, type Node } from '@xyflow/react'
import { memo } from 'react'
import { CustomNodesType, uid } from '../constant'
export type BlockProps = NodeProps<Node<{ title?: string }>>
function Block(props: BlockProps) {
return (
<>
<NodeResizer minWidth={30} minHeight={30} handleStyle={{ width: 4, height: 4 }} isVisible={props.selected} />
<h1>{props.data.title || 'no title'}</h1>
</>
)
}
export default memo(Block)
export function createBlock(options?: Partial<Node>): Node {
return {
id: uid(),
type: CustomNodesType.Block,
data: {},
position: { x: 0, y: 0 },
width: 50,
height: 50,
style: { backgroundColor: '#a5f3fc' },
selected: false,
extent: 'parent',
...options,
}
}
import { Panel } from '@xyflow/react'
import { Button, Form, Input } from 'antd'
import { memo, useEffect } from 'react'
import { CustomNodesType } from '../constant'
import { useSelectedNodeData } from '../hooks'
function BlockPanel() {
const [hasSelected, data, setData] = useSelectedNodeData<any>(CustomNodesType.Block)
const [form] = Form.useForm()
useEffect(() => {
form.resetFields()
if (hasSelected && data) {
form.setFieldsValue(data)
}
}, [data, form, hasSelected])
const onFinish = (values: any) => {
setData(values)
form.resetFields()
}
return (
<Panel
position='top-right'
hidden={!hasSelected}
style={{
backgroundColor: '#fff',
boxShadow: '0 0 8px 0 rgba(0,0,0,.2)',
}}
>
<h1>块信息</h1>
<Form form={form} layout='vertical' onFinish={onFinish}>
<Form.Item name='title' label='标题'>
<Input />
</Form.Item>
<Form.Item>
<Button htmlType='submit'>保存</Button>
<Button onClick={() => form.setFieldsValue(data)}>重置</Button>
</Form.Item>
</Form>
</Panel>
)
}
export default memo(BlockPanel)