HTML5 新特性drag API 实现图片拖拽功能(原生JS,Vue, React)

42 篇文章 4 订阅
32 篇文章 0 订阅

在这里插入图片描述


HTML5 新特性 drag API 浏览器支持情况

在这里插入图片描述

可拖拽的 DIV

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    * {
      padding: 0;
      margin: 0;
    }
    .big-box {
      width: 200px;
      height: 200px;
      background-color: aquamarine;
      position: relative;
    }
    .small-box {
      position: absolute;
      width: 50px;
      height: 50px;
      background-color: blueviolet;
      z-index: 999;
      left: 0;
      top: 200px;
    }
  </style>
</head>

<body>
  <div class="big-box"></div>
  <div class="small-box" id="small"></div>

  <script>
    // 设置是否可拖拽
    let draggle = false;
    let position = null;
    let smallBox = document.getElementById('small');
    smallBox.addEventListener('mousedown', function(e) {
      draggle = true
      position = [e.clientX, e.clientY]
    });
    document.addEventListener('mousemove', function(e) {
      if (draggle === false) return null
      const x = e.clientX
      const y = e.clientY
      const deltaX = x - position[0]
      const deltaY = y - position[1]
      const left = parseInt(smallBox.style.left || e.clientX)
      const top = parseInt(smallBox.style.top || e.clientY)
      smallBox.style.left = left + deltaX + 'px'
      smallBox.style.top = top + deltaY + 'px'
      position = [x, y]
    });
    document.addEventListener('mouseup', function(e) {
      draggle = false
    });
  </script>
</body>

</html>

拖拽事件

在IE4的时候,Js就可以实现拖放功能了,当时只支持拖放图像和一些文本。后来随着IE版本的更新,拖放事件也在慢慢完善,HTML5就以IE的拖放功能制定了该规范,Firefox 3.5、Safari 3+、chrome以及它们之后的版本都支持了该功能。 默认情况下,网页中的图像、链接和文本是可以拖动的,而其余的元素若想要被拖动,必须将 draggable 属性设置为true,这是HTML5规定的新属性,用于设置元素是否能被拖动。因此,图像、链接、文本的 draggable 属性默认为 true,其余元素的 draggable 属性默认为 false。在实现拖放功能时有这样两个概念,分别是被拖动元素和目标元素,它们都有各自支持的事件,那么我们来了解一下。

1. 被拖动元素的事件

被拖动元素所支持的事件

事件含义
dragstart准备拖动被拖动元素时触发
drag拖动的过程中触发(频繁触发)
dragend拖动结束时触发

代码基本 HTML 结构

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    .box {
      width: 100px;
      height: 100px;
      background-color: lightgreen;
    }
  </style>
</head>

<body>
  <div class="box" draggable='true'></div>
</body>

</html>
let box = document.querySelector('.box')

// 绑定dragstart事件
box.addEventListener('dragstart', function () {
  console.log('拖拽开始了');
})
// 绑定drag事件
box.addEventListener('drag', function () {
  console.log('元素被拖动');
})
// 绑定dragend事件
box.addEventListener('dragend', function () {
  console.log('拖拽结束');
});

在这里插入图片描述

2. 目标元素的事件

在实现拖放功能的过程中,目标元素上的事件有如下三个

事件含义
dragenter被拖放元素进入目标元素时触发
dragover被拖放元素在目标元素内时触发(频繁触发)
dragleave被拖动元素离开目标元素时触发
drop当被拖动元素被放到了目标元素中时触发

dragenter事件与 mouseover事件类似,当被拖放元素的一半以上面积在目标元素内才算进入了目标元素。

dragover 事件,当拖放元素进入目标元素以后就会一直触发,即使不移动元素也会触发,除非拖放事件结束或者被拖放元素离开目标元素。

dragleave 事件的其触发条件正好与 dragenter 事件相反,它是当被拖放元素离开目标元素时触发,离开目标元素的条件是:被拖放元素一半以上的面积离开目标元素

drop 事件可以叫做放置目标事件,它是当被拖放元素放置到了目标元素中时触发。虽然任何元素都支持该事件,但是所有元素默认都是不允许被放置的,所以在不做任何处理的情况下,该事件是不会触发的。

