Skip to content

useDraggable

强大的基于 HTML5 拖放 API 的可组合式拖拽函数,支持自定义数据结构和灵活的拖拽位置控制

  • 项目 1
  • 项目 2
  • 项目 3
  • 项目 4

useDraggable.ts

ts
/* eslint-disable ts/no-unsafe-function-type */
import { ref } from 'vue'

// eslint-disable-next-line no-restricted-globals
const target = typeof window === 'undefined' ? global : window

const raf = target.requestAnimationFrame
const caf = target.cancelAnimationFrame

export { raf, caf }

export type DropPosition = -1 | 0 | 1 // -1: 放在目标之前, 0: 成为子节点, 1: 放在目标之后

export function throttleByRaf(cb: (...args: any[]) => void) {
  let timer = 0

  const throttle = (...args: any[]): void => {
    if (timer)
      caf(timer)

    timer = raf(() => {
      cb(...args)
      timer = 0
    })
  }

  throttle.cancel = () => {
    caf(timer)
    timer = 0
  }

  return throttle
}

/**
 * 可拖拽组合函数
 * @param id - 拖拽元素的唯一标识
 * @returns {object} 返回拖拽相关的状态和处理函数
 * @property {Ref<boolean>} isDragOver - 是否正在拖拽经过当前元素
 * @property {Ref<DropPosition>} dropPosition - 拖拽放置位置(-1: 放在目标之前, 0: 成为子节点, 1: 放在目标之后)
 * @property {Function} handleDragStart - 开始拖拽的处理函数
 * @property {Function} handleDragOver - 拖拽经过的处理函数
 * @property {Function} handleDragLeave - 拖拽离开的处理函数
 * @property {Function} handleDrop - 拖拽放置的处理函数
 */
export function useDraggable(id: number) {
  const isDragOver = ref(false)
  const dropPosition = ref<DropPosition>(0)

  const updateDropPosition = throttleByRaf((e: DragEvent, element: HTMLElement) => {
    const rect = element.getBoundingClientRect()
    const offsetY = window.pageYOffset + rect.top
    const { pageY } = e
    const gapHeight = rect.height / 4
    const diff = pageY - offsetY

    if (diff < gapHeight)
      dropPosition.value = -1
    else if (diff < rect.height - gapHeight)
      dropPosition.value = 0
    else
      dropPosition.value = 1
  })

  const handleDragStart = (e: DragEvent) => {
    if (e.dataTransfer) {
      e.dataTransfer.setData('text/plain', id.toString())
      e.dataTransfer.effectAllowed = 'move'
    }
  }

  const handleDragOver = (e: DragEvent) => {
    e.preventDefault()
    if (!isDragOver.value)
      isDragOver.value = true

    if (e.currentTarget instanceof HTMLElement)
      updateDropPosition(e, e.currentTarget)
  }

  const handleDragLeave = () => {
    isDragOver.value = false
    dropPosition.value = 0
    updateDropPosition.cancel()
  }

  const handleDrop = (e: DragEvent, emit: Function) => {
    e.preventDefault()
    const draggedId = Number(e.dataTransfer?.getData('text/plain'))
    emit('sort', draggedId, id, dropPosition.value)
    isDragOver.value = false
    dropPosition.value = 0
    updateDropPosition.cancel()
  }

  return {
    isDragOver,
    dropPosition,
    handleDragStart,
    handleDragOver,
    handleDragLeave,
    handleDrop,
  }
}

DraggableItem.vue

vue
<script setup lang="ts">
import { useDraggable } from '@/composables/useDraggable'

interface Props {
  id: number
  text: string
}

const props = defineProps<Props>()

const { isDragOver, dropPosition, handleDragStart, handleDragOver, handleDragLeave, handleDrop } = useDraggable(props.id)
</script>

<template>
  <li
    draggable="true"
    class="drag-item"
    :class="{
      'is-over': isDragOver,
      [`drop-${dropPosition}`]: isDragOver,
    }"
    @dragstart="handleDragStart"
    @dragover="handleDragOver"
    @dragleave="handleDragLeave"
    @drop="(e) => handleDrop(e, $emit)"
  >
    {{ text }}
  </li>
</template>

<style scoped>
.drag-item {
  padding: 10px 15px;
  background: #fff;
  border: 1px solid #ddd;
  margin: 5px 0;
  cursor: move;
  position: relative;
}

.is-over {
  background: #f5f5f5;
}

.drop--1::before {
  content: '';
  position: absolute;
  top: -3px;
  left: 0;
  right: 0;
  height: 2px;
  background: #2196f3;
}

.drop-1::after {
  content: '';
  position: absolute;
  bottom: -3px;
  left: 0;
  right: 0;
  height: 2px;
  background: #2196f3;
}

.drop-0 {
  border: 2px solid #2196f3;
}
</style>

Demo.vue

vue
<script setup lang="ts">
import { ref } from 'vue'
import DraggableItem from './DraggableItem.vue'

// 示例数据
const items = ref([
  { id: 1, text: '项目 1' },
  { id: 2, text: '项目 2' },
  { id: 3, text: '项目 3' },
  { id: 4, text: '项目 4' },
])

// 处理排序
function handleSort(draggedId: number, targetId: number, position: number) {
  const draggedIndex = items.value.findIndex(item => item.id === draggedId)
  const targetIndex = items.value.findIndex(item => item.id === targetId)
  const draggedItem = items.value[draggedIndex]

  // 从原位置删除
  items.value.splice(draggedIndex, 1)

  // 根据放置位置插入
  let newIndex = targetIndex
  if (position === 1)
    newIndex++
  items.value.splice(newIndex, 0, draggedItem)
}
</script>

<template>
  <div class="draggable-demo">
    <h2>拖拽排序示例</h2>
    <ul class="draggable-list">
      <DraggableItem
        v-for="item in items"
        :id="item.id"
        :key="item.id"
        :text="item.text"
        @sort="handleSort"
      />
    </ul>
  </div>
</template>

<style scoped>
.draggable-demo {
  padding: 20px;
}

.draggable-list {
  list-style: none;
  padding: 0;
  width: 300px;
}
</style>