外观
第69章—低代码编辑器:画布区hover展示高亮框
3090字约10分钟
2024-09-19
上节我们实现了 json 到组件树的渲染,以及拖拽改变 json,支持任意层级:
这节我们继续来实现编辑时的交互效果。
也就是这个:
鼠标 hover 到画布区的任意组件,都会有高亮效果:
选中组件的时候,会有框选效果:
这种效果怎么实现呢?
最容易想到的就是每个组件都做下处理,hover 或者 click 的时候展示编辑框。
但每个组件都加这段逻辑比较麻烦。
更好的方式是在画布区根组件统一监听 hover 和 click,根据触发事件的元素的 width、height、left、top,来显示编辑框。
类似我们之前实现的 OnBoarding 组件:
就是一个 div 来改变 width、height、left、top 实现的。
这里也类似。
我们实现下:
我们需要知道 hover 或者 click 的元素对应的 component 的 id。
在渲染的时候加一下这个:
import { Button as AntdButton } from 'antd';
import { CommonComponentProps } from '../../interface';
const Button = ({id, type, text}: CommonComponentProps) => {
return (
<AntdButton data-component-id={id} type={type}>{text}</AntdButton>
)
}
export default Button;
试一下:
拖拽两个组件过来。
可以看到,id 加在了组件元素的 data-component-id 属性上。
然后在 EditArea 里处理下 hover
const [hoverComponentId, setHoverComponentId] = useState<number>();
const handleMouseOver: MouseEventHandler = (e) => {
const path = e.nativeEvent.composedPath();
for (let i = 0; i < path.length; i += 1) {
const ele = path[i] as HTMLElement;
const componentId = ele.dataset?.componentId;
if (componentId) {
setHoverComponentId(+componentId);
return;
}
}
}
mouseover 的时候做下处理,找到元素的 data-component-id 设置为 hoverComponentId 的 state
加个 debugger
浏览器里打开 devtools,鼠标划到画布区:
可以看到 composedPath 是从触发事件的元素到 html 根元素的路径。
这是 event 对象的 api。
为啥不直接 e.composedPath 而是取 e.nativeEvent.composedPath 呢?
因为 react 里的 event 是合成事件,有的原生事件的属性它没有:
这时候就可以通过 e.nativeEvent 取它的原生事件:
然后我们在整个路径从底向上找,找到第一个有 data-component-id 的元素。
它就是当前 hover 的组件了。
还有这个 ele.dataset,它是一个 dom 的属性,包含所有 data-xx 的属性的值:
这样,在 hover 到不同 component 的时候,就能拿到对应的 componentId
我们渲染下这个 hoverComponentId:
没啥问题。
然后接下来就是拿到 component-id 对应的 dom 的 with、height、left、top,加一个框上去就好了。
我们创建个组件来写这个:
editor/components/HoverMask/index.tsx
import {
useEffect,
useMemo,
useState,
} from 'react';
import { createPortal } from 'react-dom';
interface HoverMaskProps {
containerClassName: string
componentId: number;
}
function HoverMask({ containerClassName, componentId }: HoverMaskProps) {
const [position, setPosition] = useState({
left: 0,
top: 0,
width: 0,
height: 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
});
}
const el = useMemo(() => {
const el = document.createElement('div');
el.className = `wrapper`;
const container = document.querySelector(`.${containerClassName}`);
container!.appendChild(el);
return el;
}, []);
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: 12,
borderRadius: 4,
boxSizing: 'border-box',
}}
/>
), el)
}
export default HoverMask;
从上到下来看:
首先,需要传入 containerClassName 和 componentId 参数:
componentId 就是 hover 的组件 id,而 containerClassName 就是画布区的根元素的 className。
比如上图,我们计算按钮和画布区顶部的距离,就需要按钮的 boundingClientRect 还有画布区的 boundingClientRect。
所以需要传入 containerClassName 和 componentId。
我们声明 left、top、width、height 的 state,调用 updatePosition 来计算这些位置。
计算方式如下:
获取两个元素的 boundingClientRect,计算 top、left 的差值,加上 scrollTop、scrollLeft。
因为 boundingClientRect 只是可视区也就是和视口的距离,要算绝对定位的位置的话要加上已滚动的距离。
然后创建一个 div 挂载在容器下,用于存放 portal:
具体的样式比较简单,就是设置下 top、left、width、height,然后设置下 border、background 就好了:
注意还要设置 pointer-event 为 none,不响应鼠标事件。
HoverMask 组件写完了,我们用一下:
{hoverComponentId && (
<HoverMask
containerClassName='edit-area'
componentId={hoverComponentId}
/>
)}
看下效果:
高亮是对的,只是当鼠标离开画布区的时候还在高亮。
处理下 mouseleave 的时候:
onMouseLeave={() => {
setHoverComponentId(undefined);
}}
这样就好了:
但只是高亮下意义不大,我们把组件名也显示下:
就是在加一个右上角 label 的位置计算,然后根据 id 找到对应 component 的 name 显示。
import {
useEffect,
useMemo,
useState,
} from 'react';
import { createPortal } from 'react-dom';
import { getComponentById, useComponetsStore } from '../../stores/components';
interface HoverMaskProps {
containerClassName: string
componentId: number;
}
function HoverMask({ containerClassName, componentId }: HoverMaskProps) {
const [position, setPosition] = useState({
left: 0,
top: 0,
width: 0,
height: 0,
labelTop: 0,
labelLeft: 0,
});
const { components } = useComponetsStore();
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();
let labelTop = top - containerTop + container.scrollTop;
let labelLeft = left - containerLeft + width;
setPosition({
top: top - containerTop + container.scrollTop,
left: left - containerLeft + container.scrollTop,
width,
height,
labelTop,
labelLeft,
});
}
const el = useMemo(() => {
const el = document.createElement('div');
el.className = `wrapper`;
const container = document.querySelector(`.${containerClassName}`);
container!.appendChild(el);
return el;
}, []);
const curComponent = useMemo(() => {
return getComponentById(componentId, components);
}, [componentId]);
return createPortal((
<>
<div
style={{
position: "absolute",
left: position.left,
top: position.top,
backgroundColor: "rgba(0, 0, 255, 0.05)",
border: "1px dashed blue",
pointerEvents: "none",
width: position.width,
height: position.height,
zIndex: 12,
borderRadius: 4,
boxSizing: 'border-box',
}}
/>
<div
style={{
position: "absolute",
left: position.labelLeft,
top: position.labelTop,
fontSize: "14px",
zIndex: 13,
display: (!position.width || position.width < 10) ? "none" : "inline",
transform: 'translate(-100%, -100%)',
}}
>
<div
style={{
padding: '0 8px',
backgroundColor: 'blue',
borderRadius: 4,
color: '#fff',
cursor: "pointer",
whiteSpace: 'nowrap',
}}
>
{curComponent?.name}
</div>
</div>
</>
), el)
}
export default HoverMask;
测试下:
这里的位置是这样算的:
labelTop 和高亮框一样,齐平。
labelLeft 是高亮框的 left,加上高亮框宽度。
然后 translate 回去:
如果不 tanslate 回去是这样的:
此外,还要处理下边界情况,Page 组件就没显示 label 因为定位到上面去了:
if (labelTop <= 0) {
labelTop -= -20;
}
现在就能显示出来了:
其实还有个问题:
.wrapper 会创建多个。
这是因为 hoverComponentId 只要一变,就会卸载之前的 HoverMask 创建新的:
所以这段逻辑会执行多次,创建多个 .wrapper 元素:
这样性能不好。
我们改一下:
直接在 EditArea 里创建个元素用来挂载 portal,把 className 传入 HoverMask 组件。
return <div className="h-[100%] edit-area" onMouseOver={handleMouseOver} onMouseLeave={() => {
setHoverComponentId(undefined);
}} onClick={handleClick}>
{renderComponents(components)}
{hoverComponentId && (
<HoverMask
portalWrapperClassName='portal-wrapper'
containerClassName='edit-area'
componentId={hoverComponentId}
/>
)}
<div className="portal-wrapper"></div>
</div>
HoverMask 直接把 portal 挂载到这个 className 的元素下就好了:
import {
useEffect,
useMemo,
useState,
} from 'react';
import { createPortal } from 'react-dom';
import { getComponentById, useComponetsStore } from '../../stores/components';
interface HoverMaskProps {
portalWrapperClassName: string;
containerClassName: string
componentId: number;
}
function HoverMask({ containerClassName, portalWrapperClassName, componentId }: HoverMaskProps) {
const [position, setPosition] = useState({
left: 0,
top: 0,
width: 0,
height: 0,
labelTop: 0,
labelLeft: 0,
});
const { components } = useComponetsStore();
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();
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]);
return createPortal((
<>
<div
style={{
position: "absolute",
left: position.left,
top: position.top,
backgroundColor: "rgba(0, 0, 255, 0.05)",
border: "1px dashed blue",
pointerEvents: "none",
width: position.width,
height: position.height,
zIndex: 12,
borderRadius: 4,
boxSizing: 'border-box',
}}
/>
<div
style={{
position: "absolute",
left: position.labelLeft,
top: position.labelTop,
fontSize: "14px",
zIndex: 13,
display: (!position.width || position.width < 10) ? "none" : "inline",
transform: 'translate(-100%, -100%)',
}}
>
<div
style={{
padding: '0 8px',
backgroundColor: 'blue',
borderRadius: 4,
color: '#fff',
cursor: "pointer",
whiteSpace: 'nowrap',
}}
>
{curComponent?.name}
</div>
</div>
</>
), el)
}
export default HoverMask;
测试下:
现在就只会有一个 wrapper 元素了。
案例代码上传了小册仓库,可以切换到这个 commit 查看:
git reset --hard 8b0dacec372a39d4eb90090c0d0a694f7ed9485b
总结
这节我们实现了下编辑的时候的交互,实现了 hover 的时候展示高亮框和组件名。
我们在每个组件渲染的时候加上了 data-component-id,然后在画布区根组件监听 mouseover 事件,通过触发事件的元素一层层往上找,找到 component-id。
然后 getBoudingClientRect 拿到这个元素的 width、height、left、top 等信息,和画布区根元素的位置做计算,算出高亮框的位置。
并在高亮框的右上角展示了组件名。
这样,编辑时高亮展示组件信息的功能就完成了。