mirror of
https://github.com/morris/vanilla-todo.git
synced 2025-08-19 12:21:19 +02:00
380 lines
8.4 KiB
JavaScript
380 lines
8.4 KiB
JavaScript
/**
|
|
* @param {HTMLElement} el
|
|
* @param {{
|
|
* dropSelector: string;
|
|
* dragThreshold?: number;
|
|
* dropRange?: number;
|
|
* scrollThreshold?: number;
|
|
* scrollSpeed?: number;
|
|
* }} options
|
|
*/
|
|
export function AppDraggable(el, options) {
|
|
const dragThreshold = options.dragThreshold ?? 5;
|
|
const dropRange = options.dropRange ?? 50;
|
|
const dropRangeSquared = dropRange * dropRange;
|
|
const scrollThreshold = options.scrollThreshold ?? 12;
|
|
const scrollSpeed = options.scrollSpeed ?? 7;
|
|
|
|
let originX, originY;
|
|
let clientX, clientY;
|
|
let startTime;
|
|
let dragging = false;
|
|
let data;
|
|
let image;
|
|
let imageSource;
|
|
let imageX, imageY;
|
|
let currentTarget;
|
|
|
|
el.addEventListener('touchstart', start, { passive: true });
|
|
el.addEventListener('mousedown', start, { passive: true });
|
|
|
|
// Prevent click while dragging
|
|
el.addEventListener(
|
|
'click',
|
|
(e) => {
|
|
if (dragging) {
|
|
e.preventDefault();
|
|
e.stopImmediatePropagation();
|
|
}
|
|
},
|
|
true,
|
|
);
|
|
|
|
function start(e) {
|
|
if (el.classList.contains('_nodrag')) return;
|
|
if (e.type === 'mousedown' && e.button !== 0) return;
|
|
if (e.touches && e.touches.length > 1) return;
|
|
|
|
const p = getPositionHost(e);
|
|
clientX = originX = p.clientX ?? p.pageX;
|
|
clientY = originY = p.clientY ?? p.pageY;
|
|
startTime = Date.now();
|
|
|
|
startListening();
|
|
}
|
|
|
|
function move(e) {
|
|
const p = getPositionHost(e);
|
|
clientX = p.clientX ?? p.pageX;
|
|
clientY = p.clientY ?? p.pageY;
|
|
|
|
if (dragging) {
|
|
dispatchDrag();
|
|
dispatchTarget();
|
|
return;
|
|
}
|
|
|
|
const deltaX = clientX - originX;
|
|
const deltaY = clientY - originY;
|
|
|
|
if (Math.abs(deltaX) < dragThreshold && Math.abs(deltaY) < dragThreshold) {
|
|
return;
|
|
}
|
|
|
|
// Prevent unintentional dragging on touch devices
|
|
if (e.touches && Date.now() - startTime < 50) {
|
|
stopListening();
|
|
return;
|
|
}
|
|
|
|
dragging = true;
|
|
data = {};
|
|
|
|
dispatchStart();
|
|
dispatchDrag();
|
|
dispatchTarget();
|
|
dispatchOverContinuously();
|
|
autoScroll();
|
|
}
|
|
|
|
function end() {
|
|
stopListening();
|
|
|
|
requestAnimationFrame(() => {
|
|
if (dragging) {
|
|
dispatchTarget();
|
|
dispatchEnd();
|
|
|
|
dragging = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
function startListening() {
|
|
el.addEventListener('touchmove', move, { passive: true });
|
|
el.addEventListener('touchend', end, { passive: true });
|
|
window.addEventListener('mousemove', move, { passive: true });
|
|
window.addEventListener('mouseup', end, { passive: true });
|
|
}
|
|
|
|
function stopListening() {
|
|
el.removeEventListener('touchmove', move);
|
|
el.removeEventListener('touchend', end);
|
|
window.removeEventListener('mousemove', move);
|
|
window.removeEventListener('mouseup', end);
|
|
}
|
|
|
|
//
|
|
|
|
function dispatchStart() {
|
|
setImage(el);
|
|
|
|
el.dispatchEvent(
|
|
new CustomEvent('draggableStart', {
|
|
detail: buildDetail(),
|
|
bubbles: true,
|
|
}),
|
|
);
|
|
}
|
|
|
|
function dispatchDrag() {
|
|
image.dispatchEvent(
|
|
new CustomEvent('draggableDrag', {
|
|
detail: buildDetail(),
|
|
bubbles: true,
|
|
}),
|
|
);
|
|
}
|
|
|
|
function dispatchTarget() {
|
|
const nextTarget = getTarget();
|
|
|
|
if (nextTarget === currentTarget) return;
|
|
|
|
if (currentTarget) {
|
|
currentTarget.addEventListener('draggableLeave', removeDropClassOnce);
|
|
currentTarget.dispatchEvent(
|
|
new CustomEvent('draggableLeave', {
|
|
detail: buildDetail(),
|
|
bubbles: true,
|
|
}),
|
|
);
|
|
}
|
|
|
|
if (nextTarget) {
|
|
nextTarget.addEventListener('draggableEnter', addDropClassOnce);
|
|
nextTarget.dispatchEvent(
|
|
new CustomEvent('draggableEnter', {
|
|
detail: buildDetail(),
|
|
bubbles: true,
|
|
}),
|
|
);
|
|
}
|
|
|
|
currentTarget = nextTarget;
|
|
}
|
|
|
|
function dispatchOverContinuously() {
|
|
if (!dragging) return;
|
|
|
|
if (currentTarget) {
|
|
currentTarget.dispatchEvent(
|
|
new CustomEvent('draggableOver', {
|
|
detail: buildDetail(),
|
|
bubbles: true,
|
|
}),
|
|
);
|
|
}
|
|
|
|
setTimeout(dispatchOverContinuously, 50);
|
|
}
|
|
|
|
function dispatchEnd() {
|
|
if (currentTarget) {
|
|
currentTarget.addEventListener('draggableDrop', cleanUpOnce);
|
|
currentTarget.dispatchEvent(
|
|
new CustomEvent('draggableDrop', {
|
|
detail: buildDetail(),
|
|
bubbles: true,
|
|
}),
|
|
);
|
|
} else {
|
|
image.dispatchEvent(
|
|
new CustomEvent('draggableCancel', {
|
|
detail: buildDetail(),
|
|
bubbles: true,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
function autoScroll() {
|
|
if (!dragging) return;
|
|
|
|
let x = 0;
|
|
let y = 0;
|
|
|
|
if (clientX < scrollThreshold) {
|
|
if (window.scrollX > 0) {
|
|
x = -1;
|
|
}
|
|
} else if (clientX > window.innerWidth - scrollThreshold) {
|
|
x = 1;
|
|
}
|
|
|
|
if (clientY < scrollThreshold) {
|
|
if (window.scrollY > 0) {
|
|
y = -1;
|
|
}
|
|
} else if (clientY > window.innerHeight - scrollThreshold) {
|
|
y = 1;
|
|
}
|
|
|
|
if (x !== 0 || y !== 0) {
|
|
window.scrollBy(x * scrollSpeed, y * scrollSpeed);
|
|
}
|
|
|
|
requestAnimationFrame(autoScroll);
|
|
}
|
|
|
|
//
|
|
|
|
function buildDetail() {
|
|
const detail = {
|
|
el,
|
|
data,
|
|
image,
|
|
imageSource,
|
|
originX,
|
|
originY,
|
|
clientX,
|
|
clientY,
|
|
imageX,
|
|
imageY,
|
|
setImage: (source) => {
|
|
setImage(source);
|
|
detail.image = image;
|
|
},
|
|
};
|
|
|
|
return detail;
|
|
}
|
|
|
|
function setImage(source) {
|
|
if (imageSource === source) return;
|
|
imageSource = source;
|
|
|
|
removeImage();
|
|
|
|
image = imageSource.cloneNode(true);
|
|
image.style.position = 'fixed';
|
|
image.style.left = '0';
|
|
image.style.top = '0';
|
|
image.style.width = `${imageSource.offsetWidth}px`;
|
|
image.style.height = `${imageSource.offsetHeight}px`;
|
|
image.style.margin = '0';
|
|
image.style.zIndex = 9999;
|
|
image.classList.add('-dragging');
|
|
|
|
const rect = source.getBoundingClientRect();
|
|
imageX = originX - rect.left;
|
|
imageY = originY - rect.top;
|
|
|
|
image.addEventListener('draggableDrag', (e) => {
|
|
const x = e.detail.clientX - e.detail.imageX;
|
|
const y = e.detail.clientY - e.detail.imageY;
|
|
image.style.transition = 'none';
|
|
image.style.transform = `translate(${x}px, ${y}px)`;
|
|
});
|
|
|
|
image.addEventListener('draggableCancel', cleanUp);
|
|
|
|
document.body.appendChild(image);
|
|
}
|
|
|
|
function addDropClassOnce(e) {
|
|
e.target.removeEventListener(e.type, addDropClassOnce);
|
|
e.target.classList.add('-drop');
|
|
}
|
|
|
|
function removeDropClassOnce(e) {
|
|
e.target.removeEventListener(e.type, addDropClassOnce);
|
|
e.target.classList.remove('-drop');
|
|
}
|
|
|
|
function cleanUpOnce(e) {
|
|
e.target.removeEventListener(e.type, cleanUpOnce);
|
|
cleanUp();
|
|
}
|
|
|
|
function cleanUp() {
|
|
currentTarget?.classList.remove('-drop');
|
|
|
|
removeImage();
|
|
|
|
data = null;
|
|
image = null;
|
|
imageSource = null;
|
|
currentTarget = null;
|
|
}
|
|
|
|
function removeImage() {
|
|
image?.parentNode?.removeChild(image);
|
|
}
|
|
|
|
function getTarget() {
|
|
const candidates = [];
|
|
|
|
document.querySelectorAll(options.dropSelector).forEach((el) => {
|
|
const rect = el.getBoundingClientRect();
|
|
const distanceSquared = pointDistanceToRectSquared(
|
|
clientX,
|
|
clientY,
|
|
rect,
|
|
);
|
|
|
|
if (distanceSquared > dropRangeSquared) return;
|
|
|
|
candidates.push({
|
|
el,
|
|
distanceSquared,
|
|
});
|
|
});
|
|
|
|
candidates.sort((a, b) => {
|
|
if (a.distanceSquared === 0 && b.distanceSquared === 0) {
|
|
// in this case, the client position is inside both rectangles
|
|
// if A contains B, B is the correct target and vice versa
|
|
// TODO sort by z-index somehow?
|
|
return a.el.contains(b.el) ? -1 : b.el.contains(a.el) ? 1 : 0;
|
|
}
|
|
|
|
// sort by distance, ascending
|
|
return a.distanceSquared - b.distanceSquared;
|
|
});
|
|
|
|
return candidates.length > 0 ? candidates[0].el : null;
|
|
}
|
|
}
|
|
|
|
export function pointDistanceToRectSquared(x, y, rect) {
|
|
let dx = 0;
|
|
let dy = 0;
|
|
|
|
if (x < rect.left) {
|
|
dx = x - rect.left;
|
|
} else if (x > rect.right) {
|
|
dx = x - rect.right;
|
|
}
|
|
|
|
if (y < rect.top) {
|
|
dy = y - rect.top;
|
|
} else if (y > rect.bottom) {
|
|
dy = y - rect.bottom;
|
|
}
|
|
|
|
return dx * dx + dy * dy;
|
|
}
|
|
|
|
export function getPositionHost(e) {
|
|
if (e.targetTouches && e.targetTouches.length > 0) {
|
|
return e.targetTouches[0];
|
|
}
|
|
|
|
if (e.changedTouches && e.changedTouches.length > 0) {
|
|
return e.changedTouches[0];
|
|
}
|
|
|
|
return e;
|
|
}
|