React使用 ahooks的useDrop & useDrag 以及Form表单 实现的拖拽元素到指定可缩放画布的功能

一 . 需求拆解

二 . 对于画布大小的配置

由于需要打造一个大屏展示的低代码平台,那么大屏展示的方案就必不可少。这里采用的是固定宽高比,来根据视口大小自适应缩放或扩大的大屏展示方案来设计。也就是,在画布中间的配置区域,设置好大屏的尺寸,所有组件摆放位置根据计算的尺寸来进行转换。设置好的画布区域就是将来使用时候的全部屏幕。
tsx 复制代码
//监听视口大小完成缩放的hooks
import { useCallback, useEffect, useState } from 'react';
import {
    DEFAULT_DESIGN_HEIGHT,
    DEFAULT_DESIGN_WIDTH,
} from 'src/costants/const';

const useWindowScale = (param: {
    designWidth?: number; // 设计稿尺寸
    designHeight?: number;
    width?: number;
    height?: number;
}) => {
    const {
        width,
        height,
        designWidth = DEFAULT_DESIGN_WIDTH,
        designHeight = DEFAULT_DESIGN_HEIGHT,
    } = param;
    const [scale, setScale] = useState(1);
    const handleResize = useCallback(() => {
        const wrapperWidth = width ?? window.innerWidth;
        const wrapperHeight = height ?? window.innerHeight;
        //根据屏幕的变化适配的比例
        const scale =
            wrapperWidth / wrapperHeight < designWidth / designHeight
                ? wrapperWidth / designWidth
                : wrapperHeight / designHeight;
        setScale(scale);
    }, [designHeight, designWidth, height, width]);
    useEffect(() => {
        window.addEventListener('resize', handleResize);
        handleResize();
    }, [handleResize, width]);
    useEffect((): (() => void) | void => {
        return () => {
            window.removeEventListener('resize', handleResize);
        };
    }, [handleResize]);
    return {
        scale,
    };
};

export default useWindowScale;


import { Form } from 'antd';
import React from 'react';
import useWindowScale from 'src/hooks/useWindowScale';
import { useLowCodeContext } from './LargeScreenProvider';

export interface ScaleLayoutProps {
    className?: any;
    style?: React.CSSProperties;
    children?: React.ReactNode;
    width?: number;
    height?: number;
}
// 使用useWindowScale,传入容器相应的宽高,把实际画布放在里面
const ScaleLayout = (props: ScaleLayoutProps) => {
    const { children, style, width, height } = props;
    const { designWidth, designHeight } = useLowCodeContext();
    const { scale } = useWindowScale({
        width,
        height,
        designWidth,
        designHeight,
    });
    return (
        <div
            style={{
                width: width ? `${width}px` : '100vw',
                height: height ? `${height}px` : '100vh',
                overflow: 'hidden',
                background: 'rgba(5,22,4,1)',
                position: 'relative',
                ...style,
            }}
        >
            <div
                style={{
                    width: `${designWidth}px`,
                    height: `${designHeight}px`,
                    transformOrigin: 'left top',
                    transform: `scale(${scale}) translate(-50%, -50%)`,
                    transition: 'transform .3s ease-in-out',
                    position: 'absolute',
                    top: '50%',
                    left: '50%',
                    background: 'white',
                }}
            >
                {children}
            </div>
        </div>
    );
};
export default ScaleLayout;

//实际的画布组件容器
import React, { useRef } from 'react';
import styles from './Content.module.less';
import ScaleLayout from '../components/ScaleLayout';
import { useSize } from 'ahooks';
const LowCodeContent: React.FC = () => {
    const ref = useRef(null);
    const size = useSize(ref);
    return (
        <div className={styles.layoutWrapper}>
            <div ref={ref} className={styles.contentWrapper}>
                <ScaleLayout width={size?.width} height={size?.height}>
                    <div ref={dropRef} className={styles.positionWrapper}>
                        内容区域
                    </div>
                </ScaleLayout>
            </div>
        </div>
    );
};
export default LowCodeContent;

三 . 对于拖拽方案的选择与实现

​ 拖拽可以考虑使用监听鼠标位置,然后改变元素positions的方案,也可以使用H5自带的draggable属性来实现。

​ 监听鼠标位置方案:

​ dom操作很频繁,需要一直设置元素的position属性,但是精准度较高。

​ H5的draggable方案:

