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表单 实现的拖拽元素到指定可缩放画布的功能就完成了。

相关推荐
空中海1 小时前
03 渲染机制、性能优化与现代 React
javascript·react.js·性能优化
openKaka_4 小时前
为什么 React 18 之后使用 createRoot,而不是 ReactDOM.render
前端·javascript·react.js
老王以为5 小时前
从源码到架构:React useActionState 深度剖析
前端·javascript·react.js
天蓝色的鱼鱼6 小时前
当AI开始替我写代码,我还要纠结选Vue还是React吗?
vue.js·react.js·ai编程
空中海1 天前
01 React Native 基础、核心组件与布局体系
javascript·react native·react.js
空中海1 天前
05 React架构设计、项目实践与专家清单
前端·react.js·前端框架
空中海1 天前
04 工程化、质量体系与 React 生态
前端·ubuntu·react.js
空中海1 天前
03 性能、动画与 React Native 新架构
react native·react.js·架构
空中海1 天前
02 React Native状态、导航、数据流与设备能力
javascript·react native·react.js