• Babel 插件通关秘籍
  • Git 原理详解及实用指南
  • Nest 通关秘籍
  • React 通关秘籍
  • TypeScript 全面进阶指南
  • TypeScript 类型体操通关秘籍
  • 现代CSS
  • Babel 插件通关秘籍
  • Git 原理详解及实用指南
  • Nest 通关秘籍
  • React 通关秘籍
  • TypeScript 全面进阶指南
  • TypeScript 类型体操通关秘籍
  • 现代CSS
  • React 通关秘籍

    • 1.关于本小册
    • 2.一网打尽组件常用Hook
    • 3.Hook的闭包陷阱的成因和解决方案
    • 4.React组件如何写TypeScript类型
    • 5.React组件如何调试
    • 6.受控模式VS非受控模式
    • 7.组件实战:迷你Calendar
    • 8.组件实战:Calendar日历组件(上)
    • 9.组件实战:Calendar日历组件(下)
    • 10.快速掌握Storybook
    • 11.React组件如何写单测
    • 12.深入理解Suspense和ErrorBoundary
    • 13.组件实战:Icon图标组件
    • 14.组件实战:Space间距组件
    • 15.React.Children和它的两种替代方案
    • 16.三个简单组件的封装
    • 17.浏览器的5种Observer
    • 18.组件实战:Watermark防删除水印组件
    • 19.手写react-lazyload
    • 20.图解网页的各种距离
    • 21.自定义hook练习
    • 22.自定义hook练习(二)
    • 23.用react-spring做弹簧动画
    • 24.react-spring结合use-gesture手势库实现交互动画
    • 25.用react-transition-group和react-spring做过渡动画
    • 26.快速掌握Tailwind:最流行的原子化CSS框架
    • 27.用CSSModules避免样式冲突
    • 28.CSSInJS:快速掌握styled-components
    • 29.react-spring实现滑入滑出的转场动画
    • 30.组件实战:Message全局提示组件
    • 31.组件实战:Popover气泡卡片组件
    • 32.项目里如何快速定位组件源码
    • 33.一次超爽的React调试体验
    • 34.组件实战:ColorPicker颜色选择器(一)
    • 35.组件实战:ColorPicker颜色选择器(二)
    • 36.组件实战:onBoarding漫游式引导组件
    • 37.组件实战:Upload拖拽上传
    • 38.组件实战:Form表单组件
    • 39.React组件库都是怎么构建的
    • 40.组件库实战:构建esm和cjs产物,发布到npm
    • 41.组件库实战:构建umd产物,通过unpkg访问
    • 42.数据不可变:immutable和immer
    • 43.基于ReactRouter实现keepalive
    • 44.Historyapi和ReactRouter实现原理
    • 45.ReactContext的实现原理和在antd里的应用
    • 46.ReactContext的性能缺点和解决方案
    • 47.手写一个Zustand
    • 48.原子化状态管理库Jotai
    • 49.用react-intl实现国际化
    • 50.国际化资源包如何通过Excel和GoogleSheet分享给产品经理
    • 51.基于react-dnd实现拖拽排序
    • 52.react-dnd实战:拖拽版TodoList
    • 53.ReactPlayground项目实战:需求分析、实现原理
    • 54.ReactPlayground项目实战:布局、代码编辑器
    • 55.ReactPlayground项目实战:多文件切换
    • 56.ReactPlayground项目实战:babel编译、iframe预览
    • 57.ReactPlayground项目实战:文件增删改
    • 58.ReactPlayground项目实战:错误显示、主题切换
    • 59.ReactPlayground项目实战:链接分享、代码下载
    • 60.ReactPlayground项目实战:WebWorker性能优化
    • 61.ReactPlayground项目实战:总结
    • 62.手写MiniReact:思路分析
    • 63.手写MiniReact:代码实现
    • 64.手写MiniReact:和真实React源码的对比
    • 65.React18的并发机制是怎么实现的
    • 66.Ref的实现原理
    • 67.低代码编辑器:核心数据结构、全局store
    • 68.低代码编辑器:拖拽组件到画布、拖拽编辑json
    • 69.低代码编辑器:画布区hover展示高亮框
    • 70.低代码编辑器:画布区click展示编辑框
    • 71.低代码编辑器:组件属性、样式编辑
    • 72.低代码编辑器:预览、大纲
    • 73.低代码编辑器:事件绑定
    • 74.低代码编辑器:动作弹窗
    • 75.低代码编辑器:自定义JS
    • 76.低代码编辑器:组件联动
    • 77.低代码编辑器:拖拽优化、Table组件
    • 78.低代码编辑器:Form组件、store持久化
    • 79.低代码编辑器:项目总结
    • 80.快速掌握ReactFlow画流程图
    • 81.ReactFlow振荡器调音:项目介绍
    • 82.ReactFlow振荡器调音:流程图绘制
    • 83.ReactFlow振荡器调音:合成声音
    • 84.AudioContext实现在线钢琴
    • 85.React服务端渲染:从SSR到hydrate
    • 86.小册总结

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

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

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

上次更新: 6/21/25, 9:42 AM
贡献者: YNight
Prev
68.低代码编辑器:拖拽组件到画布、拖拽编辑json
Next
70.低代码编辑器:画布区click展示编辑框