手把手教你实现拖拽新增替换元素

713 阅读4分钟

背景

最近产品提了个需求,要实现往画布拖拽图片、svg,在同类组件上松开鼠标时进行替换操作,反之进行新增。 最终效果如下: iShot_2024-08-26_11.58.03.gif

功能分析

要实现本功能,可以简单拆解成以下几点:

  • 要拖拽元素区域绑定鼠标事件,进行事件监听,设置拖拽状态
  • 拖动元素时,生成临时元素跟随鼠标,并实时设置元素样式
  • 松开鼠标后,获取“落脚点”处元素,判断是新增还是替换元素,最后清空临时元素

实现流程

1、绑定事件

  • 首先给拖拽区域绑定mousedown事件,建议将事件绑定到可拖拽元素的公共父容器上,利用事件委派的机制判断点击的元素,如果给每一个元素单独绑定事件会消耗更多内存。

  • mousemove和mouseup绑定到document上,这个没啥好说的(详细逻辑后续讲解)

    假如拖拽区域的dom结构如下,我们可以将事件绑定到list-wrap

<!-- 素材拖动区域 -->
<div class="list-wrap left">
  <div class="tit">素材区</div>
  <div class="drag-wrap">
    <img class="drag-ele" src="xxx" />
    <img class="drag-ele" src="xxx" />
    ...
  </div>
</div>

2、事件处理

mousedown事件

我们在开始应该给可拖拽元素增加一个通用类名用于标识,这边我们用drag-ele去做标识。鼠标按下时,我们判断当前元素如果存在此类名,则设置拖拽状态dragState并且记录拖拽元素,用于后续逻辑处理。

// 拖拽状态
let dragState = false;
// 拖拽元素
let dragEle = null;

function mouseDown(event) {
  // 判断在可拖动元素上进行点击时,才执行后续拖拽相关逻辑
  if (event.target.classList.contains('drag-ele')) {
    dragState = true;
    dragEle = event.target;
  }
}
mousemove事件

在此事件内,我们需要创建跟随鼠标的临时元素,并实时设置元素的位置。

创建元素

创建时首先判断是否已存在临时元素,存在的话则跳过创建流程,反之进行元素创建。

为了使创建的临时元素与鼠标在元素上按下时,保持相同的视觉位置效果,需要对其left和top做一些处理,使用鼠标的当前的位置减去鼠标相对于元素的偏移位置,才能得到正确的left和top。

left = clientX - offsetX;

top = clientY - offsetY;

iShot_2024-08-27_10.18.58.png

拖动时,元素放大效果实现

通常拖拽区域的的缩略图比较小,为了易用性,我们拖拽时,可以逐步放大图片,让用户看的更清晰些。

效果如下:

iShot_2024-08-27_10.35.01.gif

实现这个效果需要事先设定几个参数: 最大放大倍数 - 拖拽元素最大放大的倍数 最大移动距离 - 拖动多少距离能达到最大倍数 上图中最大放大倍数是2,最大移动距离是100,以上两个值,可以根据项目情况随便调整。 在首次生成临时元素时,记录鼠标的初始位置和相对元素的偏移,然后在鼠标拖动的过程中,判断鼠标的移动距离,计算要放大的倍数,进行设置。

优化:设置缩放时有一个需要注意的点,scale默认是基于元素中心进行的,不做任何处理,元素放大时会导致鼠标视觉位置的偏移,比如下图,我在索隆的手上拖动,随着放大,位置发生了偏移。 iShot_2024-08-27_11.11.12.gif

要解决这个问题,需要在初始的时候设置下transform-origin(设置动画基点),设置值为鼠标初始相对于图片的偏移,这样放大后就能保持视觉统一了。

iShot_2024-08-27_11.12.27.gif

代码参考:

// 存储创建的临时元素
let dragTempEle = null;

function mouseMove(event) {
  if (dragState) {
    // 创建临时元素
    createTempEle(event);

    // 修改临时元素位置
    setTempPosi(event);
  }
}