​ 可能遇到的问题:拖拽的时候会生成一个元素的缩略图在鼠标旁,但是由于我们的画布有scal属性的设置,缩略图并不会根据实际缩放来更新,照成视觉误差。查询到的解决方案:

尝试使用ahooks的

来实现拖拽功能并且拿到回调事件,通过form的setValue方法来保存定位信息

tsx 复制代码
import React, { useRef } from 'react';
import styles from './LeftComponents.module.less';
import { useDrag } from 'ahooks';

const LeftComponents: React.FC = () => {
    const dragRef = useRef(null);
    useDrag('组件一', dragRef, {
        onDragStart: (e) => {
            console.log('onDragStart', e);
        },
        onDragEnd: (e) => {
            console.log('onDragEnd', e);
        },
    });
    return (
        <>
            <div className={styles.wrapper}>
                <div ref={dragRef} className={styles.componentItem}>
                    一个拖拽的盒子
                </div>
            </div>
        </>
    );
};
export default LeftComponents;


const LowCodeContent: React.FC = () => {
    const ref = useRef(null);
    const size = useSize(ref);
    const dropRef = useRef(null);
    const form = Form.useFormInstance();
    useDrop(dropRef, {
        onDom: (content: string, e) => {
            console.log('onDom', e);
            const domEvent = e as React.DragEvent<Element> & {
                offsetX: number;
                offsetY: number;
            };
            form.setFieldValue(content, {
                left: domEvent?.offsetX,
                top: domEvent?.offsetY,
            });
        },
        onDragEnter: (e) => {
            console.log('onDragEnter', e);
        },
        onDragOver(e) {
            // console.log('onDragOver', e);
        },
        onDragLeave(e) {
            console.log('onDragLeave', e);
        },
    });
    const renderComponent = () => {
        const value = form.getFieldValue('组件一');
        return (
            <div
                ref={dragRef}
                draggable
                style={{
                    width: `100px`,
                    height: `100px`,
                    position: 'absolute',
                    top: `${value.top}px`,
                    left: `${value.left}px`,
                    border: `1px solid`,
                }}
            ></div>
        );
    };
    return (
        <div className={styles.layoutWrapper}>
            <div ref={ref} className={styles.contentWrapper}>
                <ScaleLayout width={size?.width} height={size?.height}>
                    <div ref={dropRef} className={styles.positionWrapper}>
                        <Form.Item shouldUpdate noStyle>
                            {() => {
                                return renderComponent();
                            }}
                        </Form.Item>
                        {}
                    </div>
                </ScaleLayout>
            </div>
        </div>
    );
};
export default LowCodeContent;

遇到的问题:

​ 1. 拖拽倒是实现了,但是拿到的定位信息是鼠标指针的信息,并不是缩略图的左上角开始计算的,这时候会照成视觉误差。

解决方案:调用api event.dataTransfer?.setDragImage 设置缩略图的位置:

ts 复制代码
 useDrag('组件一', dragRef, {
        onDragStart: (event) => {
            // 创建并显示缩略图
            const thumbnail = document.createElement('div');
            thumbnail.innerHTML = 'BOX'; // 替换成您自己的缩略图内容
            thumbnail.style.position = 'absolute';
            thumbnail.style.backgroundColor = 'rgba(3, 3, 3, 0.3)';
            thumbnail.style.width = '100px';
            thumbnail.style.height = '50px';
            thumbnail.style.position = 'absolute';
            thumbnail.style.top = '-1000px';
            document.body.appendChild(thumbnail);
            // 设置缩略图为拖拽图像
            event.dataTransfer?.setDragImage(thumbnail, 0, 0);

            // 存储缩略图的引用,以便在 dragend 事件中进行清除
            setMyThumbnail(thumbnail);
        },
        onDragEnd: (e) => {
            console.log('onDragEnd=', e);
            if (myThumbnail) {
                document.body.removeChild(myThumbnail);
                setMyThumbnail(null);
            }
        },
    });

​ 2.由于直接拿的 domEvent?.offsetX 作为画布内坐标的获取,刚开始确实没什么。但是offsetX属性得到的是距离最近定位元素的距离,这样就会导致,如果你拖拽到画布内其他元素的内部,获得的offsetX是距离那个元素的距离,但是在设置定位时候是基于画布定位的,会导致效果诡异。

