IdbI

第69章—低代码编辑器:画布区hover展示高亮框

上节我们实现了 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 等信息,和画布区根元素的位置做计算,算出高亮框的位置。

并在高亮框的右上角展示了组件名。

这样,编辑时高亮展示组件信息的功能就完成了。

返回上级