代码基本 HTML 结构

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    .box {
      width: 100px;
      height: 100px;
      background-color: lightgreen;
    }

    .location {
      width: 100px;
      height: 100px;
      background-color: lightpink;
    }
  </style>
</head>

<body>
  <div class="box" draggable='true'></div>
  <div class="location">目标元素</div>
</body>

</html>
let located = document.querySelector('.location')
// 绑定dragenter事件
located.addEventListener('dragenter', function () {
  console.log('元素进入了目标元素');
})
// 绑定dragover事件
located.addEventListener('dragover', function () {
  console.log('元素在目标元素内');
})
// 绑定dragleave事件
located.addEventListener('dragleave', function () {
  console.log('元素离开了目标元素');
});

在这里插入图片描述

触发 drop事件,只需要阻止 dragenter事件 和 dragover事件 的默认行为即可。

let located = document.querySelector('.location')
located.addEventListener('dragenter', function (e) {
  e.preventDefault()
})
located.addEventListener('dragover', function (e) {
  e.preventDefault()
})
located.addEventListener('drop', function () {
  console.log('元素被放置');
});

在这里插入图片描述

3. dataTransfer对象

通过 event.dataTransfer 来获取该对象,其主要的作用就是从被拖放元素向目标元素传递一个字符串数据

(1)方法

dataTransfer上有两个方法

方法含义
setData设置字符串,并设置数据类型
getData获取对应数据类型的字符串

setData() 方法接收两个参数,第一个参数表示的是字符串的数据类型,HTML5规定了两种数据类型,分别是 text/plaintext/uri-list,前者表示普通字符串,后者表示URL字符串;第二个参数用于存放的字符串。

getData() 方法只接收一个参数,即需要接收的字符串类型。

let box = document.querySelector('.box')
// 为被拖放元素绑定 dragstart 事件
box.addEventListener('dragstart', function (e) {
  // 设置类型为 text/plain 的 字符串
  e.dataTransfer.setData('text/plain', '我来自被拖拽的元素')
})
let located = document.querySelector('.location')
located.addEventListener('dragenter', function (e) {
  e.preventDefault()
})
located.addEventListener('dragover', function (e) {
  e.preventDefault()
})
located.addEventListener('drop', function (e) {
  // 将被拖放元素放置到目标元素时获取字符串
  let res = e.dataTransfer.getData('text/plain')
  console.log(res);
});

在这里插入图片描述

(2)属性

dataTransfer 对象上还有两个比较常用的属性

属性含义
dropEffect被拖放元素的放置行为
effectAllowed目标元素支持的放置行为

这个两个属性需要搭配使用,它们决定了被拖放元素目标元素之间的关系的,当设定好两者的关系后,在进行拖动操作的时候,鼠标会根据不同的关系显示不同的样式,除此之外,没有别的特别的作用。

dropEffect 可以设置以下几个属性

含义
none默认值。不能把拖动的元素放在这里
move应该把拖动的元素移动到该目标元素
copy应该把拖动元素复制到该目标元素
link表示目标元素会打开被拖放进来的元素对应的链接

注意:dropEffect 属性必须在 dragenter 事件中设置,否则无效

effectAllowed 可以设置以下几个属性

含义
uninitialized被拖放元素没有设置放置行为
none被拖放元素不能有放置行为
copy只允许值为 ‘copy’ 的 dropEffect 目标元素
link只允许值为 ‘link’ 的 dropEffect 目标元素
move只允许值为 ‘move’ 的 dropEffect 目标元素
copyLink只允许值为 ‘copy’ 和 ‘link’ 的 dropEffect 目标元素
copymove只允许值为 ‘copy’ 和 ‘move’ 的 dropEffect 目标元素
linkMove只允许值为 ‘link’ 和 ‘move’ 的 dropEffect 目标元素
all只允许任意值的 dropEffect 目标元素

注意:effectAllowed 属性必须在 dragstart 事件中设置,否则无效

这两个属性基本上只是用来改变鼠标样式的,所以如果想实现特定的功能还得我们自己来重写事件的处理函数。

(3)拖放实例

需求: 将一段文本拖放到一个元素中
因为文本是默认支持的拖放元素,所以我们可以不对其做任何的事件绑定。

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    .location {
      width: 100px;
      height: 100px;
      border: 1px solid black;
    }
  </style>
