引言
你是否曾在低代码平台的画布区,像福尔摩斯一样追踪鼠标的踪迹,想知道组件是如何“亮起来”的?🕵️♂️别眨眼,今天就带你揭秘 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递归渲染所有子组件,支持复杂嵌套结构,像套娃一样层层递进。 handleMouseOver和handleClick利用事件冒泡和composedPath,精准捕捉鼠标悬停和点击的目标组件,堪比“鼠标侦探”。- 悬停和选中状态分别由
HoverMask和SelectedMask控制,互不干扰,配合默契。 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 类似,保证视觉一致性。
效果展示:
三、总结
- 解耦:蒙层与组件渲染分离,便于维护和扩展。
- 性能:只在必要时渲染蒙层,避免无谓重绘。
- 体验:交互流畅,视觉反馈及时,提升用户幸福感。
看完源码和分析,是不是觉得“高亮”与“选中”背后也有这么多门道?下次在画布区拖拽组件时,不妨想想这些小魔法是怎么实现的吧!🧙♂️✨
如果你还有其他关于低代码平台的疑问,欢迎留言交流,一起探索更多技术趣事!🤝😄
项目所有代码均已整理至以下仓库,方便大家查看与使用:
→ 代码仓库地址内附项目体验地址