低码编辑器中的“拖拽”是如何实现的

发布于:2024-04-30 ⋅ 阅读:(39) ⋅ 点赞:(0)

本文作者:来自 MoonWebTeam 的 acejhli

本文编辑:kanedongliu

1. 引言

低代码编辑器主要有物料系统、配置表单、组件编排三部分组成,实现组件编排核心能力则是拖拽能力,它是编辑器的交互基础,它能极大地提升用户在使用系统时的交互体验,因为它通常意味着用户可以直观地操作界面,实现所见即所得,大大提高了使用效率。

image.png

更重要的是,这种直观的操作方式也减少了用户的学习成本,让用户在短时间内就能上手操作,从而提高了用户的满意度和系统的使用率。

除此之外,拖拽技术在各种场景中都可以得到非常有效的应用。例如,在进行项目或元素的排序时,我们可以通过拖拽来轻松地改变它们的顺序。在文件上传的过程中,拖拽能够使用户更方便地选择和提交文件。在进行逻辑编排时,拖拽技术可以帮助用户更直观地理解和操作流程。

本文将会带大家了解浏览器中拖拽相关的 API 有哪些,对比市面上常见的拖拽库都有什么区别,最后一起探索一下 moveable 这个库的实现原理,并带大家简单地实现一个 moveable able 来扩展 moveable 的能力。

2. 浏览器中如何实现拖拽能力

2.1. 浏览器中的实现方式

如果你有仔细观察,你会发现浏览器中有些元素默认就是可拖拽的,某些元素也是默认可放置的。默认可拖拽的元素包括:选中的文本、图片、链接,而输入框默认也可以作为文本的可放置元素。

image.png

如果你想自定义拖拽元素和放置的位置,那么就可以使用到浏览器提供的拖放操作 API:Drag and Drop API.

2.1.1.

DnD( Drag and Drop API)是在 HTML5 中新增的,提供了原生支持的拖拽功能。通过使用该 API,开发者不仅可以方便元素拖拽,也能跟操作系统交互,从其他程序中拖拽文件到浏览器中放置。

它主要有两个概念 drag source 和 drop target,允许拖动的元素 drag source 通过鼠标长按拖拽放置到到 drop target 上。

该 API 组主要通过以下事件实现相关的功能:

  • dragstart:当用户开始拖动元素时触发。
  • drag:当元素被拖动时连续触发。
  • dragenter:当拖动的元素进入放置目标时触发。
  • dragleave:当拖动的元素离开放置目标时触发。
  • dragover:当元素被拖动到放置目标上方时连续触发。默认情况下,数据/元素不能放置在其他元素上。要允许放置,我们必须阻止对此事件的默认处理。
  • drop:当元素被放置到放置目标时触发。
  • dragend:当拖动操作结束时(释放鼠标按钮或按下ESC键)触发。

2.1.2. 使用 mouseenter、mousedown 和 mouseup 等事件接口

另一种常见的实现方式是使用鼠标事件接口,如 mouseenter、mousedown 和 mouseup 等。通过监听这些事件,开发者可以捕获用户的拖拽意图, 并相应地调整元素。

这种方式相对灵活,可以根据具体需求进行定制,但可能需要更多的逻辑处理来确保拖拽的准确性和流畅性。可以不局限于拖拽&放置这种固定操作模式,比如直接拖动改变位置、双击改变大小等。

移动端场景,我们则需要 touchstart、touchend、touchmove 等事件来实现,同时也能通过多指操作来实现更复杂的逻辑。

2.1.3. 使用 Canvas 实现

上面说到的方式都是与 html 元素在交互,但是在更复杂的场景,我们可能会使用到 canvas。事实上在 canvas 中也是通过鼠标事件来实现拖拽能力的,这里单独提出来是因为相对于其他方式,它的使用场景更加特殊,实现起来也会复杂许多。

