前端大屏原理系列:拖拽组件到页面

本文是《前端大屏原理系列》第二篇:拖拽组件到页面。

本系列所有技术点,均经过本人开源项目 react-big-screen 实际应用,欢迎 star (*´▽`)ノノ.

一、效果演示

二、拖拽创建

拖拽创建简单的理解:拖拽一个元素模板,将其放置在画板上时创建一个实例。按照这个思路,我们来了解其中的实现细节。

2.1 开始拖拽

有两种拖拽方案:绑定drag事件模拟drag事件。不管使用哪种方案都可以。我们在拖拽开始时,可以获取组件模板信息并传递。

html5 Drag 伪代码

tsx 复制代码
templateDom.addEventListener('dragstart', e => {
    e.dataTransfer.setData("text/plain", JSON.stringify({
        // 组件模板信息
        // ...
    }));
})

模拟 Drag 伪代码

tsx 复制代码
useVirtualDrag(domRef, {
    type: 'create-component',
    data: {
        // 组件模板信息
        // ...
    }
})

2.2 拖拽放置

放置时,我们需要计算两种信息:组件实例对象放置坐标。计算组件实例对象,直接通过接收的模板进行创建即可。计算组件的放置坐标,这一步很容易出错。常规思路:画布设置 position:relative,拖拽到画布时取 e.offsetX、e.offsetY作为放置坐标 。这时你可能会遇到问题:如果放置到 position:relative 的组件上方时怎么计算坐标(此时offsetX/Y取相对于组件的位置而非画布)?

思考:如果大屏缩放至50%时该如何计算?

此处我使用一种无需依赖定位属性的计算方式:鼠标位置减去画布左上角位置,即放置位置

实现代码:

js 复制代码
...
onDrop (e) {
    ...
    // 根据模板信息计算组件实例对象
    const componentNode = createComponentNode(...);
    // 计算画布的视口位置信息
    const containerRect = container.getBoundingClientRect();
    // 计算画布创建新组件的放置坐标
    //(鼠标坐标与视口坐标的差值,就是放置坐标)
    const coordinate = {
        x: e.x - containerRect.x,
        y: e.y - containerRect.y
    };
    ...
}
...

2.3 创建实例

这一步很简单,将组件实例对象创建坐标组合并渲染到画布上。

tsx 复制代码
// react-big-screen的实现(伪代码)
...
// 放置事件回调函数
onDrop (e) {
    ...
    // 创建组件实例对象
    const componentNode = createComponentNode(...);
    ...
    // 绑定放置坐标
    componentNode.x = coordinate.x;
    componentNode.y = coordinate.y;
    
    // 添加实例(触发页面渲染)
    engine.componentNode.add(componentNode);
    
    // 添加实例后,再选中实例
    setTimeout(() => {
        // 第二个参数true,表示清空其他选中
        engine.instance.select(componentNode.id, true);
    })
    ...
}
...

三、优化体验

3.1 为何不使用 html5 Drag 事件及衍生库?

目的是为了流畅的用户体验。拖放是 HTML5 的标准之一,任何元素都能够拖放,其可以作为大屏拖拽创建的一种实现方式。drag事件有一个容易忽略的点:如果在一个未添加ondragover事件及e.preventDefault()的元素上方松开鼠标取消拖拽,拖拽元素会有一段回归的延迟。这个延迟让你不能立即开始对原始元素开始新的拖拽。其他基于html5 Drag事件的第三方库,也会存在这种类似的延迟。

测试代码:

html 复制代码
<!DOCTYPE html>
<html>
  <head>
    <style>
      .block {
        width: 300px;
        height: 200px;
        color: gray;
        display: flex;
        margin-top: 100px;
        align-items: center;
        justify-content: center;
        border: 1px solid black;
        background-color: whitesmoke;
      }
    </style>
  </head>
  <body style="padding: 100px">
    <img src="./img/1.jpg" width="300" draggable />
    <div class="block">监听 ondragover</div>
    <script>
      const dom = document.querySelector(".block");
      dom.addEventListener("dragover", (e) => {
        e.preventDefault();
      });
    </script>
  </body>
</html>

3.2 实现基于 mouse 事件的 useVirtualDrag

为了实现松开鼠标能立刻重新开始拖拽,我选择开发useVirtualDraguseVirtualDrop这两个 react hook。实现原理是:鼠标开始拖拽时保存拖拽信息,鼠标松开取消拖拽时读取拖拽信息并处理。需要注意的是,鼠标按住移动才算作拖拽。

使用示例:

tsx 复制代码
// ----------------- 开始拖拽 hook ----------------
// 开始创建组件类型的拖拽
useVirtualDrag(domRef, {
  type: "create-component", // 拖拽类型
  data: {
    component,
  },
}); 
tsx 复制代码
// ----------------- 拖拽放置 hook ----------------
// 结束拖拽时创建新组件
useVirtualDrop(domRef, {
  accept: ['create-component'], // 匹配拖拽类型
  onDrop: (e: MouseEvent, dragOptions) => {
     // ...
     // 此处执行"页面创建新组件"的逻辑
  })

实现 useVirtualDrag:

tsx 复制代码
import { useEffect, useRef } from "react";

// 拖拽信息
export let dragOptions: VirtualDragOptions & {
  isDragging: boolean; // 是否正在拖拽
};

// 拖拽信息
export interface VirtualDragOptions {
  type: string; // 拖拽类型
  data?: Record<string, any>; // 拖拽携带数据
}

type Unmount = () => void;

// 模拟拖拽
function dragElement(
  dom: HTMLElement,
  options: {
    onStart?: () => void;
    onEnd?: () => void;
  }
): Unmount {
  let isDragging = false; // 拖拽中变量,只有开始拖拽才传输
  dom.addEventListener("mousedown", mousedown);

  function mousedown(e: MouseEvent) {
    dom.addEventListener("mousemove", mousemove); // 在待拖拽元素上鼠标移动,才算拖拽
    window.addEventListener("mouseup", mouseup);
  }

  function mousemove(e: MouseEvent) {
    if (isDragging) return; // 开始拖拽只触发一次
    isDragging = true;
    options?.onStart?.();
  }

  function mouseup(e: MouseEvent) {
    options?.onEnd?.();
  }

  // 清除拖拽中和结束的事件监听器
  function clear() {
    if (!isDragging) return;
    isDragging = false;
    dom.removeEventListener("mousemove", mousemove);
    window.removeEventListener("mouseup", mouseup);
  }

  return () => {
    dom.removeEventListener("mousedown", mousedown);
    clear();
  };
}

// 虚拟拖拽hook
function useVirtualDrag<T extends HTMLElement>(
  domRef: React.RefObject<T>,
  options: VirtualDragOptions
) {
  // 获取最新的options,避免闭包问题。
  const optionsRef = useRef<VirtualDragOptions>(options);
  optionsRef.current = options;

  useEffect(() => {
    const dom = domRef.current;
    if (!dom) {
      throw new Error("dom must be set.");
    }
    return dragElement(dom, {
      onStart() {
        // 开始拖拽
        // 1. 设置拖拽信息
        // 2. 全局设置拖拽图标
        // 3. 设置 isDragging = true
        dragOptions = {
          isDragging: true,
          ...optionsRef.current,
        };
      },
      onEnd() {
        // 结束拖拽
        // 1. 全局恢复拖拽图标
        // 2. 设置 isDragging = false
        dragOptions.isDragging = false;
      },
    });
  }, []);
}

实现 useVirtualDrop:

tsx 复制代码
import React, { useEffect, useRef } from "react";
import { VirtualDragOptions, dragOptions } from "./useVirtualDrag";

interface VirtualDropOptions {
  // 匹配拖拽类型(不设置则匹配任意类型)
  accept?: string[];
  // 拖拽放置监听回调
  onDrop?: (e: MouseEvent, dragOptions: VirtualDragOptions) => void;
}

export function useVirtualDrop<T extends HTMLElement>(
  domRef: React.RefObject<T>,
  options: VirtualDropOptions
) {
  // 保存实时值
  const optionsRef = useRef<VirtualDropOptions>(options);
  optionsRef.current = options;

  useEffect(() => {
    const { accept } = options;
    if (!Array.isArray(accept)) {
      throw new Error("accept must be a array.");
    }

    const dom = domRef.current;
    if (!dom) {
      throw new Error("dom must be set.");
    }

    const mouseup = (e: MouseEvent) => {
      // 非拖拽中,则不接收
      if (!dragOptions?.isDragging) {
        return;
      }
      // 匹配拖拽类型才会相应接收
      if (accept && !accept?.includes?.(dragOptions.type || "")) {
        return;
      }
      optionsRef.current?.onDrop?.(e, dragOptions);
    };

    dom.addEventListener("mouseup", mouseup);
    return () => {
      dom.removeEventListener("mouseup", mouseup);
    };
  }, []);
}

【前端大屏原理系列】

react-big-screen 是一个从0到1使用React开发的前端拖拽大屏开源项目。此系列将对大屏的关键技术点一一解析。包含了:拖拽系统实现、自定义组件、收藏夹、快捷键、可撤销历史记录、加载远程组件/本地组件、自适应预览页、布局容器组件、多组件联动(基于事件机制)、成组/取消成组、多子页面切换、i18n国际化语言、鼠标范围框选、... ... 等等。

演示地址:点击访问

相关推荐
宝耶9 分钟前
HTML:表格数据展示区
前端·html
程序员海军23 分钟前
一键把网站变成吉卜力风格的神器来了
前端·chatgpt
三原25 分钟前
前端微应用-乾坤(qiankun)原理分析-沙箱隔离(js)
前端·架构·前端框架
IT专家-大狗26 分钟前
Edge浏览器安卓版流畅度与广告拦截功能评测【不卡还净】
android·前端·edge
Kx…………36 分钟前
Day3:个人中心页面布局前端项目uniapp壁纸实战
前端·学习·uni-app·实战·项目
肠胃炎39 分钟前
认识Vue
前端·javascript·vue.js
七月丶42 分钟前
🛠 用 Node.js 和 commander 快速搭建一个 CLI 工具骨架(gix 实战)
前端·后端·github
砖吐筷筷44 分钟前
我理想的房间是什么样的丨去明日方舟 Only 玩 - 筷筷月报#18
前端·github
七月丶44 分钟前
🔀 打造更智能的 Git 提交合并命令:gix merge 实战
前端·后端·github
iguang1 小时前
通过实现一个mcp-server来理解mcp
前端