typescript 复制代码
 useDrop(dropRef, {
        onDom: (content: string, e) => {
            console.log('onDom', e);
            const domEvent = e as React.DragEvent<Element> & {
                offsetX: number;
                offsetY: number;
            };
            form.setFieldValue(content, {
                left: domEvent?.offsetX,//(坑,要用pageX-当前容器坐标)
                top: domEvent?.offsetY,
            });
        },
        onDragEnter: (e) => {
            console.log('onDragEnter', e);
        },
        onDragOver(e) {
            // console.log('onDragOver', e);
        },
        onDragLeave(e) {
            console.log('onDragLeave', e);
        },
    });

​ 解决方案:选用(clientX减去容器当前的页面定位)* 缩放的数值(使用useLowCodeContext传递下去)算出来组件相对于容器的定位。

ts 复制代码
					 const wrapperPosition = (
                dropRef?.current as unknown as Element
            )?.getBoundingClientRect();
            const domEvent = e as React.DragEvent<Element> & {
                offsetX: number;
                offsetY: number;
            };
            const { left: wrapperLeft, top: wrapperTop } = wrapperPosition;
            const { clientY, clientX } = domEvent;
            const offsetX = (clientX - wrapperLeft) / scale;
            const offsetY = (clientY - wrapperTop) / scale;

拖拽之后采用Form表单来保存数据

json 复制代码
 {
    "designWidth": 1643,
    "designHeight": 1243,
    "CHART": [
        {
            "left": 489.46515679442507,
            "top": 277.30006605407505,
            "id": "0"
        },
        {
            "left": 732.7665505226481,
            "top": 314.51086744780326,
            "id": "1"
        },
    ]
}

​ 实现方案为通过useDrop的onDom方法,拿到useDrag时传入的content值来作为form的key。

ts 复制代码
//定义一些节点type的枚举
export enum NODE_TYPE {
    ORDER = 'ORDER',
    CHART = 'CHART',
}

	//新增元素组件时触发拖拽事件,传给画布上读取节点类型用于新增form表单(LeftComponent组件用于新增元素)
const LeftComponents: React.FC = () => {
    const dragRef = useRef(null);
    const [myThumbnail, setMyThumbnail] = useState<HTMLDivElement | null>(null);

    useDrag(NODE_TYPE.CHART, dragRef, {
        onDragStart: (event) => {
            // 创建并显示缩略图
            const thumbnail = document.createElement('div');
            thumbnail.innerHTML = NODE_TYPE.CHART; // 替换成您自己的缩略图内容
            Object.assign(thumbnail.style, THUMBNAIL_STYLE);
            document.body.appendChild(thumbnail);
            // 设置缩略图为拖拽图像
            event.dataTransfer?.setDragImage(thumbnail, 0, 0);
            // 存储缩略图的引用,以便在 dragend 事件中进行清除
            setMyThumbnail(thumbnail);
        },
        onDragEnd: (e) => {
            // console.log('onDragEnd=', e);
            if (myThumbnail) {
                document.body.removeChild(myThumbnail);
                setMyThumbnail(null);
            }
        },
    });
    return (
        <>
            <div draggable className={styles.wrapper}>
                <div ref={dragRef} className={styles.componentItem}>
                    一个拖拽的盒子
                </div>
            </div>
        </>
    );
};
export default LeftComponents;

这时候又一个问题要注意就是,在拖拽已在画布上的元素时,应该触发修改表单元素而不是新增。所以当拖拽已有元素的时候,通过一个分割符加上在数据中的index拼接成新的content

tsx 复制代码
//ContentComponentItem 组件

/** 分隔符 */
export const NAME_SEPARATOR = '@';

const ContentComponentItem: React.FC<ContentComponentItemProps> = ({
    index,
}) => {
    const dragRef = useRef(null);
    const [myThumbnail, setMyThumbnail] = useState<HTMLDivElement | null>(null);

    useDrag(`${NODE_TYPE.CHART}${NAME_SEPARATOR}${index}`, dragRef, {
        onDragStart: (event) => {
            // 创建并显示缩略图
            const thumbnail = document.createElement('div');
            thumbnail.innerHTML = NODE_TYPE.CHART; // 替换成您自己的缩略图内容
            Object.assign(thumbnail.style, THUMBNAIL_STYLE);
            document.body.appendChild(thumbnail);
            // 设置缩略图为拖拽图像
            event.dataTransfer?.setDragImage(thumbnail, 0, 0);

            // 存储缩略图的引用,以便在 dragend 事件中进行清除
            setMyThumbnail(thumbnail);
        },
        onDragEnd: (e) => {
            console.log('onDragEnd=', e);
            if (myThumbnail) {
                document.body.removeChild(myThumbnail);
                setMyThumbnail(null);
            }
        },
    });

    return (
        <Form.Item shouldUpdate noStyle>
            {(form) => {
                const value = form.getFieldValue([NODE_TYPE.CHART, index]);
                return (
                    <div
                        ref={dragRef}
                        style={{
                            width: `100px`,
                            height: `100px`,
                            position: 'absolute',
                            top: `${value?.top}px`,
                            left: `${value?.left}px`,
                            border: `1px solid`,
                            display: `${value ? 'block' : 'none'}`,
                        }}
                    >
                        {index}
                    </div>
                );
            }}
        </Form.Item>
    );
};
export default ContentComponentItem;