</head>

<body>
  <div class="box">我是一段测试文字</div>
  <div class="location"></div>
  <script>
    let located = document.querySelector('.location')

    located.addEventListener('dragenter', function (e) {
      e.dataTransfer.dropEffect = 'copy'
      e.preventDefault()
    })
    located.addEventListener('dragover', function (e) {
      e.preventDefault()
    })
    located.addEventListener('drop', function (e) {
      e.target.innerHTML = e.dataTransfer.getData('text/plain')
    });
  </script>
</body>

</html>

在这里插入图片描述

拖拽 DIV
<!DOCTYPE html>
<html>

<head>
  <meta charset="UTF-8">
  <title>HTML5中的拖放效果</title>
  <style>
    .html-drag-container {
      height: 70vh;
      background-color: #eee;
    }

    .html-drag-box {
      width: 200px;
      height: 200px;
      background-color: tomato;
      border-radius: 10px;
      position: relative;
    }

    .html-drop-box {
      width: 300px;
      height: 300px;
      background-color: aquamarine;
      display: flex;
      justify-content: center;
      align-items: center;
    }
  </style>
</head>

<body>
  <div className="html-drag-container">
    <div id="html-drag-box-container">
      <div class="html-drag-box" draggable="true"></div>
    </div>
    <div class="html-drop-box"></div>
  </div>

  <script type="text/javascript">
    let dragbox = document.querySelector('.html-drag-box')
    // 目标元素
    let dropbox = document.querySelector('.html-drop-box')

    dragbox.addEventListener('dragstart', function (e) {
      e.dataTransfer.setData('text/plain', e.target.className);
    });

    // 目标元素
    dropbox.addEventListener('dragover', function (e) {
      e.preventDefault();
    });

    // 目标元素
    dropbox.addEventListener('drop', function (e) {
      e.preventDefault();
      dropbox.appendChild(document.getElementsByClassName(e.dataTransfer.getData('text/plain'))[0]);
    });
  </script>
</body>

</html>

在这里插入图片描述

Vue 实现图片拖动排序

在拖动目标上触发事件 (源元素):
事件含义
ondragstart用户开始拖动元素时触发
ondrag元素正在拖动时触发
ondragend用户完成元素拖动后触发
释放目标时触发的事件:
事件含义
ondragenter当被鼠标拖动的对象进入其容器范围内时
ondragover当某被拖动的对象在另一对象容器范围内拖动时触发此事件
ondragleave当被鼠标拖动的对象离开其容器范围内时触发此事件
ondrop在一个拖动过程中,释放鼠标键时触发此事件

拖拽排序的原理大概是:鼠标按住列表某一项开始拖动时触发ondragstart事件,将该拖动项用变量记录下来;接着拖拽过程中,该拖动项经过列表其他项时,触发ondragenter事件,同样记录该拖动项最后经过的列表其他项的数据,最后在ondragend 事件中将数组列表删掉一开始ondragstart事件记录的拖动项,并将删掉的数据插入ondragenter事件最后记录的位置,完成拖动排序。

<template>
  <div class="test_wrapper" @dragover="dragover($event)">
    <transition-group class="transition-wrapper" name="sort">
      <!-- 循环列表中的每一项都是拖拽对象 -->
      <div
        v-for="item in dataList"
        :key="item.id"
        class="sort-item"
        :draggable="true"
        @dragstart="dragstart(item)"
        @dragenter="dragenter(item, $event)"
        @dragend="dragend(item, $event)"
        @dragover="dragover($event)"
      >
        <img :src="item.imgUrl" alt="" />
      </div>
    </transition-group>
  </div>
</template>

