import { ref, onUnmounted } from 'vue' export interface DraggableEvent extends MouseEvent { oldIndex: number newIndex: number item?: any } export interface DraggableOptions { group?: string animation?: number disabled?: boolean handle?: string ghostClass?: string dragClass?: string mode?: 'ghost' | 'line' dropIndicatorClass?: string items?: any[] onStart?: (event: MouseEvent) => void onEnd?: (event: DraggableEvent) => void } export interface DraggableElement extends HTMLElement { __drag_group?: string __drag_index?: number __drag_list?: DraggableElement[] __intended_index?: number __drag_data?: any } export function useDraggable(options: DraggableOptions = {}) { const isDragging = ref(false) const dragElement = ref(null) const ghostElement = ref(null) const dropIndicator = ref(null) const dropTarget = ref(null) // Only run in browser environment const isBrowser = typeof window !== 'undefined' // Move style injection into setup if (isBrowser) { const existingStyle = document.getElementById('draggable-style') if (!existingStyle) { const style = document.createElement('style') style.id = 'draggable-style' style.textContent = ` .draggable-ghost { opacity: 0.5; box-shadow: 0 2px 10px rgba(0,0,0,0.1); transition: transform 0.1s; } ` document.head.appendChild(style) } } const defaultOptions: DraggableOptions = { animation: 150, mode: 'line', ghostClass: 'draggable-ghost', dragClass: 'draggable-dragging', dropIndicatorClass: 'draggable-drop-indicator', ...options } let startX = 0 let startY = 0 let initialX = 0 let initialY = 0 let shiftAmount = 0 let cleanupFn: (() => void) | undefined let scrollRAF: number | null = null let lastDropIndex = -1 let lastUpdateTime = 0 const UPDATE_THRESHOLD = 150 // ms between updates const POSITION_CACHE = new Map() function createGhost(el: DraggableElement) { if (!isBrowser) { return null } const ghost = el.cloneNode(true) as HTMLElement const rect = el.getBoundingClientRect() ghost.style.position = 'fixed' ghost.style.margin = '0' ghost.style.top = `${rect.top}px` ghost.style.left = `${rect.left}px` ghost.style.width = `${rect.width}px` ghost.style.height = `${rect.height}px` ghost.style.transform = 'translate3d(0, 0, 0)' ghost.style.pointerEvents = 'none' ghost.style.zIndex = '9999' ghost.style.cursor = 'grabbing' ghost.style.willChange = 'transform' ghost.classList.add(defaultOptions.ghostClass!) document.body.appendChild(ghost) return ghost } function createDropIndicator(el: DraggableElement) { if (!isBrowser) { return null } const line = document.createElement('div') line.classList.add(defaultOptions.dropIndicatorClass!) line.style.position = 'fixed' line.style.pointerEvents = 'none' line.style.zIndex = '9999' line.style.height = '2px' line.style.background = 'var(--primary-color, #0066ff)' line.style.boxShadow = '0 0 4px rgba(0, 102, 255, 0.5)' line.style.transition = 'all 0.15s ease' line.style.display = 'none' // Start hidden // Add the dot indicator const dot = document.createElement('div') dot.style.position = 'absolute' dot.style.left = '-4px' dot.style.top = '-3px' dot.style.width = '8px' dot.style.height = '8px' dot.style.borderRadius = '50%' dot.style.background = 'var(--primary-color, #0066ff)' line.appendChild(dot) document.body.appendChild(line) return line } function updateDropIndicator(target: DraggableElement, isAfter: boolean) { if (!dropIndicator.value) { return } const rect = target.getBoundingClientRect() dropIndicator.value.style.display = 'block' dropIndicator.value.style.width = `${rect.width}px` dropIndicator.value.style.left = `${rect.left}px` // Move indicator to match the actual drop position if (isAfter) { dropIndicator.value.style.top = `${rect.bottom}px` } else { dropIndicator.value.style.top = `${rect.top}px` } } function findDraggableParent(element: Element | null): DraggableElement | null { let current = element while (current && (!('__drag_group' in current) || (current as any).dataset.draggable === 'false')) { current = current.parentElement } return current as DraggableElement | null } function handleGhostMode(dragEl: DraggableElement) { dragEl.style.opacity = '0.4' dragEl.style.transform = 'scale(0.95)' return dragEl.offsetHeight } function handleLineMode(dragEl: DraggableElement) { dragEl.style.opacity = '0.5' return 2 } // function resetDragStyles(dragEl: DraggableElement) { // if (defaultOptions.mode === 'ghost') { // dragEl.style.opacity = '' // dragEl.style.transform = '' // } else { // dragEl.style.opacity = '' // } // } function cacheElementPositions(draggedList: DraggableElement[]) { POSITION_CACHE.clear() draggedList.forEach((el, index) => { const rect = el.getBoundingClientRect() POSITION_CACHE.set(index, { top: rect.top, bottom: rect.bottom }) }) } function calculateDropIndex(e: MouseEvent, draggedList: DraggableElement[], dragIndex: number, newDropTarget: DraggableElement) { const now = Date.now() if (now - lastUpdateTime < UPDATE_THRESHOLD) { // Return last calculated index if we're within threshold return lastDropIndex !== -1 ? lastDropIndex : dragIndex } // Use cached positions if available, otherwise calculate new ones if (POSITION_CACHE.size === 0) { cacheElementPositions(draggedList) } const mouseY = e.clientY const targetIndex = draggedList.indexOf(newDropTarget) const targetPos = POSITION_CACHE.get(targetIndex) if (!targetPos) { return dragIndex } const elementHeight = targetPos.bottom - targetPos.top const relativeY = mouseY - targetPos.top // Calculate sticky zones const upperSticky = elementHeight * 0.3 const lowerSticky = elementHeight * 0.7 let dropIndex = targetIndex // Different behavior based on context if (targetIndex === dragIndex) { // On original element - require significant movement if (relativeY > lowerSticky) { dropIndex += 1 } } else if (targetIndex === lastDropIndex) { // On previously targeted element - use sticky zones if (targetIndex > dragIndex) { if (relativeY < upperSticky) { dropIndex = targetIndex } else { dropIndex = targetIndex + 1 } } else { if (relativeY > lowerSticky) { dropIndex = targetIndex + 1 } else { dropIndex = targetIndex } } } else { // On new element - use 60/40 split if (relativeY > elementHeight * 0.6) { dropIndex += 1 } } // Update state lastDropIndex = dropIndex lastUpdateTime = now return dropIndex } function cleanup() { if (!isBrowser) { return } document.removeEventListener('mousemove', onDragMove) document.removeEventListener('mouseup', onDragEnd) document.removeEventListener('keydown', onKeyDown) document.removeEventListener('touchmove', onTouchMove) document.removeEventListener('touchend', onTouchEnd) document.removeEventListener('touchcancel', onTouchEnd) window.removeEventListener('blur', cleanup) // Remove elements with animation if (ghostElement.value) { ghostElement.value.style.opacity = '0' ghostElement.value.style.transform += ' scale(0.8)' setTimeout(() => { ghostElement.value?.parentNode?.removeChild(ghostElement.value) ghostElement.value = null }, 150) } if (dropIndicator.value) { dropIndicator.value.style.opacity = '0' setTimeout(() => { dropIndicator.value?.parentNode?.removeChild(dropIndicator.value) dropIndicator.value = null }, 150) } // Reset all elements if (dragElement.value) { const list = dragElement.value.__drag_list list?.forEach((el) => { el.style.transition = '' el.style.transform = '' el.style.opacity = '' }) dragElement.value.classList.remove(defaultOptions.dragClass!) dragElement.value = null } isDragging.value = false dropTarget.value = null if (isBrowser) { document.body.style.userSelect = '' document.body.style.webkitUserSelect = '' document.body.style.cursor = '' } if (scrollRAF) { cancelAnimationFrame(scrollRAF) scrollRAF = null } lastDropIndex = -1 lastUpdateTime = 0 POSITION_CACHE.clear() } function onKeyDown(e: KeyboardEvent) { if (e.key === 'Escape' && isDragging.value) { cleanup() } } function onDragStart(e: MouseEvent) { if (!isBrowser || defaultOptions.disabled) { return } if (e.button !== 0) { return } // Only left mouse button if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) { return } const dragEl = findDraggableParent(e.target as Element) if (!dragEl || dragEl.dataset.draggable === 'false') { return } // If handle is specified, check if clicked element matches if (defaultOptions.handle) { const handleEl = (e.target as Element).closest(defaultOptions.handle) if (!handleEl || !dragEl.contains(handleEl)) { return } } e.preventDefault() isDragging.value = true dragElement.value = dragEl // Store initial positions startX = e.clientX startY = e.clientY const rect = dragEl.getBoundingClientRect() initialX = rect.left initialY = rect.top // Create ghost element that follows mouse ghostElement.value = createGhost(dragEl) if (ghostElement.value) { ghostElement.value.style.left = `${initialX}px` ghostElement.value.style.top = `${initialY}px` } // Only create drop indicator in line mode if (defaultOptions.mode === 'line') { dropIndicator.value = createDropIndicator(dragEl) } // Apply mode-specific styles and get shift amount dragEl.classList.add(defaultOptions.dragClass!) shiftAmount = defaultOptions.mode === 'ghost' ? handleGhostMode(dragEl) : handleLineMode(dragEl) defaultOptions.onStart?.(e) // Prevent text selection during drag if (isBrowser) { document.body.style.userSelect = 'none' document.body.style.webkitUserSelect = 'none' document.body.style.cursor = 'grabbing' window.addEventListener('blur', cleanup) document.addEventListener('mousemove', onDragMove) document.addEventListener('mouseup', onDragEnd) document.addEventListener('keydown', onKeyDown) } } function findScrollParent(element: Element): Element { let parent = element.parentElement while (parent) { const { overflow, overflowY } = window.getComputedStyle(parent) if (/auto|scroll/.test(overflow + overflowY)) { return parent } parent = parent.parentElement } return document.scrollingElement || document.documentElement || document.body } function autoScroll(e: MouseEvent) { if (!dragElement.value) { return } if (scrollRAF) { cancelAnimationFrame(scrollRAF) } scrollRAF = requestAnimationFrame(() => { const scrollParent = findScrollParent(dragElement.value!) as HTMLElement const rect = scrollParent.getBoundingClientRect() const margin = 50 const speed = Math.min(20, rect.height * 0.1) // Adjust speed based on container height const { scrollTop } = scrollParent const maxScroll = scrollParent.scrollHeight - scrollParent.clientHeight // Use requestAnimationFrame for smoother scrolling if (e.clientY - rect.top < margin) { const newScroll = Math.max(0, scrollTop - speed) scrollParent.scrollTop = newScroll } else if (rect.bottom - e.clientY < margin) { const newScroll = Math.min(maxScroll, scrollTop + speed) scrollParent.scrollTop = newScroll } }) } function onDragMove(e: MouseEvent) { if (!isDragging.value || !dragElement.value) { return } // Move ghost element with cursor if (ghostElement.value) { const dx = e.clientX - startX const dy = e.clientY - startY ghostElement.value.style.transform = `translate3d(${dx}px, ${dy}px, 0)` } // Get the draggable list and current positions const list = dragElement.value.__drag_list! const draggedList = list.filter(el => el.dataset.draggable !== 'false') const dragIndex = draggedList.indexOf(dragElement.value) // Find the element at cursor position const elementAtPoint = document.elementFromPoint(e.clientX, e.clientY) const newDropTarget = findDraggableParent(elementAtPoint) // Validate drop target if (!newDropTarget || newDropTarget.__drag_group !== dragElement.value.__drag_group) { if (dropIndicator.value) { dropIndicator.value.style.display = 'none' } return } const dropIndex = calculateDropIndex(e, draggedList, dragIndex, newDropTarget) // Only update if position actually changed if (dropTarget.value !== newDropTarget || dropTarget.value.__intended_index !== dropIndex) { dropTarget.value = newDropTarget dropTarget.value.__intended_index = dropIndex // Update line indicator if (dropIndicator.value && defaultOptions.mode === 'line') { const targetIndex = draggedList.indexOf(newDropTarget) const isAfter = dropIndex > targetIndex updateDropIndicator(newDropTarget, isAfter) } // Update element positions const shift = shiftAmount draggedList.forEach((el) => { // Use transform3d for better performance el.style.transition = `transform ${defaultOptions.animation}ms cubic-bezier(0.2, 0, 0, 1)` el.style.transform = 'translate3d(0, 0, 0)' if (el === dragElement.value) { if (defaultOptions.mode === 'ghost') { const offset = (dropIndex - dragIndex) * shift el.style.transform = `translate3d(0, ${offset}px, 0)` } } else { const currentIndex = draggedList.indexOf(el) if (dragIndex < dropIndex) { if (currentIndex > dragIndex && currentIndex <= dropIndex) { el.style.transform = `translate3d(0, -${shift}px, 0)` } } else if (dragIndex > dropIndex) { if (currentIndex >= dropIndex && currentIndex < dragIndex) { el.style.transform = `translate3d(0, ${shift}px, 0)` } } } }) } autoScroll(e) } function onDragEnd(e: MouseEvent) { if (!isDragging.value || !dragElement.value || !dropTarget.value) { cleanup() return } const list = dragElement.value.__drag_list if (!list || !list.length) { cleanup() return } const draggedList = list.filter(el => el.dataset.draggable !== 'false') const actualDragIndex = draggedList.indexOf(dragElement.value) const actualDropIndex = dropTarget.value.__intended_index! if (actualDragIndex !== -1 && actualDropIndex !== -1) { list.forEach((el) => { el.style.transition = 'none' el.style.transform = '' }) void document.body.offsetHeight const dragEvent = new MouseEvent('mouseup', e) as DraggableEvent dragEvent.oldIndex = actualDragIndex dragEvent.newIndex = actualDropIndex dragEvent.item = dragElement.value.__drag_data defaultOptions.onEnd?.(dragEvent) } cleanup() } function onTouchStart(e: TouchEvent) { if (!isBrowser || defaultOptions.disabled || !e.target) { return } const touch = e.touches[0] if (!touch) { return } const target = e.target as Element const draggableParent = findDraggableParent(target) if (!draggableParent) { return } const { handle } = defaultOptions if (handle && !target.closest(handle)) { return } e.preventDefault() // Prevent scrolling isDragging.value = true dragElement.value = draggableParent startX = touch.clientX startY = touch.clientY const rect = draggableParent.getBoundingClientRect() initialX = rect.left initialY = rect.top // Rest of the setup same as onDragStart... ghostElement.value = createGhost(draggableParent) if (ghostElement.value) { ghostElement.value.style.left = `${initialX}px` ghostElement.value.style.top = `${initialY}px` } // Only create drop indicator in line mode if (defaultOptions.mode === 'line') { dropIndicator.value = createDropIndicator(draggableParent) } // Apply mode-specific styles and get shift amount dragElement.value.classList.add(defaultOptions.dragClass!) shiftAmount = defaultOptions.mode === 'ghost' ? handleGhostMode(dragElement.value) : handleLineMode(dragElement.value) defaultOptions.onStart?.(e as unknown as MouseEvent) if (isBrowser) { // Prevent text selection during drag document.body.style.userSelect = 'none' document.body.style.webkitUserSelect = 'none' document.body.style.cursor = 'grabbing' window.addEventListener('blur', cleanup) document.addEventListener('touchmove', onTouchMove, { passive: false }) document.addEventListener('touchend', onTouchEnd) document.addEventListener('touchcancel', onTouchEnd) } } function onTouchMove(e: TouchEvent) { e.preventDefault() // Prevent scrolling const touch = e.touches[0] if (!touch) { return } const mouseEvent = { clientX: touch.clientX, clientY: touch.clientY, target: document.elementFromPoint(touch.clientX, touch.clientY) } as MouseEvent onDragMove(mouseEvent) autoScroll(mouseEvent) } function onTouchEnd(e: TouchEvent) { const touch = e.changedTouches[0] if (!touch) { return } const mouseEvent = { clientX: touch.clientX, clientY: touch.clientY, target: document.elementFromPoint(touch.clientX, touch.clientY) } as MouseEvent onDragEnd(mouseEvent) document.removeEventListener('touchmove', onTouchMove) document.removeEventListener('touchend', onTouchEnd) document.removeEventListener('touchcancel', onTouchEnd) } function initDraggableContainer(container: HTMLElement) { if (!isBrowser) { return } cleanupFn?.() // Reset state isDragging.value = false dragElement.value = null ghostElement.value = null dropIndicator.value = null dropTarget.value = null const elements = Array.from(container.children) as DraggableElement[] elements.forEach((el) => { delete el.__drag_group delete el.__drag_index delete el.__drag_list }) let currentIndex = 0 elements.forEach((el, index) => { if (el.dataset.draggable !== 'false') { el.style.userSelect = 'none' el.style.webkitUserSelect = 'none' el.__drag_group = options.group el.__drag_index = currentIndex++ el.__drag_list = elements.filter(e => e.dataset.draggable !== 'false') if (options.items) { el.__drag_data = options.items[index] } el.addEventListener('mousedown', onDragStart) el.addEventListener('touchstart', onTouchStart) } }) cleanupFn = () => { elements.forEach((el) => { el.removeEventListener('mousedown', onDragStart) el.removeEventListener('touchstart', onTouchStart) delete el.__drag_group delete el.__drag_index delete el.__drag_list }) cleanup() } } // Automatically clean up when component is unmounted onUnmounted(() => { cleanupFn?.() }) return { isDragging, dragElement, initDraggableContainer } }