js-draw
Version:
Draw pictures using a pen, touchscreen, or mouse! JS-draw is a drawing library for JavaScript and TypeScript.
158 lines (157 loc) • 6.41 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
const math_1 = require("@js-draw/math");
const makeDraggable = (dragElement, options) => {
const dragElements = [...options.draggableChildElements, dragElement];
let lastX = 0;
let lastY = 0;
let startX = 0;
let startY = 0;
let pointerDown = false;
let capturedPointerId = null;
const isDraggableElement = (element) => {
if (!element) {
return false;
}
if (dragElements.includes(element)) {
return true;
}
// Some elements need to handle drag events to avoid breaking the UI:
const undraggableElementTypes = ['INPUT', 'SELECT'];
let hasSuitableAncestors = false;
let ancestor = element.parentElement;
while (ancestor) {
if (undraggableElementTypes.includes(ancestor.tagName))
break;
if (dragElements.includes(ancestor)) {
hasSuitableAncestors = true;
break;
}
ancestor = ancestor.parentElement;
}
return !undraggableElementTypes.includes(element.tagName) && hasSuitableAncestors;
};
const canScrollAncestorVertically = (element, direction) => {
if (!(element instanceof HTMLElement))
return false;
let ancestor = element;
// Only consider ancestors within the draggable element: This should not
// consider the main document scroll element:
while (ancestor && !dragElements.includes(ancestor)) {
// Workaround: Certain content can't scroll completely to the start/end
// (e.g. paged lists). To work around this, define a small, non-zero
// tolerance. Within this tolerance, the element is considered "completely
// scrolled":
const epsilon = 10;
if (ancestor.scrollHeight > ancestor.clientHeight + epsilon) {
const canScrollUp = ancestor.scrollTop > epsilon;
const canScrollDown = ancestor.scrollTop + ancestor.clientHeight < ancestor.scrollHeight - epsilon;
if (direction.y < 0 && canScrollDown)
return true;
if (direction.y > 0 && canScrollUp)
return true;
}
ancestor = ancestor.parentElement;
}
return false;
};
const removeEventListenerCallbacks = [];
const addEventListener = (listenerType, listener, options) => {
dragElement.addEventListener(listenerType, listener, options);
removeEventListenerCallbacks.push(() => {
dragElement.removeEventListener(listenerType, listener);
});
};
const clickThreshold = 5;
// Returns whether the current (or if no current, **the last**) gesture is roughly a click.
// Because this can be called **after** a gesture has just ended, it should not require
// the gesture to be in progress.
const isRoughlyClick = (currentX, currentY) => {
return Math.hypot(currentX - startX, currentY - startY) < clickThreshold;
};
let startedDragging = false;
addEventListener('pointerdown', (event) => {
if (event.defaultPrevented || !isDraggableElement(event.target)) {
return;
}
if (event.isPrimary) {
startedDragging = false;
lastX = event.clientX;
lastY = event.clientY;
startX = event.clientX;
startY = event.clientY;
capturedPointerId = null;
pointerDown = true;
}
}, { passive: true });
const onGestureEnd = (_event) => {
// If the pointerup/pointercancel event was for a pointer not being tracked,
if (!pointerDown) {
return;
}
if (capturedPointerId !== null) {
dragElement.releasePointerCapture(capturedPointerId);
capturedPointerId = null;
}
options.onDragEnd({
roughlyClick: isRoughlyClick(lastX, lastY),
endTimestamp: performance.now(),
displacement: math_1.Vec2.of(lastX - startX, lastY - startY),
});
pointerDown = false;
startedDragging = false;
};
addEventListener('pointermove', (event) => {
if (!event.isPrimary || !pointerDown) {
return undefined;
}
// Mouse event and no buttons pressed? Cancel the event.
// This can happen if the event was canceled by a focus change (e.g. by opening a
// right-click menu).
if (event.pointerType === 'mouse' && event.buttons === 0) {
onGestureEnd(event);
return undefined;
}
const x = event.clientX;
const y = event.clientY;
const isClick = isRoughlyClick(x, y);
const dx = x - lastX;
const dy = y - lastY;
const deltaToStart = math_1.Vec2.of(x - startX, y - startY);
const isScroll = () => canScrollAncestorVertically(event.target, deltaToStart);
if (startedDragging || (!isClick && !isScroll())) {
options.onDrag(dx, dy, deltaToStart);
if (capturedPointerId === null) {
dragElement.setPointerCapture(event.pointerId);
capturedPointerId = event.pointerId;
}
lastX = x;
lastY = y;
startedDragging = true;
}
});
addEventListener('pointerleave', (event) => {
// Capture the pointer if it exits the container while dragging.
if (capturedPointerId === null && pointerDown && event.isPrimary) {
dragElement.setPointerCapture(event.pointerId);
capturedPointerId = event.pointerId;
}
});
addEventListener('pointerup', onGestureEnd);
addEventListener('pointercancel', onGestureEnd);
// In Chrome (as of Dec 2025), .preventDefault needs to be called from ontouchmove
// to allow the drag event to continue to be handled.
addEventListener('touchmove', (event) => {
if (startedDragging && event.touches.length > 0) {
event.preventDefault();
}
});
return {
removeListeners: () => {
for (const removeListenerCallback of removeEventListenerCallbacks) {
removeListenerCallback();
}
},
};
};
exports.default = makeDraggable;