高亮!选中!揭秘低代码画布区的魔法交互术🪄

153 阅读4分钟

引言

你是否曾在低代码平台的画布区,像福尔摩斯一样追踪鼠标的踪迹,想知道组件是如何“亮起来”的?🕵️‍♂️别眨眼,今天就带你揭秘 HoverMask 和点击选中背后的魔法!✨

一、核心原理概述

在低代码平台的画布区,用户和组件的互动就像一场精彩的舞蹈:

  • 移动蒙层(HoverMask):当鼠标悄悄滑过组件时,当前组件会自动高亮,仿佛在对你眨眼,提升你的可视化编辑体验。🖱️💡
  • 点击事件与选中蒙层(SelectedMask):轻轻一点,组件就被选中,还会弹出操作浮层,方便你随时编辑属性或一键删除,操作丝滑到飞起!🖱️🎯

这些交互效果的实现,离不开背后精妙的事件监听、蒙层定位和状态管理。每一次鼠标的移动和点击,系统都在悄悄计算,精准地为你高亮和选中目标组件,让你拥有“所见即所得”的神奇体验。

是不是觉得技术也可以很有趣?继续往下看,更多细节和代码分析等你来探索!😎🚀

  • 想要获取该项目的全部代码?可直接访问此仓库链接:内附项目体验地址
    🔗 github.com/LZY-Ricardo…

二、核心组件源码大揭秘

1. EditArea —— 画布的守门员

EditArea 组件是整个画布区的“守门员”,负责承载所有可编辑的组件,并统一管理鼠标事件。让我们来看看它的真身:

import React, { useEffect, useState } from 'react'
import { useComponentsStore } from '../../stores/components'
import type { Component } from '../../stores/components'
import { useComponentConfigStore } from '../../stores/component-config'
import HoverMask from '../HoverMask'
import SelectedMask from '../SelectedMask'

export default function EditArea() {
  const { components, setCurComponentId, curComponentId } = useComponentsStore()
  const { componentConfig } = useComponentConfigStore()
  const [hoverComponentId, setHoverComponentId] = useState<number>()

  function renderComponents(components: Component[]): React.ReactNode {
    return components.map((component: Component) => {
      const config = componentConfig?.[component.name]
      if (!config?.dev) { // 没有对应的组件,比如:'Page'
        return null
      }
      // 渲染组件
      return React.createElement(
        config.dev,
        {
          key: component.id,
          id: component.id,
          name: component.name,
          styles: component.styles,
          ...config.defaultProps,
          ...component.props
        },
        renderComponents(component.children || [])  // 递归渲染整个 json 树
      )
    })
  }

  const handleMouseOver: React.MouseEventHandler = (e) => {
    const path = e.nativeEvent.composedPath()
    for (let i = 0; i < path.length; i++) {
      const ele = path[i] as HTMLElement
      const componentId = ele.dataset && ele.dataset.componentId
      if (componentId) {
        setHoverComponentId(+componentId)
        return
      }
    }
  }

  const handleClick: React.MouseEventHandler = (e) => {
    const path = e.nativeEvent.composedPath()
    for (let i = 0; i < path.length; i++) {
      const ele = path[i] as HTMLElement
      const componentId = ele.dataset && ele.dataset.componentId
      if (componentId) {
        setCurComponentId(+componentId)
        return
      }
    } 
  }

  return (
    <div className='h-[100%] edit-area' 
      onMouseOver={handleMouseOver} 
      onMouseLeave={() => setHoverComponentId(undefined)}
      onClick={handleClick}
    >
      {renderComponents(components)}
      {hoverComponentId && hoverComponentId !== curComponentId && (
        <HoverMask 
          componentId={hoverComponentId} 
          containerClassName='edit-area'
          portalWrapperClassName='portal-wrapper'
        />
      )}
      { curComponentId && (
        <SelectedMask 
          componentId={curComponentId} 
          containerClassName='edit-area'
          portalWrapperClassName='portal-wrapper'
        />
      )}
      <div className="portal-wrapper"></div>
    </div>
  )
}