他通常用于高性能、大量元素、自定义要求高的场景。例如:在线版的 PS、在线版的 CAD、以及 figma 等新一代画图软件。

2.2. 各种实现方式的对比

HTML5 Drag and Drop API 使用鼠标事件接口 使用Canvas实现
实现方式 使用HTML5标准中的Drag and Drop API,提供原生的拖拽功能 使用鼠标事件接口,通过监听这些事件,可以捕获用户的拖拽意图, 并相应地移动元素 使用Canvas API,通过在Canvas上绘制元素,并使用鼠标事件来监听和处理拖拽操作
难易程度 相对简单,但操作模式相对固定,可能需要更多的代码来处理复杂的拖拽逻辑 相对复杂,可能需要更多的逻辑处理来确保拖拽的准确性和流畅性 很复杂,需要绘制元素以及自己计算元素的位置,可能需要更多的计算资源,需要处理更多的性能问题
灵活性 较低、一般用于从一个容器拖动到另一个容器 较高,可以根据具体需求进行定制 最高,可以创建出各种复杂的拖拽交互效果
可扩展性 可以实现文件拖拽、元素拖拽等功能 较好,但需要处理多种鼠标事件,通过 touch 事件可以兼容移动端 最好,可以实现各种复杂的自定义拖拽效果
常见的产品 element-ui 的上传组件、排序组件 TMagic、钉钉宜搭 figma、在线PS

2.3. 常见的拖拽库有哪些

为了更加方便快速地在项目中引入拖拽能力,我们也许会去看看业内有哪些现成的轮子。我收集了比较常见的几个拖拽的实现,供大家参考。

2.3.1

React DnD 强调的是拖动放置,实现的是把一个元素拖动到另一个元素上,这是对原生 DnD(HTML5 Drag and Drop API)接口扩展实现。

image.png

React DnD 本身是一个基于 HTML5 的拖放 API 构建的 React 高阶组件。它利用 React 组件的生命周期以及 context API 对拖放状态进行管理。React DnD 的设计理念是把 DOM 操作和事件处理交给开发者,他只负责定义接口和状态管理。

所以 React DnD 并不能单独使用,需要额外的后端模块使用 react-dnd-html5-backend,这个后端模块则是利用浏览器的 drag 和 drop 接口具体实现拖拽的交互。其他的后端模块还有 react-dnd-touch-backend,这可以允许在移动端实现拖拽交互。以及react-dnd-test-backend 对测试的支持。

image.png

React DnD 在使用上,依赖对外暴露的 collect 接口,当被拖拽元素或放置元素的状态发生变化时,就会回调 collect,从而可以根据这个状态更新组件状态。

function DragBox() {
  const [{isDragging}, dragRef] = useDrag({
    type: type,
    item: {type},
    collect(monitor) {
      return {
        // 是否在拖动状态
        isDragging: monitor.isDragging(),
      }
    },
  });
  return <>
    <div className={classnames({
      drag: true,
      // 根据状态修改样式
      dragging: isDragging,
    })} ref={dragRef}></div>
  </>
}

React DnD 可以用于大多数的拖拽场景,例如拖动排序、低代码平台组件的拖拽。

2.3.2

moveable 强调的是对元素的操作:移动、缩放、旋转、变形等。

image.png

moveable 的实现原理主要是通过监听鼠标和触摸事件,然后根据这些事件计算元素的变换属性,进而在回调中执行对应的操作。

Moveable 支持多种交互操作,包括但不限于拖放、缩放、旋转、扭曲和调整大小等。而且能够支持批量操作,对元素操作做了高度地封装。Moveable 非常适用于那些需要进行复杂交互操作的应用场景,例如设计工具、图形编辑器等。

2.3.3

Interact.js 对浏览器原生事件进行封装,通过简洁统一的 API,对多端交互做了统一处理,特别适合在移动端场景使用。

