/** * DragHandler.js * Generic drag and drop controller for mouse and touch via Pointer Events API */ class DragHandler { constructor(config) { this.draggableSelector = config.draggableSelector; this.dropTargetSelector = config.dropTargetSelector; this.getItemId = config.getItemId; this.getSelectedItems = config.getSelectedItems; this.validateDrop = config.validateDrop; this.onDrop = config.onDrop; this.handleSelector = config.handleSelector || null; this.ignoreSelector = config.ignoreSelector || 'input, button, label, select, textarea, a'; this.onDragStart = config.onDragStart || null; this.onDragEnd = config.onDragEnd || null; this.previewElement = config.previewElement || 'img, video, .icon'; this.previewOptions = { offset: { x: -30, y: -40 }, showCount: true, ...config.previewOptions }; this.dragThreshold = { time: 90, // ms to hold before drag starts distance: 5 // px of movement allowed during hold }; this.state = this.getInitialState(); this.onPointerDown = this.handlePointerDown.bind(this); this.onPointerMove = this.handlePointerMove.bind(this); this.onPointerUp = this.handlePointerUp.bind(this); this.onPointerCancel = this.handlePointerCancel.bind(this); this.init(); } getInitialState() { return { active: false, itemIds: [], startPos: null, currentPos: null, targetElement: null, previewElement: null, startTime: null, holdTimer: null, pointerId: null, pointerTarget: null }; } init() { document.addEventListener('pointerdown', this.onPointerDown); } handlePointerDown(e) { if (this.shouldIgnoreElement(e.target)) return; const draggableElement = e.target.closest(this.draggableSelector); if (!draggableElement) return; if (this.handleSelector) { const handle = e.target.closest(this.handleSelector); if (!handle || !draggableElement.contains(handle)) return; } const itemId = this.getItemId(draggableElement); if (!itemId) return; const selectedItems = this.getSelectedItems(draggableElement); const itemIds = selectedItems.includes(itemId) ? selectedItems : [itemId]; e.preventDefault(); this.state = { active: false, itemIds: itemIds, startPos: { x: e.clientX, y: e.clientY }, currentPos: { x: e.clientX, y: e.clientY }, targetElement: null, previewElement: null, startTime: Date.now(), holdTimer: null, draggableElement: draggableElement, pointerId: e.pointerId, pointerTarget: draggableElement }; document.addEventListener('pointermove', this.onPointerMove); document.addEventListener('pointerup', this.onPointerUp); document.addEventListener('pointercancel', this.onPointerCancel); this.state.holdTimer = setTimeout(() => { if (this.state && !this.state.active) { this.startDrag(); } }, this.dragThreshold.time); } startDrag() { // Don't start if already cancelled if (!this.state || this.state.active) return; this.state.holdTimer = null; this.state.active = true; if (this.state.pointerTarget && this.state.pointerId !== null) { try { this.state.pointerTarget.setPointerCapture(this.state.pointerId); } catch (e) { console.warn('Could not capture pointer:', e); } } this.state.previewElement = this.createPreview( this.state.draggableElement, this.state.itemIds ); this.applyDraggingState(true); this.updatePreview(); if (this.onDragStart) { this.onDragStart(this.state.itemIds, this.state.draggableElement); } if (window.jvbA11y) { const message = this.state.itemIds.length > 1 ? `Started dragging ${this.state.itemIds.length} items` : 'Started dragging item'; window.jvbA11y.announce(message); } } handlePointerMove(e) { if (!this.state) return; this.state.currentPos = { x: e.clientX, y: e.clientY }; // Check if we've moved beyond threshold before timer expires if (!this.state.active && this.state.holdTimer) { const dx = e.clientX - this.state.startPos.x; const dy = e.clientY - this.state.startPos.y; const distance = Math.sqrt(dx * dx + dy * dy); // If moved significantly while waiting, start drag immediately if (distance > this.dragThreshold.distance * 2) { clearTimeout(this.state.holdTimer); this.startDrag(); } } if (!this.state.active) return; e.preventDefault(); this.updatePreview(); const elementUnderPointer = document.elementFromPoint(e.clientX, e.clientY); const dropTarget = this.findDropTarget(elementUnderPointer); if (dropTarget !== this.state.targetElement) { this.clearTargetHighlight(); this.state.targetElement = dropTarget; if (dropTarget && this.validateDrop(this.state.itemIds, dropTarget)) { this.highlightTarget(dropTarget); } } } handlePointerUp(e) { if (!this.state) return; // If timer still running, this was a quick click - cancel drag and allow click if (this.state.holdTimer && !this.state.active) { this.cancelDragStart(); return; } // Only process drop if drag was actually active if (!this.state.active) { this.cleanup(); return; } document.removeEventListener('pointermove', this.onPointerMove); document.removeEventListener('pointerup', this.onPointerUp); document.removeEventListener('pointercancel', this.onPointerCancel); const { itemIds, targetElement } = this.state; let success = false; if (targetElement && this.validateDrop(itemIds, targetElement)) { this.onDrop(itemIds, targetElement); success = true; } this.cleanup(); if (this.onDragEnd) { this.onDragEnd(itemIds, success); } if (window.jvbA11y) { const message = success ? (itemIds.length > 1 ? `Moved ${itemIds.length} items` : 'Item moved') : 'Drag cancelled'; window.jvbA11y.announce(message); } } handlePointerCancel(e) { if (!this.state) return; document.removeEventListener('pointermove', this.onPointerMove); document.removeEventListener('pointerup', this.onPointerUp); document.removeEventListener('pointercancel', this.onPointerCancel); // Clear timer if still running if (this.state.holdTimer) { clearTimeout(this.state.holdTimer); this.state.holdTimer = null; } this.cleanup(); if (window.jvbA11y) { window.jvbA11y.announce('Drag cancelled'); } } cancelDragStart() { if (this.state && this.state.holdTimer) { clearTimeout(this.state.holdTimer); this.state.holdTimer = null; } document.removeEventListener('pointermove', this.onPointerMove); document.removeEventListener('pointerup', this.onPointerUp); document.removeEventListener('pointercancel', this.onPointerCancel); this.cleanup(); } createPreview(draggableElement, itemIds) { let preview = window.getTemplate('dragPreview'); if (!preview) { preview = this.createPreviewElement(); } let itemsContainer = preview.querySelector('.drag-items'); let wrapperTemplate = itemsContainer.querySelector('.drag-item'); itemIds.forEach((id, index) => { let itemElement = this.findElementByItemId(id); if (itemElement) { let previewPart = itemElement.querySelector(this.previewElement)?.cloneNode(true); if (previewPart) { let wrapper = wrapperTemplate.cloneNode(true); wrapper.appendChild(previewPart); itemsContainer.append(wrapper); } } }); wrapperTemplate.remove(); let countBadge = preview.querySelector('.drag-count'); if (countBadge) { countBadge.textContent = '{' + itemIds.length + '}'; countBadge.hidden = itemIds.length <= 1; } document.body.appendChild(preview); return preview; } createPreviewElement() { const preview = document.createElement('div'); preview.className = 'drag-preview'; let items = preview.cloneNode(true); let count = preview.cloneNode(true); let item = preview.cloneNode(true); item.className = 'drag-item'; items.className = 'drag-items'; items.append(item); count.className = 'drag-count'; preview.append(items); preview.append(count); return preview; } updatePreview() { if (!this.state.previewElement || !this.state.currentPos) return; this.state.previewElement.style.left = `${this.state.currentPos.x + this.previewOptions.offset.x}px`; this.state.previewElement.style.top = `${this.state.currentPos.y + this.previewOptions.offset.y}px`; } findDropTarget(element) { if (!element) return null; return element.closest(this.dropTargetSelector); } highlightTarget(target) { if (!target) return; target.classList.add('dragover'); if (this.state.isMultiDrag) { target.classList.add('multi-drop'); target.setAttribute('data-item-count', this.state.itemIds.length); } if (navigator.vibrate) { const pattern = this.state.isMultiDrag ? [25, 10, 25] : [25]; navigator.vibrate(pattern); } } clearTargetHighlight() { document.querySelectorAll('.dragover').forEach(el => { el.classList.remove('dragover', 'multi-drop'); el.removeAttribute('data-item-count'); }); } applyDraggingState(isDragging) { this.state.itemIds.forEach(id => { const element = this.findElementByItemId(id); if (element) { element.classList.toggle('dragging', isDragging); } }); } findElementByItemId(itemId) { // Try the actual selector pattern first const elements = document.querySelectorAll(this.draggableSelector); for (const el of elements) { if (this.getItemId(el) === itemId) { return el; } } return null; } shouldIgnoreElement(element) { return (element.matches(this.ignoreSelector)); } cleanup() { if (this.state && this.state.holdTimer) { clearTimeout(this.state.holdTimer); } if (this.state && this.state.pointerTarget && this.state.pointerId !== null) { try { this.state.pointerTarget.releasePointerCapture(this.state.pointerId); } catch (e) { // Pointer may have been auto-released } } this.clearTargetHighlight(); this.applyDraggingState(false); if (this.state && this.state.previewElement) { this.state.previewElement.remove(); } this.state = this.getInitialState(); } destroy() { this.cleanup(); document.removeEventListener('pointerdown', this.onPointerDown); document.removeEventListener('pointermove', this.onPointerMove); document.removeEventListener('pointerup', this.onPointerUp); document.removeEventListener('pointercancel', this.onPointerCancel); } } window.jvbDragHandler = DragHandler;