分析:

  • 组件通过 renderComponents 递归渲染所有子组件,支持复杂嵌套结构,像套娃一样层层递进。
  • handleMouseOverhandleClick 利用事件冒泡和 composedPath,精准捕捉鼠标悬停和点击的目标组件,堪比“鼠标侦探”。
  • 悬停和选中状态分别由 HoverMaskSelectedMask 控制,互不干扰,配合默契。
  • portal-wrapper 作为蒙层的挂载点,保证蒙层永远在画布之上,视觉效果一级棒。

2. HoverMask —— 高亮的魔法披风

HoverMask 组件负责在鼠标悬停时,为目标组件披上一层高亮的“魔法披风”。来看看它的魔法代码:

import { useEffect, useMemo, useState } from 'react'
import { createPortal} from 'react-dom'
import { getComponentById, useComponentsStore } from '../../stores/components'

interface HoverMaskProps {
  containerClassName: string
  componentId: number,
  portalWrapperClassName: string
}

export default function HoverMask({ containerClassName, componentId, portalWrapperClassName }: HoverMaskProps) {
  const { components } = useComponentsStore()

  const [position, setPosition] = useState({
    top: 0,
    left: 0,
    width: 0,
    height: 0,
    labelTop: 0,
    labelLeft: 0,
  })

  useEffect(() => {
    updatePosition()
  }, [componentId])

  function updatePosition() {
    if (!componentId) return

    const container = document.querySelector(`.${containerClassName}`)
    if (!container) return

    const node = document.querySelector(`[data-component-id="${componentId}"]`)
    if (!node) return

    const { top, left, width, height } = node.getBoundingClientRect()
    const {top: containerTop, left: containerLeft} = container.getBoundingClientRect()

    setPosition({
      top: top - containerTop + container.scrollTop,
      left: left - containerLeft + container.scrollTop,
      width,
      height,
      labelTop: top - containerTop + container.scrollTop,
      labelLeft: left - containerLeft + width,
    })
  }

  const el = useMemo(() => {
    return document.querySelector(`.${portalWrapperClassName}`)
  }, [])

  const curComponent = useMemo(() => {
    return getComponentById(componentId, components)
  }, [componentId])

  return createPortal((
    <>
      <div style={{
        position: 'absolute',
        top: position.top,
        left: position.left,
        width: position.width,
        height: position.height,
        backgroundColor: 'rgba(0, 0, 255, 0.1)',
        border: '1px dashed blue',
        borderRadius: 4,
        boxSizing: 'border-box',
        pointerEvents: 'none',
        zIndex: 12
      }}></div>

      <div 
        style={{
          position: 'absolute',
          top: position.labelTop,
          left: position.labelLeft,
          fontSize: 14,
          zIndex: 13,
          display: (!position.width || position.width < 10) ? 'none' : 'inline-block',
          transform: 'translate(-100%, -100%)'
        }}
      >
        <div
          style={{
            padding: '0px 8px',
            backgroundColor: 'blue',
            color: '#fff',
            borderRadius: 4,
            cursor: 'pointer',
            whiteSpace: 'nowrap',
          }}
        >{curComponent?.desc}</div>
      </div>
    </>
  ), el as HTMLElement)
}

分析:

  • 通过 updatePosition 动态计算蒙层和标签的位置,确保高亮永远紧贴目标组件,像贴身保镖一样寸步不离。
  • useMemo 优化挂载点和当前组件的查找,性能拉满。
  • 蒙层采用 createPortal 挂载到 portal-wrapper,保证层级最高,视觉效果炸裂。
  • 高亮边框和淡蓝色背景,既醒目又不喧宾夺主,用户体验满分。

3. SelectedMask —— 选中的荣耀光环

SelectedMask 组件则是为被选中的组件加冕“荣耀光环”,并提供操作浮层。来看看它的“加冕仪式”源码:

import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { Space, Popconfirm } from 'antd'
import { getComponentById, useComponentsStore } from '../../stores/components'
import { DeleteOutlined } from '@ant-design/icons'

interface SelectedMaskProps {
  containerClassName: string
  portalWrapperClassName: string
  componentId: number
}

