xyflow 布局功能

490 阅读2分钟

安装依赖

pnpm i @xyflow/react
// index.tsx

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>
  )
}

// constant.ts

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)

// context.tsx

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)
}

// hooks.ts

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]
}

// nodes/Wrapper.tsx

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,
  }
}

// nodes/Box.tsx

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,
  }
}

// nodes/Block.tsx

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,
  }
}

// nodes/BlockPanel.tsx

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)