Interact.js 是一个高度灵活且模块化的JavaScript库,它提供了一种简洁且一致的API,可以让你通过监听原生的鼠标和触摸事件,进而实现拖放和其他多种交互操作。

Interact.js 的特点是它提供了一种基于事件的 API,你可以自定义事件处理函数来说实现你的交互逻辑。

// 声明 drop target
interact('.dropzone').dropzone({
  // 声明 drag source
  accept: '#yes-drop',
  // 放置时的重叠部分
  overlap: 0.75,
  // 允许放置时
  ondropactivate: function (event) {
    event.target.classList.add('drop-active')
  },
  // 拖拽进入 drop target 时
  ondragenter: function (event) {
    var draggableElement = event.relatedTarget
    var dropzoneElement = event.target
    dropzoneElement.classList.add('drop-target')
    draggableElement.classList.add('can-drop')
    draggableElement.textContent = 'Dragged in'
  },
  // 拖拽离开 drop target 时
  ondragleave: function (event) {
    event.target.classList.remove('drop-target')
    event.relatedTarget.classList.remove('can-drop')
    event.relatedTarget.textContent = 'Dragged out'
  },
  // 已放置
  ondrop: function (event) {
    event.relatedTarget.textContent = 'Dropped'
  },
  // 当不允许放置时
  ondropdeactivate: function (event) {
    event.target.classList.remove('drop-active')
    event.target.classList.remove('drop-target')
  }
})

Interact.js 对触摸、鼠标和指针事件做了统一处理,这意味着你可以在多种设备上提供一致的交互体验。Interact.js 非常适合那些需要在多种设备上提供一致交互体验的应用,例如可视化数据分析工具、缩放旋转等交互操作的实现。

3. moveable 库介绍

上面提到的拖拽库各有所长,可以结合具体的场景是使用。在笔者参与的低代码编辑器项目中,正是使用到了 moveable 这个库的能力,所以接下来让我们一起探索一下 moveable 的实现原理吧。

3.1. 实现原理

moveable 是一个支持多 UI 框架的库,react-moveable 是它的核心实现,其他 UI 框架的支持都是直接或间接通过 react-moveable 实现的。

image.png

个人猜测这么做的原因主要有两个:一个是 react 的灵活性比较大,保证灵活性的同时也能利用框架能力提效;另一个则因为该库是作者 scena 项目的基础组件,moveable 的首要用户是他们自己,react 则是他们团队或项目的技术栈。

preact-moveable 的实现非常简单,实际并没有增加任何的代码,只是修改了它的 ts 类型,以便在 preact 中使用。

import Moveable from "react-moveable";
import Preact from "preact";
import { PreactMoveableInterface } from "./types";

export default Moveable as any as new (...args: any[]) => PreactMoveableInterface;

在原生 js 和其他框架的适配上,moveable 并没有直接使用完整的 react 来渲染,不然的话就有点杀鸡用牛刀了。它选择自研的类 react 库—— 。croact和 preact 的做法有些相似,使用更少的抽象层和逻辑来打造更轻量级的 react 渲染方案,而它的主要目标就是为了方便将基于 react 构建的组件用于其他 UI 框架。所以 moveable 在此之上适配到其他框架,都是通过 croact 来渲染的。

因为 react-moveable 的方法都是挂载到组件之上的,要在 react 外调用组件的方法,可以通过组件的 ref 参数,在创建真实 DOM 时执行会 ref 指定的函数,将元素作为第一个参数传递传入,这时候我们也就能拿到该组件的实例。

containerProvider = renderSelf(
    <InnerMoveable
        ref={ref(this, "innerMoveable")}
        {...nextOptions}
        {...events}
    /> as any,
    selfElement,
);

export function ref(target: any, name: string) {
    return (e: any) => {
        e && (target[name] = e);
    };
}

moveable 的核心都在 这个类上,它本身是一个 react 的组件,利用 react 的生命周期方法来管理自身的状态。

image.png

