/**
|
* 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;
|