• 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.小册总结

上节实现了 hover 时展示高亮框和组件名的效果:

这节我们来实现 click 时展示编辑框,以及组件删除:

hover 时记录了 hoverComponentId:

click 时同样也要记录。

但是 hover 时不一样,click 选中的组件除了展示编辑框,还要在右侧属性区展示对应的组件属性:

所以我们要把它记录到全局 store 里。

我们加一下:

interface State {
    components: Component[];
    curComponentId?: number | null;
    curComponent: Component | null;
}

interface Action {
    addComponent: (component: Component, parentId?: number) => void;
    deleteComponent: (componentId: number) => void;
    updateComponentProps: (componentId: number, props: any) => void;
    setCurComponentId: (componentId: number | null) => void;
}
curComponentId: null,
curComponent: null,
setCurComponentId: (componentId) =>
  set((state) => ({
    curComponentId: componentId,
    curComponent: getComponentById(componentId, state.components),
  })),

同样,click 事件也是绑定在画布区根组件 EditArea 上的:

import React, { MouseEventHandler, useEffect, useState } from "react";
import { useComponentConfigStore } from "../../stores/component-config";
import { Component, useComponetsStore } from "../../stores/components";
import HoverMask from "../HoverMask";
import SelectedMask from "../SelectedMask";

export function EditArea() {
    const { components, curComponentId, setCurComponentId } =
        useComponetsStore();
    const { componentConfig } = useComponentConfigStore();

    function renderComponents(components: Component[]): React.ReactNode {
        return components.map((component: Component) => {
            const config = componentConfig?.[component.name];

            if (!config?.component) {
                return null;
            }

            return React.createElement(
                config.component,
                {
                    key: component.id,
                    id: component.id,
                    name: component.name,
                    ...config.defaultProps,
                    ...component.props,
                },
                renderComponents(component.children || [])
            );
        });
    }

    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;
            }
        }
    };

    const handleClick: 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) {
                setCurComponentId(+componentId);
                return;
            }
        }
    };

    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}
                />
            )}
            {curComponentId && (
                <SelectedMask
                    portalWrapperClassName="portal-wrapper"
                    containerClassName="edit-area"
                    componentId={curComponentId}
                />
            )}
            <div className="portal-wrapper"></div>
        </div>
    );
}

点击事件触发时,找到元素对应的 component id,设置为 curComponentId。

然后渲染 curComponentId 对应的 SelectedMask。

实现下这个 SelectedMask 组件:

editor/components/SelectedMask/index.tsx

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

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

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

    const { components, curComponentId } = 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]);

    function handleDelete() {}

    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",
                }}
            />
            <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%)",
                }}>
                <Space>
                    <div
                        style={{
                            padding: "0 8px",
                            backgroundColor: "blue",
                            borderRadius: 4,
                            color: "#fff",
                            cursor: "pointer",
                            whiteSpace: "nowrap",
                        }}>
                        {curComponent?.name}
                    </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
    );
}

export default SelectedMask;

和 HoverMask 区别不大,主要这几点区别:

从 store 取出 curComponentId 来。

如果 id 不为 1,说明不是 Page 组件,就显示删除按钮。

点击的时候删除组件:

再就是编辑框的颜色稍微深一点:

测试下:

点击时显示了编辑框,并且点击删除能删除组件。

只是会和 HoverMask 重合。

我们处理下:

hoverComponentId 和 curComponentId 一样的时候,就不显示高亮框。

这样就好了。

amis 的编辑器还有这个功能:

组件会展示它所有的父组件,点击就会选中该父组件。

我们也实现下:

每个组件都有 component.parentId,用来找父组件也很简单,不断向上找,放到一个数组里就行。

然后用 DropDown 组件展示下拉列表:

点击 item 的时候切换 curComponentId。

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

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

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

    const {
        components,
        curComponentId,
        curComponent,
        deleteComponent,
        setCurComponentId,
    } = 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 curSelectedComponent = useMemo(() => {
        return getComponentById(componentId, components);
    }, [componentId]);

    function handleDelete() {
        deleteComponent(curComponentId!);
        setCurComponentId(null);
    }

    const parentComponents = useMemo(() => {
        const parentComponents = [];
        let component = curComponent;

        while (component?.parentId) {
            component = getComponentById(component.parentId, components)!;
            parentComponents.push(component);
        }

        return parentComponents;
    }, [curComponent]);

    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",
                }}
            />
            <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%)",
                }}>
                <Space>
                    <Dropdown
                        menu={{
                            items: parentComponents.map((item) => ({
                                key: item.id,
                                label: item.name,
                            })),
                            onClick: ({ key }) => {
                                setCurComponentId(+key);
                            },
                        }}
                        disabled={parentComponents.length === 0}>
                        <div
                            style={{
                                padding: "0 8px",
                                backgroundColor: "blue",
                                borderRadius: 4,
                                color: "#fff",
                                cursor: "pointer",
                                whiteSpace: "nowrap",
                            }}>
                            {curSelectedComponent?.name}
                        </div>
                    </Dropdown>
                    {curComponentId !== 1 && (
                        <div
                            style={{
                                padding: "0 8px",
                                backgroundColor: "blue",
                            }}>
                            <Popconfirm
                                title="确认删除?"
                                okText={"确认"}
                                cancelText={"取消"}
                                onConfirm={handleDelete}>
                                <DeleteOutlined style={{ color: "#fff" }} />
                            </Popconfirm>
                        </div>
                    )}
                </Space>
            </div>
        </>,
        el
    );
}

export default SelectedMask;

试一下:

这样,选中父组件的功能就完成了。

但现在有个问题:

删除组件后会触发它父组件的 hover,但这时候高亮框的高度是没删除元素的高度,会多出一块。

还有,click 选中的组件再添加组件的时候编辑框高度不会变化:

这个问题也好解决,在 components 变化后调用下 updatePosition 就好了:

useEffect(() => {
    updatePosition();
}, [components]);

SelectedMask 和 HoverMask 都处理下。

这样就好了。

此外,amis 编辑器左边物料和选中时的编辑框都是展示的组件描述,而我们直接展示组件名:

这样不大好,我们改一下:

在 Component 类型加一下 desc:

ComponentConfig 也加一下:

addComponent 的时候从 config 取出组件的 desc:

然后展示的时候展示 desc 就好了:

左边的 MaterialItem 传入 desc:

显示的文案改成 desc:

HoverMask 和 SelectedMask 也显示 desc:

测试下:

没啥问题。

然后左边不需要展示页面组件,过滤下:

还有,使用者是可能调整窗口大小的,这时候编辑框没有重新计算位置:

做下处理:

useEffect(() => {
    const resizeHandler = () => {
        updatePosition();
    };
    window.addEventListener("resize", resizeHandler);
    return () => {
        window.removeEventListener("resize", resizeHandler);
    };
}, []);

这样就好了:

案例代码上传了小册仓库,可以切换到这个 commit 查看:

git reset --hard f8f0cd06dc5c08f6df2f5dcb5d5327c4bb11d94b

总结

这节我们实现了点击时的编辑框。

首先在 components 的 store 里保存了 curComponentId。

然后在 EditArea 添加 click 事件,点击的时候拿到 data-component-id 设置到 curComponentId。

根据 curComponentId 渲染 SelectedMask。

SelctedMask 展示删除按钮,可以调用 deleteComponent 删除组件,展示父组件的列表,可以切换选中父组件。

渲染 SelectedMask 的时候要隐藏掉 HoverMask。

还要做 components 变化、window resize 的时候的 udpatePosition 处理。

此外,我们还把展示的 component.name 换成了 component.desc

这样,画布区的交互就完成了。

上次更新: 6/21/25, 9:42 AM
贡献者: YNight
Prev
69.低代码编辑器:画布区hover展示高亮框
Next
71.低代码编辑器:组件属性、样式编辑