moveable 通过两种类型的事件来实现各种功能,这两种事件也会通过回调函数的方式暴露给 able 和使用方。一种是浏览器原生的 mouseEntermouseLeave 事件,另一种则是通过 封装的事件:dragStartdragdragEndpinchStartpinchpinchEnd

事件操作的对象包括目标元素和控制元素,控制元素指的是 able render 出来的组件。分别对各操作对象的不同处理,就能做不同的交互了。

例如:监听目标元素的 drag 事件,修改元素的位置,可以实现 draggable 的能力。监听 control 元素的 drag 事件,更新 control 的位置和修改目标元素的大小,可以实现 scalable 和 resiable 的能力。

image.png

3.2. 能力扩展

moveable 不仅是一个基础功能丰富的库,而且本身也非常灵活和可扩展,允许用户实现更加复杂有趣的功能。

moveable 是通过定义 able 的方式来实现功能的扩展,例如 Tmagic 选中组件后上面的操作按钮就是通过 able 的方式扩展的。

image.png

4. 实现一个 moveable able

看完 moveable 的实现原理之后,我们不妨上手做一个 able 来扩展一下它的能力。

4.1. 如何自定义一个 able

官方仓库中提供了 able 的 api ,在 中也有对应的例子,参照文档和官方示例我们就能很实现一个简单的 able。

定义一个最小 able,我们只需要实现提供一个 name 和一个 render 函数即可。

const CustomAble = {
  name: 'customeAble',
  render() {
      return <div className="moveable-custom-able"></div>;
  }
}

name 即是对 able 的声明,在初始化 moveable 时,需要通过 name 来开启 able 的能力。render 方法返回一个 react node,因为 moveable 的底层是 react 实现的,moveable 的各种功能一般都通过一个元素操作,所以我们这里会把一个 control 组件渲染到页面上。

image.png 例如我们可以在这个元素上绑定一个删除元素的点击事件,那我们就能实现删除目标元素的效果。

render(moveable) {
  function handleClick() {
    moveable.getTargets()[0].remove();
  }
  return <div className="moveable-custom-able" onClick={handleClick}></div>;
}

4.2. 实现一个智能放置组件的 able

笔者设计这个 able 的功能是:给定一个方框,当目标元素拖动到这个方框的范围时,方框高亮,这时候如果放开鼠标按键,就会把目标的元素的大小位置设定到这个方框里面。

image.gif

4.2.1. 参考范围

首先给这个 able 取一个唯一的名字,就叫做 snappableSizeAble,还需要定义一个参考范围,用来指定目标元素变化后的大小和位置,就用 Rect 类型表示即可。所以我们可以这样定义。

interface SnapSizeRect {
  top: number;
  left: number;
  width: number;
  height: number;
}

export const SnappableSizeAble: Able = {
  // 定义 able 的名字
  name: 'snappableSizeAble',
  // 定义 able 接收的参数
  props: [
    'snapSizeRect'
  ],
};

// 使用
function App() {
  const targetRef = useRef(null);
  return (
    <div className="app">
      <div className='target' ref={targetRef}></div>
      <Moveable
        // 声明需要使用的 ables
        ables={[SnappableSizeAble]}
        props={{
          // 根据组件的名字将配置设置成 ture 才能启用 able
          snappableSizeAble: true,
          // 传入 able 的参数
          snapSizeRect: {
            top: 200,
            left: 400,
            width: 200,
            height: 100,
          },
        }}
        target={targetRef}
        // 开启拖拽和缩放的能力
        scalable={true}
        draggable={true}
        // 当拖拽和缩放时通过 transform 修改元素的位置和大小
        onScale={e => {
          e.target.style.transform = e.drag.transform;
        }}
        onDrag={e => {
          e.target.style.transform = e.transform;
        }}
      ></Moveable>
    </div>
  );
}

4.2.2. 渲染参考范围标识