<script>
export default {
  name: "HelloWorld",
  props: {
    msg: String,
  },
  data() {
    return {
      dataList: [
        {
          id: 1,
          imgUrl:
            "https://cdn.pixabay.com/photo/2020/11/28/10/09/clouds-5784152__340.jpg",
        },
        {
          id: 2,
          imgUrl:
            "https://cdn.pixabay.com/photo/2020/12/05/07/32/mountains-5805337__340.jpg",
        },
        {
          id: 3,
          imgUrl:
            "https://cdn.pixabay.com/photo/2020/12/06/00/59/trees-5807492__340.jpg",
        },
        {
          id: 4,
          imgUrl:
            "https://cdn.pixabay.com/photo/2020/11/28/09/02/road-5784006__340.jpg",
        },
      ],
      oldData: 0, // 开始排序时按住的旧数据
      newData: 0, // 拖拽过程的数据
    };
  },
  methods: {
    // 1. 拖动元素时,将元素赋值给 oldData
    dragstart(value) {
      this.oldData = value;
    },
    // 2. 当被鼠标拖动的对象进入其容器范围内时,将元素赋值给 this.newData。
    // 同时想要触发 drop事件,需要阻止 dragenter事件的默认行为。
    dragenter(value, e) {
      this.newData = value;
      e.preventDefault();
    },
    // 3. 用户完成元素拖动后触发,比较新值和旧值是否相同,如果不同,则从数组中获取对应的 index,删除老的节点,在数组中,将新值添加进入
    dragend(value, e) {
      if (this.oldData !== this.newData) {
        let oldIndex = this.dataList.indexOf(this.oldData);
        let newIndex = this.dataList.indexOf(this.newData);
        let newItems = [...this.dataList];
        // 删除老的节点
        newItems.splice(oldIndex, 1);
        // 在列表中目标位置增加新的节点
        newItems.splice(newIndex, 0, this.oldData);
        this.dataList = [...newItems];
      }
    },
    // 4. 触发 drop事件,需要阻止 dragover事件的默认行为。
    dragover(e) {
      e.preventDefault();
    },
  },
};
</script>

<style scoped lang="scss">
.sort-move {
  transition: transform 0.3s;
}
</style>

在这里插入图片描述

React 实现图片拖动排序

在这里插入图片描述

思路分析
  • 本文图片拖拽排序的实现主要利用了元素在拖放的过程中会触发的 ondragstart、ondragend 和 ondragover 事件。
  • 通过监听 ondragstart 事件,将当前被拖动元素的数据保存下来,并给其增加特殊样式(设置元素的透明度和放大元素)。
  • 通过监听 ondragend 事件,将上面一步设置在被拖动元素的特殊样式删除。为了减少内存消耗,我们把被拖动元素的 ondragend 事件委托到最外层容器(事件委托)
  • 实现最重要的拖动排序功能,主要是为元素绑定 ondragover 事件。当 ondragover 事件被触发时,需要获取当前鼠标的位置(event.clientX, event.clientY),计算出当前鼠标拖动到哪个元素上通过判断当前被拖动元素和其他元素的位置,从而实现元素的交换排序。

具体实现

基本布局

在实现拖拽功能之前,先完成基本布局:

import './App.css';
import React, { useMemo, useState } from 'react';

//  每行多少列
const COLUMN = 5;
//  每个元素宽度
const WIDTH = 120;
//  每个元素高度
const HEIGHT = 80;
// 图片左右 padding
const IMAGE_PADDING = 5;

const showList = [
  {
    id: 2,
    name: 'osmo pocket',
    image: 'https://cdn.pixabay.com/photo/2021/05/29/05/48/beach-6292506__340.jpg',
  },
  {
    id: 4,
    name: 'mavic pro',
    image: 'https://cdn.pixabay.com/photo/2021/05/24/11/56/lake-6278825__340.jpg',
  },
  {
    id: 1,
    name: 'mavic mini2',
    image: 'https://cdn.pixabay.com/photo/2021/05/25/12/50/flower-6282371__340.jpg',
  },
  {
    id: 3,
    name: '机甲大师s1',
    image: 'https://cdn.pixabay.com/photo/2021/05/28/15/58/marina-6291287__340.jpg',
  },
  {
    id: 0,
    name: 'mavic 2',
    image: 'https://cdn.pixabay.com/photo/2021/05/29/17/37/fantasy-6293929__340.jpg',
  },
];

