Skip to content

Drag Image

Adds a custom drag image that follows the mouse cursor while dragging.

Demo

vue
<script lang="ts" setup>
import { createDragDrop } from 'dnd-ts'
import { fromHTML, reorderItems } from 'dnd-ts/utils'
import { onMounted, onUnmounted, ref } from 'vue'
import data from './data/mock-data-persons.json'

const items = ref<{ name: string, avatar: string, id: string }[]>(data)
const container = ref<HTMLElement | null>(null)
const checked = ref<Record<string, boolean>>({})

onMounted(() => {
  const instance = createDragDrop(container.value!, {
    getSelectedElements: () =>
      Array.from(container.value!.querySelectorAll(`[data-selected="true"]`)),
    addClasses: true,
    indicator: { offset: 2 },
    autoScroll: true,
    dragImage: {
      minElements: 0,
      createElement: (selectedElements: HTMLElement[]) => {
        const item = items.value[Number.parseInt(selectedElements[0].getAttribute('data-index')!)]
        return fromHTML(
          `<div class='custom-drag-image' data-num='${selectedElements.length}'>
            <img class="avatar" src="${item.avatar}" />
            <span>${item.name}</span>
          </div>`,
        )
      },
    },
    onDragEnd({ dragElements, dropElement, position }) {
      if (!dropElement)
        return
      const index = Number.parseInt(dropElement.getAttribute('data-index')!)
      const selectedItems = dragElements.map(
        e => items.value.find(item => item.id === e.getAttribute('data-id'))!,
      )
      if (position === 'after') {
        items.value = reorderItems(items.value, selectedItems, index + 1)
      }
      else if (position === 'before') {
        items.value = reorderItems(items.value, selectedItems, index)
      }
    },
  })
  onUnmounted(() => instance.destroy())
})
</script>

<template>
  <div
    ref="container"
    class="demo"
    style="overflow: auto; max-height: 400px; padding: 10px"
  >
    <ul class="list">
      <li
        v-for="(item, index) in items"
        :key="item.id"
        :data-id="item.id"
        :data-index="index"
        class="list-item"
        style="margin-bottom: 4px"
        :data-selected="checked[item.id]"
        @click="checked[item.id] = !checked[item.id]"
      >
        <img src="/handle.svg">
        <input v-model="checked[item.id]" type="checkbox">
        <img class="avatar" :src="item.avatar">
        <span>{{ item.name }}</span>
      </li>
    </ul>
  </div>
</template>

<style type="text/css">
[data-selected="true"] {
  background: #eee !important;
}
.avatar {
  border-radius: 30px;
  overflow: hidden;
  width: 20px;
}

.custom-drag-image:not([data-num="1"]):after {
  content: attr(data-num);
  position: absolute;
  display: block;
  background: black;
  border-radius: 30px;
  color: #fff;
  min-width: 20px;
  line-height: 1;
  padding: 4px 3px;
  text-align: center;
  top: -10px;
  font-size: 12px;
  right: 10px;
}

.custom-drag-image {
  position: relative;
  background: #fff;
  padding: 6px 10px;
  margin: 10px;
  display: flex;
  box-shadow: 3px 3px 38px -15px rgba(0, 0, 0, 0.75);
  gap: 6px;
  align-items: center;
  justify-content: center;
  font-size: 13px;
  font-weight: bold;
  border-radius: 6px;
}
.custom-drag-image span {
  text-overflow: ellipsis;
  white-space: nowrap;
  overflow: hidden;
  max-width: 120px;
}
</style>

Types

ts
export interface DragImagePluginOptions {
  /**
   * Method that creates the drag image element.
   *
   * Defaults to: defaultCreateElementFn
   */
  createElement: (selectedElements: HTMLElement[]) => HTMLElement
  /**
   * Minium amount of elements required to use custom drag image instead of
   * browser default.
   *
   * Defaults to: 0
   */
  minElements: number
}
ts
import type { DragDropPlugin } from '../types'
import type { DragImagePluginOptions } from './types'
import { fromHTML } from '../utils'

function isTouchEvent(e: Event): e is TouchEvent {
  return typeof TouchEvent !== 'undefined' && e instanceof TouchEvent
}

export function defaultCreateElementFn(selectedElements: HTMLElement[]) {
  return fromHTML(
    `<div class='drag-image'>${selectedElements.length} Element(s)</div>`,
  )
}

export function updateContainerStyle(element: HTMLElement, top: number, left: number) {
  element.setAttribute(
    'style',
    `position: fixed; z-index: 9999; top: ${top}px; left:${left}px; pointer-events: none;`,
  )
}

export const DEFAULTS: DragImagePluginOptions = {
  createElement: defaultCreateElementFn,
  minElements: 0,
}

function dragImage(options?: Partial<DragImagePluginOptions>): DragDropPlugin {
  const { createElement, minElements } = options
    ? { ...DEFAULTS, ...options }
    : DEFAULTS

  let removeListeners: (() => void) | null = null
  const customImageContainer = document.createElement('div')

  const blankDragImage = document.createElement('canvas')
  blankDragImage.width = blankDragImage.height = 1

  const stop = () => {
    removeListeners?.()
    removeListeners = null
    customImageContainer.remove()
    blankDragImage.remove()
  }

  const startDesktop = (originalEvent: DragEvent) => {
    document.body.appendChild(blankDragImage)
    originalEvent.dataTransfer?.setDragImage(blankDragImage, 0, 0)
    const onMove = (e: DragEvent) =>
      updateContainerStyle(customImageContainer, e.clientY, e.clientX)
    document.addEventListener('dragover', onMove)
    removeListeners = () => document.removeEventListener('dragover', onMove)
    updateContainerStyle(customImageContainer, originalEvent.clientY, originalEvent.clientX)
  }

  const startTouch = (originalEvent: TouchEvent) => {
    const t = originalEvent.touches[0]
    if (t)
      updateContainerStyle(customImageContainer, t.clientY, t.clientX)
    const onMove = (e: TouchEvent) => {
      const touch = e.touches[0]
      if (touch)
        updateContainerStyle(customImageContainer, touch.clientY, touch.clientX)
    }
    document.addEventListener('touchmove', onMove)
    removeListeners = () => document.removeEventListener('touchmove', onMove)
  }

  return {
    DragStart({ originalEvent, dragElements }) {
      if (dragElements.length <= minElements)
        return
      customImageContainer.innerHTML = ''
      customImageContainer.appendChild(createElement(dragElements))
      document.body.appendChild(customImageContainer)
      if (isTouchEvent(originalEvent))
        startTouch(originalEvent)
      else startDesktop(originalEvent as DragEvent)
    },
    DragEnd: stop,
  }
}

export { dragImage }
export default dragImage