我们需要有一个标识来表示可以放置的位置,以及放置生效时需要有高亮提示。所以我们使用到了 able 的 render 方法,用来渲染一个范围标识。

export const SnappableSizeAble: Able = {
  // ……省略其他
  render(moveable: MoveableManagerInterface<{snapSizeRect: SnapSizeRect}>) {
    const sizeRect = moveable.props.snapSizeRect;
    const style = {
      transform: `translate(${-moveable.state.left}px, ${-moveable.state.top}px)`,
      position: 'fixed',
      // 根据参数设置的范围来设置范围标识的大小和位置
      top: `${sizeRect.top}px`,
      left: `${sizeRect.left}px`,
      width: `${sizeRect.width}px`,
      height: `${sizeRect.height}px`,
      // 通过背景图的样式改变来标识是否可放置
      // moveable.hitTest 是用来检测是否碰撞的方法
      backgroundColor: moveable.hitTest(sizeRect) ? 'rgba(0, 115, 255, 0.146)' : 'transparent',
    };

    return <div 
      // key 是必传的
      key='snappable-size' 
      className='moveable-snappable-size'
      style={style}
    >
    </div>
  },
};

这时候,我们就能实时检测目标元素的位置并更新范围标识的样式了。

image.gif

4.2.3. 自适应功能的实现

当拖拽结束后,把 target 元素的大小和位置设置成和 sizeRect 表示的信息一直即可。需要注意的是,在修改 target 的样式后,需要通过 moveable.updateRect() 触发更新。

export const SnappableSizeAble: Able = {
  // 定义了 dragStart 才会回调 dragEnd
  dragStart() {},
  // 拖拽结束后会调用
  dragEnd(moveable: MoveableManagerInterface<{snapSizeRect: SnapSizeRect}>) {
    const sizeRect = moveable.props.snapSizeRect;
    if(!moveable.hitTest(sizeRect)) return;
    // 如果在标识的范围内,就更新 target 的样式
    const target = moveable.getTargets()[0];
    target.style.width = `${sizeRect.width}px`;
    target.style.height = `${sizeRect.height}px`;
    target.style.transform = `translate(${sizeRect.left}px, ${sizeRect.top}px)`;
    // 修改 target 样式后需要调用这个方法更新 moveable 的状态
    moveable.updateRect()
  },
  // ……其他逻辑
};

至此,我们就实现了拖拽到指定范围内,元素就自动修改成指定的大小和放置到指定位置。

4.3. 应用场景

以上我们实现了一个基本可用的 able ,不妨思考一下,这个能力我们可以用在哪些地方呢?

我目前想到的应用场景是在低代码编辑的时候,可能存在一些特殊的容器组件内部仅包含一个组件,且该宽高位置和容器组件一致,利用这个能力就可以做一个交互优化。

另外一个就是如果编辑器使用的场景是活动,这种场景往往是一个基础背景图+组件实现,背景是包含按钮的位置等图案的。如果能结合 AI 的能力识别出背景图中的按钮和其他组件的边框位置,也能提高页面的编排效率。

5. 总结

浏览器的拖拽能力在许多地方都会用到,例如我们团队正在开发的低代码编辑器、以及后续计划开发的逻辑编排系统、接口裁剪系统等相关的系统,或者 C 端的一些交互需求,都可能需要用到相关的能力。

本文对常用的几个拖拽库做了一些介绍和对比,也针对 moveable 的实现原理做了简单的介绍,并带读者们通过 moveable able 扩展了拖拽功能。

最后,如果客官觉得文章还不错,👏👏👏欢迎点赞、转发、收藏、关注,这是对小编的最大支持和鼓励,鼓励我们持续产出优质内容。

点赞

6. 关于我们

MoonWebTeam目前成员均来自于腾讯,我们致力于分享有深度的前端技术,有价值的人生思考。

7. 往期推荐


网站公告

今日签到

点亮在社区的每一天
去签到