export default function SelectedMask({ containerClassName, portalWrapperClassName, componentId }: SelectedMaskProps) {
  const [position, setPosition] = useState({
    top: 0,
    left: 0,
    width: 0,
    height: 0,
    labelTop: 0,
    labelLeft: 0,
  })

  const { components, curComponentId, deleteComponent, setCurComponentId } = useComponentsStore()

  useEffect(() => {
    updatePosition()
  }, [componentId])

  useEffect(() => {
    const resizeHandler = () => {
      updatePosition()
    }
    window.addEventListener('resize', resizeHandler)

    return () => {  // 组件卸载时,移除事件监听器
      window.removeEventListener('resize', resizeHandler)
    }
  }, [])

  function updatePosition() {
    if (!componentId) {
      return
    }
    const container = document.querySelector(`.${containerClassName}`)
    if (!container) return
    const node = document.querySelector(`[data-component-id="${componentId}"]`)
    if (!node) return
    const { top, left, width, height } = node.getBoundingClientRect()
    const { top: containerTop, left: containerLeft } = container.getBoundingClientRect()

    let labelTop = top - containerTop + container.scrollTop
    let labelLeft = left - containerLeft + width

    if (labelTop <= 0) {
      labelTop -= -20
    }

    setPosition({
      top: top - containerTop + container.scrollTop,
      left: left - containerLeft + container.scrollTop,
      width,
      height,
      labelTop,
      labelLeft,
    })
  }

  const el = useMemo(() => {
    return document.querySelector(`.${portalWrapperClassName}`)
  }, [])

  const curComponent = useMemo(() => {  // 找到被点击的组件对象
    return getComponentById(componentId, components)
  }, [componentId])

  const handleDelete = () => {
    deleteComponent(curComponentId!)
    setCurComponentId(null!)
  }

  return createPortal((
    <>
      <div
        style={{
          position: "absolute",
          left: position.left,
          top: position.top,
          backgroundColor: "rgba(0, 0, 255, 0.1)",
          border: "1px dashed blue",
          pointerEvents: "none",
          width: position.width,
          height: position.height,
          zIndex: 14,
          borderRadius: 4,
          boxSizing: 'border-box',
        }}
      />
      <div
        style={{
          position: "absolute",
          left: position.labelLeft,
          top: position.labelTop,
          fontSize: "14px",
          zIndex: 15,
          display: (!position.width || position.width < 10) ? "none" : "inline",
          transform: 'translate(-100%, -100%)',
        }}
      >
        <Space>
          <div
            style={{
              padding: '0 8px',
              backgroundColor: 'blue',
              borderRadius: 4,
              color: '#fff',
              cursor: "pointer",
              whiteSpace: 'nowrap',
            }}
          >
            {curComponent?.desc}
          </div>
          {curComponentId !== 1 && (
            <div style={{ padding: '0 8px', backgroundColor: 'blue' }}>
              <Popconfirm
                title="确认删除?"
                okText={'确认'}
                cancelText={'取消'}
                onConfirm={handleDelete}
              >
                <DeleteOutlined style={{ color: '#fff' }} />
              </Popconfirm>
            </div>
          )}
        </Space>
      </div>
    </>
  ), el as HTMLElement)
}

分析:

  • SelectedMask 不仅高亮选中组件,还贴心地提供了操作浮层,支持一键删除,体验堪比“魔法师的权杖”。
  • 通过 useEffect 监听窗口尺寸变化,蒙层自适应,永不“走位失误”。
  • handleDelete 让你轻松删除组件,操作安全还带确认弹窗,防止误删,细节满满。
  • 蒙层和标签的定位算法与 HoverMask 类似,保证视觉一致性。

效果展示:

低代码蒙层.gif

三、总结

  • 解耦:蒙层与组件渲染分离,便于维护和扩展。
  • 性能:只在必要时渲染蒙层,避免无谓重绘。
  • 体验:交互流畅,视觉反馈及时,提升用户幸福感。

看完源码和分析,是不是觉得“高亮”与“选中”背后也有这么多门道?下次在画布区拖拽组件时,不妨想想这些小魔法是怎么实现的吧!🧙‍♂️✨

如果你还有其他关于低代码平台的疑问,欢迎留言交流,一起探索更多技术趣事!🤝😄

项目所有代码均已整理至以下仓库,方便大家查看与使用:
→ 代码仓库地址内附项目体验地址