const DragAndDropPage = () => {
  const [list, setList] = useState(showList);

  // 动画需要,需要保持一定的渲染顺序
  const sortedList = useMemo(() => {
    return list.slice().sort((a, b) => {
      return a.id - b.id;
    });
  }, [list]);

  const listHeight = useMemo(() => {
    const size = list.length;
    return Math.ceil(size / COLUMN) * HEIGHT;
  }, [list]);

  return (
    <div 
      className="warpper" 
      style={{ width: COLUMN * (WIDTH + IMAGE_PADDING) + IMAGE_PADDING }}
    >
      <ul className="list" style={{ height: listHeight }}>
        {sortedList.map((item) => {
          const index = list.findIndex((i) => i === item);
          const row = Math.floor(index / COLUMN);
          const col = index % COLUMN;
          return (
            <li
              key={item.id}
              className="item"
              style={{
                height: HEIGHT,
                left: col * (WIDTH + IMAGE_PADDING),
                top: row * HEIGHT,
                padding: `0 ${IMAGE_PADDING}px`
              }}
              data-id={item.id}
            >
              <img src={item.image} alt={item.name} width={WIDTH} />
            </li>
          );
        })}
      </ul>
    </div>
  );
};

export default React.memo(DragAndDropPage);

样式文件的内容如下:

.wrapper {
  overflow: hidden;
  margin: 100px auto;
  padding: 10px 0;
  background: #fff;
  border-radius: 5px;
}

.list {
  list-style: none;
  padding: 0;
  margin: 0;
  font-size: 0;
  position: relative;
}

.item {
  position: absolute;
  display: flex;
  align-items: center;
  transition: all 0.2s ease-in-out;
}

展示的 li 元素看起来像是按照 list 原始顺序展示,但是实际上其渲染顺序是由排序后的列表 sortedList 决定的。

之所以表现像是按照 list 原始顺序展示,是通过在 sortedList.map 时根据当前 item 在 list 数组的索引 index 计算出其CSS的 left 和 top 属性。

拖拽实现

首先,为最外层的容器 wrapper 绑定 ref 属性,方便在之后的事件方法中获取到该 DOM 节点,并将每个 li 元素的 draggable 属性的值设置为 true 使其变为可拖动元素,同时绑定 ondragstart 事件:

<div 
      className="warpper"
      // 绑定 ref 属性
      ref={dropAreaRef}
    >
      <ul className="list" style={{ height: listHeight }}>
        {sortedList.map((item) => {
          const index = list.findIndex((i) => i === item);
          const row = Math.floor(index / COLUMN);
          const col = index % COLUMN;
          return (
            <li
              key={item.id}
              className="item"
              // draggable 属性的值设置为 true 使其变为可拖动元素
              draggable
              style={{
                height: HEIGHT,
                left: col * (WIDTH + IMAGE_PADDING),
                top: row * HEIGHT,
                padding: `0 ${IMAGE_PADDING}px`
              }}
              data-id={item.id}
              // 同时绑定 ondragstart 事件
              onDragStart={(e) => handleDragStart(e, item)}
            >
              <img src={item.image} alt={item.name} width={WIDTH} />
            </li>
          );
        })}
      </ul>
    </div>

handleDragStart 方法主要是将当前被拖动元素的数据保存下来,并给其增加特殊样式(设置元素的透明度和放大元素)。

const handleDragStart = (e, data) => {
  dragItemRef.current = data
  const el = dropAreaRef.current?.querySelector(`[data-id="${data.id}"]`)
  if (el) {
    el.classList.add('.draggingItem')
  }
}

拖放结束移除特殊样式

当开始拖动元素时,元素应用了特殊样式,但是松开鼠标后元素没恢复原来的样式。这时候就需要在拖动结束后删除前面添加的特殊样式。
为了减少内存消耗,我们把被拖动元素的 ondragend 事件委托到最外层容器(事件委托),相关代码如下:

<div 
   className="warpper"
   onDragEnd={handleDragEnd}>
</div>
const handleDragEnd = useCallback(()=>{
    const data = dragItemRef.current
    if (data) {
      const el = dropAreaRef.current?.querySelector(`[data-id="${data.id}"]`)
      if (el) {
        el.classList.remove('.draggingItem')
      }
      dragItemRef.current = undefined
    }
  }, [])
拖动排序

主要是为元素绑定 ondragover 事件。

默认情况下,数据/元素不能放置到其他元素中。如果要实现该功能,我们需要防止元素的默认处理方法。我们可以通过调用 event.preventDefault() 方法来实现 ondragover 事件。

当 ondragover 事件被触发时,需要获取当前鼠标的位置(event.clientX, event.clientY),计算出当前鼠标拖动到哪个元素上通过判断当前被拖动元素和其他元素的位置,实现元素的交换排序,关键的是实现 updateList 方法,相关代码如下:

/** 将某元素插入到数组中的某位置 */
export function insertBefore(list, from, to) {
  const copy = [...list];
  const fromIndex = copy.indexOf(from);
  if (from === to) {
    return copy;
  }
  copy.splice(fromIndex, 1);
  const newToIndex = to ? copy.indexOf(to) : -1;
  if (to && newToIndex >= 0) {
    copy.splice(newToIndex, 0, from);
  } else {
    // 没有 To 或 To 不在序列里,将元素移动到末尾
    copy.push(from);
  }
  return copy;
}

/** 判断是否数组相等 */
export function isEqualBy(a, b, key) {
  const aList = a.map((item) => item[key]);
  const bList = b.map((item) => item[key]);
  
  let flag = true;
  aList.forEach((i, idx) => {
    if (i !== bList[idx]) {
      flag = false
    }
  })
  return flag;
}

  const updateList = useCallback((clientX, clientY) => {
      const dropRect = dropAreaRef.current?.getBoundingClientRect();
      if (dropRect) {
        const offsetX = clientX - dropRect.left;
        const offsetY = clientY - dropRect.top;
        const dragItem = dragItemRef.current;
        // 超出拖动区域
        if (
          !dragItem ||
          offsetX < 0 ||
          offsetX > dropRect.width ||
          offsetY < 0 ||
          offsetY > dropRect.height
        ) {
          return;
        }

        const col = Math.floor(offsetX / WIDTH);
        const row = Math.floor(offsetY / HEIGHT);
        let currentIndex = row * COLUMN + col;
        const fromIndex = list.indexOf(dragItem);
        if (fromIndex < currentIndex) {
          // 从前往后移动
          currentIndex++;
        }
        const currentItem = list[currentIndex];

        const ordered = insertBefore(list, dragItem, currentItem);
        if (isEqualBy(ordered, list, 'id')) {
          return;
        }
        setList(ordered);
      }
    }, [list]);

  const handleDragOver = useCallback((e) => {
      e.preventDefault();
      updateList(e.clientX, e.clientY);
    }, [updateList]);

效果如下:
在这里插入图片描述

完整代码

import './App.css';
import React, { useMemo, useState, useRef, useCallback } from 'react';

//  每行多少列
const COLUMN = 5;
//  每个元素宽度
const WIDTH = 120;
//  每个元素高度
const HEIGHT = 80;
// 图片左右 padding
const IMAGE_PADDING = 5;

const showList = [
  {
    id: 2,
    name: 'osmo pocket',
    image: 'https://cdn.pixabay.com/photo/2021/05/29/05/48/beach-6292506__340.jpg',
  },
  {
    id: 4,
    name: 'mavic pro',
    image: 'https://cdn.pixabay.com/photo/2021/05/24/11/56/lake-6278825__340.jpg',
  },
  {
    id: 1,
    name: 'mavic mini2',
    image: 'https://cdn.pixabay.com/photo/2021/05/25/12/50/flower-6282371__340.jpg',
  },
  {
    id: 3,
    name: '机甲大师s1',
    image: 'https://cdn.pixabay.com/photo/2021/05/28/15/58/marina-6291287__340.jpg',
  },
  {
    id: 0,
    name: 'mavic 2',
    image: 'https://cdn.pixabay.com/photo/2021/05/29/17/37/fantasy-6293929__340.jpg',
  },
];


/** 将某元素插入到数组中的某位置 */
export function insertBefore(list, from, to) {
  const copy = [...list];
  const fromIndex = copy.indexOf(from);
  if (from === to) {
    return copy;
  }
  copy.splice(fromIndex, 1);
  const newToIndex = to ? copy.indexOf(to) : -1;
  if (to && newToIndex >= 0) {
    copy.splice(newToIndex, 0, from);
  } else {
    // 没有 To 或 To 不在序列里,将元素移动到末尾
    copy.push(from);
  }
  return copy;
}

/** 判断是否数组相等 */
export function isEqualBy(a, b, key) {
  const aList = a.map((item) => item[key]);
  const bList = b.map((item) => item[key]);
  
  let flag = true;
  aList.forEach((i, idx) => {
    if (i !== bList[idx]) {
      flag = false
    }
  })
  return flag;
}