// 拖拽元素时生成临时元素
function createTempEle(event) {
  // 判断是否已创建临时元素
  if (dragTempEle) return;

  const w = event.target.offsetWidth;
  const h = event.target.offsetHeight;
  let left = 0;
  let top = 0;
  dragEleInfo = {
    offX: event.offsetX,
    offY: event.offsetY,
    left: event.clientX,
    top: event.clientY
  };
  let tempEle = event.target.cloneNode(true);
  tempEle.id = 'drag-temp';

  // 获取拖拽元素内的封图元素,克隆的元素内使用原图,保证放大后的清晰度
  tempEle.style.cssText = `max-width:${w}px; max-height:${h}px;`;

  // 设置临时元素的样式
  left = event.clientX - dragEleInfo.offX;
  top = event.clientY - dragEleInfo.offY;
  tempEle.style.cssText = `
      position: fixed;
      left: ${left}px;
      top: ${top}px;
      z-index:100;
      width:${w}px;
      height:${h}px;
      opacity:.6;
      transform-origin:${dragEleInfo.offX}px ${dragEleInfo.offY}px;
  `;
  dragTempEle = tempEle;
  document.body.appendChild(tempEle);
}

// 拖动时,设置临时元素的位置
function setTempPosi(event) {
    if (dragState) {
        // 计算移位置
        dragTempEle.style.left = event.clientX - dragEleInfo.offX + 'px';
        dragTempEle.style.top = event.clientY - dragEleInfo.offY + 'px';

        // 根据横向拖动距离,计算放大比例(最多放大2倍)
        const moveX = event.clientX - dragEleInfo.left;
        const ratio = Math.min(Math.max((moveX / 100) * 1, 0), 1);
        const scale = 1 + ratio;
        dragTempEle.style.transform = `scale(${scale})`;
    }
}
mouseup事件

此事件内需要做两件事,判断是新增还是替换元素、重置拖拽状态清空临时数据。

判断新增还是替换,需要判断鼠标松开时,当前位置的元素,如果是在放置区图片上松开,则进行替换,反之则是新增。

要获取松开位置的元素列表,可以通过document.elementsFromPoint(x,y)获取,然后遍历元素,根据标识判断即可。

替换:直接替换图片地址即可,无需赘述

新增:需要根据放置区和鼠标位置计算出元素最终相对于容器的位置,如下图

元素最终的 left = left1 - left2 - 初始鼠标相对于元素的左偏移 , top同理

优化:如果是在一些低代码编辑器中做这个功能,因为编辑器的画布通常带有缩放功能。在新增元素时,为了保持拖拽元素的视觉效果和新增的元素一致,需要对新增元素尺寸做转换处理,转换的逻辑与画布的缩放比例成反比,画布放的越大,元素尺寸转换结果越小,画布缩放越小,元素尺寸转换结果越大,借此达到视觉统一。

iShot_2024-08-27_11.39.59.png 代码参考:

function mouseUp(event) {
  if (dragState) {
    // 判断是否在同类元素上松开,是则进行替换,反之新增
    const sameEle = findSameEle();
    if (sameEle) {
      replaceEle(sameEle);
    } else {
      addEle();
    }

    // 移除拖拽临时元素,重置拖拽相关数据
    dragState = false;
    dragTempEle = null;
    if ($('#drag-temp')) {
      $('#drag-temp').remove();
    }
  }
}

// 查找当前位置是否所有同类元素
function findSameEle() {
  const eles = document.elementsFromPoint(event.pageX, event.pageY);
  let sameEle = null;
  for (let item of eles) {
    if (item.classList.contains('eles')) {
      sameEle = item;
      break;
    }
  }
  return sameEle;
}

// 拖拽新增数据
function addEle(e) {
  const eles = document.elementsFromPoint(event.pageX, event.pageY);

  // 判断在画布上松开鼠标才新增元素
  let add = false;
  for (let ele of eles) {
    // 如果在放置区松开鼠标则新增
    if (ele.classList.contains('place-wrap')) {
      add = true;
      break;
    }
  }
  if (add) {
    // 获取画布和临时元素信息
    const place = $('.place-wrap');
    const info = place.getBoundingClientRect();
    const infoTemp = dragTempEle.getBoundingClientRect();

    // 计算元素插入的位置、尺寸等信息
    const imgEle = document.createElement('img');
    imgEle.src = dragTempEle.src;
    imgEle.classList.add('eles');
    imgEle.style.cssText = `
        position: absolute;
        left: ${infoTemp.x - info.x}px;
        top: ${infoTemp.y - info.y}px;
        width:${infoTemp.width}px;
        height:${infoTemp.height}px;
    `;
    place.appendChild(imgEle);
  }
}

// 松开鼠标判断新增还是替换同类元素
function replaceEle(sameEle) {
  sameEle.src = dragTempEle.src;
}

Demo