然后在画布组件接收放下事件时,根据这个分隔符解析出来是否为编辑,以及真正的content

tsx 复制代码
  const LowCodeContent: React.FC = () => {
    const { scale } = useLowCodeContext();
    const ref = useRef(null);
    const size = useSize(ref);
    const dropRef = useRef(null);
    const form = Form.useFormInstance();
    useDrop(dropRef, {
        onDom: (content: string, e) => {
            console.log('onDom', content, e);
            const isEditContent = content.includes(NAME_SEPARATOR);
            const [editContent, editContentIndex] = _.split(
                content,
                NAME_SEPARATOR
            );
            const wrapperPosition = (
                dropRef?.current as unknown as Element
            )?.getBoundingClientRect(); //容器的位置信息

            const domEvent = e as React.DragEvent<Element> & {
                offsetX: number;
                offsetY: number;
            };
            const { left: wrapperLeft, top: wrapperTop } = wrapperPosition;
            const { clientY, clientX } = domEvent;
            const offsetX = (clientX - wrapperLeft) / scale;
            const offsetY = (clientY - wrapperTop) / scale;
            const contentValue =
                form.getFieldValue(isEditContent ? editContent : content) ?? [];

            const nameIndex = isEditContent
                ? editContentIndex
                : contentValue?.length ?? 0; //如果是修改元素,传入相应的name,如果是新增 name自增

            form.setFieldValue(
                [isEditContent ? editContent : content, nameIndex],
                {
                    left: offsetX,
                    top: offsetY,
                    id: nameIndex,
                }
            );
        },
    });

    return (
        <div className={styles.layoutWrapper}>
            <div ref={ref} className={styles.contentWrapper}>
                <ScaleLayout width={size?.width} height={size?.height}>
                    <div ref={dropRef} className={styles.positionWrapper}>
                        内容区域
                        <Form.Item noStyle shouldUpdate>
                            {(form) => {
                                const contentValue =
                                    form.getFieldValue(NODE_TYPE.CHART) ?? [];
                                console.log(contentValue);
                                return contentValue?.map(
                                    (item: any, index: number) => {
                                        return (
                                            <ContentComponentItem
                                                key={item?.id}
                                                index={index}
                                            />
                                        );
                                    }
                                );
                            }}
                        </Form.Item>
                    </div>
                </ScaleLayout>
            </div>
        </div>
    );
};
export default LowCodeContent;

自此,一个使用 ahooks的useDrop & useDrag 以及Form表单 实现的拖拽元素到指定可缩放画布的功能就完成了。

相关推荐
PleaSure乐事13 小时前
【React.js】AntDesignPro左侧菜单栏栏目名称不显示的解决方案
前端·javascript·react.js·前端框架·webstorm·antdesignpro
getaxiosluo13 小时前
react jsx基本语法,脚手架,父子传参,refs等详解
前端·vue.js·react.js·前端框架·hook·jsx
新星_14 小时前
函数组件 hook--useContext
react.js
阿伟来咯~15 小时前
记录学习react的一些内容
javascript·学习·react.js
吕彬-前端15 小时前
使用vite+react+ts+Ant Design开发后台管理项目(五)
前端·javascript·react.js
学前端的小朱15 小时前
Redux的简介及其在React中的应用
前端·javascript·react.js·redux·store
bysking16 小时前
【前端-组件】定义行分组的表格表单实现-bysking
前端·react.js
September_ning21 小时前
React.lazy() 懒加载
前端·react.js·前端框架
web行路人21 小时前
React中类组件和函数组件的理解和区别
前端·javascript·react.js·前端框架
番茄小酱00121 小时前
Expo|ReactNative 中实现扫描二维码功能
javascript·react native·react.js