const DragAndDropPage = () => {
  const [list, setList] = useState(showList);
  const dropAreaRef = useRef(null)
  const dragItemRef = useRef()

  // 动画需要,需要保持一定的渲染顺序
  const sortedList = useMemo(() => {
    return list.slice().sort((a, b) => {
      return a.id - b.id;
    });
  }, [list]);

  const listHeight = useMemo(() => {
    const size = list.length;
    return Math.ceil(size / COLUMN) * HEIGHT;
  }, [list]);

  const handleDragStart = (e, data) => {
    dragItemRef.current = data
    const el = dropAreaRef.current?.querySelector(`[data-id="${data.id}"]`)
    if (el) {
      el.classList.add('.draggingItem')
    }
  }

  const handleDragEnd = useCallback(()=>{
    const data = dragItemRef.current
    if (data) {
      const el = dropAreaRef.current?.querySelector(`[data-id="${data.id}"]`)
      if (el) {
        el.classList.remove('.draggingItem')
      }
      dragItemRef.current = undefined
    }
  }, [])

  const updateList = useCallback((clientX, clientY) => {
      const dropRect = dropAreaRef.current?.getBoundingClientRect();
      if (dropRect) {
        const offsetX = clientX - dropRect.left;
        const offsetY = clientY - dropRect.top;
        const dragItem = dragItemRef.current;
        // 超出拖动区域
        if (
          !dragItem ||
          offsetX < 0 ||
          offsetX > dropRect.width ||
          offsetY < 0 ||
          offsetY > dropRect.height
        ) {
          return;
        }

        const col = Math.floor(offsetX / WIDTH);
        const row = Math.floor(offsetY / HEIGHT);
        let currentIndex = row * COLUMN + col;
        const fromIndex = list.indexOf(dragItem);
        if (fromIndex < currentIndex) {
          // 从前往后移动
          currentIndex++;
        }
        const currentItem = list[currentIndex];

        const ordered = insertBefore(list, dragItem, currentItem);
        if (isEqualBy(ordered, list, 'id')) {
          return;
        }
        setList(ordered);
      }
    }, [list]);

  const handleDragOver = useCallback((e) => {
      e.preventDefault();
      updateList(e.clientX, e.clientY);
    }, [updateList]);

  return (
    <div 
      className="warpper"
      // 绑定 ref 属性
      ref={dropAreaRef}
      style={{ width: COLUMN * (WIDTH + IMAGE_PADDING) + IMAGE_PADDING }}
      onDragEnd={handleDragEnd}
      onDragOver={handleDragOver}
    >
      <ul className="list" style={{ height: listHeight }}>
        {sortedList.map((item) => {
          const index = list.findIndex((i) => i === item);
          const row = Math.floor(index / COLUMN);
          const col = index % COLUMN;
          return (
            <li
              key={item.id}
              className="item"
              // draggable 属性的值设置为 true 使其变为可拖动元素
              draggable
              style={{
                height: HEIGHT,
                left: col * (WIDTH + IMAGE_PADDING),
                top: row * HEIGHT,
                padding: `0 ${IMAGE_PADDING}px`
              }}
              data-id={item.id}
              // 同时绑定 ondragstart 事件
              onDragStart={(e) => handleDragStart(e, item)}
            >
              <img src={item.image} alt={item.name} width={WIDTH} />
            </li>
          );
        })}
      </ul>
    </div>
  );
};

export default React.memo(DragAndDropPage);

样式文件内容:

.wrapper {
  overflow: hidden;
  margin: 100px auto;
  padding: 10px 0;
  background: #fff;
  border-radius: 5px;
}

.list {
  list-style: none;
  padding: 0;
  margin: 0;
  font-size: 0;
  position: relative;
}

.item {
  position: absolute;
  display: flex;
  align-items: center;
  transition: all 0.2s ease-in-out;
}

.draggingItem {
  transform: scale(1.06);
  opacity: 0.7;
}

参考文章:

https://juejin.cn/post/6896712416928169991
https://www.cnblogs.com/shandou/p/13818367.html
https://mp.weixin.qq.com/s/kjVo7hVz6Lvvdqxp7Hvihw

  • 8
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值