@dragdroptouch/drag-drop-touch
Version:
A polyfill that enables HTML5 drag-and-drop support for touch devices
541 lines (538 loc) • 14.3 kB
JavaScript
// ts/drag-drop-touch-util.ts
function pointFrom(e, page = false) {
const touch = e.touches[0];
return {
x: page ? touch.pageX : touch.clientX,
y: page ? touch.pageY : touch.clientY
};
}
function copyProps(dst, src, props) {
for (let i = 0; i < props.length; i++) {
let p = props[i];
dst[p] = src[p];
}
}
function newForwardableEvent(type, srcEvent, target) {
const _kbdProps = ["altKey", "ctrlKey", "metaKey", "shiftKey"];
const _ptProps = [
"pageX",
"pageY",
"clientX",
"clientY",
"screenX",
"screenY",
"offsetX",
"offsetY"
];
const evt = new Event(type, {
bubbles: true,
cancelable: true
}), touch = srcEvent.touches[0];
evt.button = 0;
evt.which = evt.buttons = 1;
copyProps(evt, srcEvent, _kbdProps);
copyProps(evt, touch, _ptProps);
setOffsetAndLayerProps(evt, target);
return evt;
}
function setOffsetAndLayerProps(e, target) {
const rect = target.getBoundingClientRect();
if (e.offsetX === void 0) {
e.offsetX = e.clientX - rect.x;
e.offsetY = e.clientY - rect.y;
}
if (e.layerX === void 0) {
e.layerX = e.pageX - rect.left;
e.layerY = e.pageY - rect.top;
}
}
function copyStyle(src, dst) {
removeTroublesomeAttributes(dst);
if (src instanceof HTMLCanvasElement) {
let cDst = dst;
cDst.width = src.width;
cDst.height = src.height;
cDst.getContext("2d").drawImage(src, 0, 0);
}
copyComputedStyles(src, dst);
dst.style.pointerEvents = "none";
for (let i = 0; i < src.children.length; i++) {
copyStyle(src.children[i], dst.children[i]);
}
}
function copyComputedStyles(src, dst) {
let cs = getComputedStyle(src);
for (let key of cs) {
if (key.includes("transition")) continue;
dst.style[key] = cs[key];
}
Object.keys(dst.dataset).forEach((key) => delete dst.dataset[key]);
}
function removeTroublesomeAttributes(dst) {
["id", "class", "style", "draggable"].forEach(function(att) {
dst.removeAttribute(att);
});
}
// ts/drag-dto.ts
var DragDTO = class {
_dropEffect;
_effectAllowed;
_data;
_dragDropTouch;
constructor(dragDropTouch) {
this._dropEffect = "move";
this._effectAllowed = "all";
this._data = {};
this._dragDropTouch = dragDropTouch;
}
get dropEffect() {
return this._dropEffect;
}
set dropEffect(value) {
this._dropEffect = value;
}
get effectAllowed() {
return this._effectAllowed;
}
set effectAllowed(value) {
this._effectAllowed = value;
}
get types() {
return Object.keys(this._data);
}
/**
* ...docs go here...
* @param type
*/
clearData(type) {
if (type !== null) {
delete this._data[type.toLowerCase()];
} else {
this._data = {};
}
}
/**
* ...docs go here...
* @param type
* @returns
*/
getData(type) {
let lcType = type.toLowerCase(), data = this._data[lcType];
if (lcType === "text" && data == null) {
data = this._data["text/plain"];
}
return data;
}
/**
* ...docs go here...
* @param type
* @param value
*/
setData(type, value) {
this._data[type.toLowerCase()] = value;
}
/**
* ...docs go here...
* @param img
* @param offsetX
* @param offsetY
*/
setDragImage(img, offsetX, offsetY) {
this._dragDropTouch.setDragImage(img, offsetX, offsetY);
}
};
// ts/drag-drop-touch.ts
var { round } = Math;
var DefaultConfiguration = {
allowDragScroll: true,
contextMenuDelayMS: 900,
dragImageOpacity: 0.5,
dragScrollPercentage: 10,
dragScrollSpeed: 10,
dragThresholdPixels: 5,
forceListen: false,
isPressHoldMode: false,
pressHoldDelayMS: 400,
pressHoldMargin: 25,
pressHoldThresholdPixels: 0
};
var DragDropTouch = class {
_dragRoot;
_dropRoot;
_dragSource;
_lastTouch;
_lastTarget;
_ptDown;
_isDragEnabled;
_isDropZone;
_dataTransfer;
_img;
_imgCustom;
_imgOffset;
_pressHoldIntervalId;
configuration;
/**
* Deal with shadow DOM elements.
*
* Previous implementation used `document.elementFromPoint` to find the dropped upon
* element. This, however, doesn't "pierce" the shadow DOM. So instead, we can
* provide a drop tree element to search within. It would be nice if `elementFromPoint`
* were implemented on this node (arbitrarily), but it only appears on documents and
* shadow roots. So here we simply walk up the DOM tree until we find that method.
*
* In fact this does NOT restrict dropping to just the root provided-- but the whole
* tree. I'm not sure that this is a general solution, but works for my specific and
* the general one.
*
* @param dragRoot
* @param options
*/
constructor(dragRoot = document, dropRoot = document, options) {
this.configuration = { ...DefaultConfiguration, ...options || {} };
this._dragRoot = dragRoot;
this._dropRoot = dropRoot;
while (!this._dropRoot.elementFromPoint && this._dropRoot.parentNode)
this._dropRoot = this._dropRoot.parentNode;
this._dragSource = null;
this._lastTouch = null;
this._lastTarget = null;
this._ptDown = null;
this._isDragEnabled = false;
this._isDropZone = false;
this._dataTransfer = new DragDTO(this);
this._img = null;
this._imgCustom = null;
this._imgOffset = { x: 0, y: 0 };
this.listen();
}
/**
* ...docs go here...
* @returns
*/
listen() {
if (navigator.maxTouchPoints === 0 && !this.configuration.forceListen) {
return;
}
const opt = { passive: false, capture: false };
this._dragRoot.addEventListener(
`touchstart`,
this._touchstart.bind(this),
opt
);
this._dragRoot.addEventListener(
`touchmove`,
this._touchmove.bind(this),
opt
);
this._dragRoot.addEventListener(
`touchend`,
this._touchend.bind(this)
);
this._dragRoot.addEventListener(
`touchcancel`,
this._touchend.bind(this)
);
}
/**
* ...docs go here...
* @param img
* @param offsetX
* @param offsetY
*/
setDragImage(img, offsetX, offsetY) {
this._imgCustom = img;
this._imgOffset = { x: offsetX, y: offsetY };
}
/**
* ...docs go here...
* @param e
*/
_touchstart(e) {
if (this._shouldHandle(e)) {
this._reset();
let src = this._closestDraggable(e.target);
if (src) {
if (e.target && !this._dispatchEvent(e, `mousemove`, e.target) && !this._dispatchEvent(e, `mousedown`, e.target)) {
this._dragSource = src;
this._ptDown = pointFrom(e);
this._lastTouch = e;
setTimeout(() => {
if (this._dragSource === src && this._img === null) {
if (this._dispatchEvent(e, `contextmenu`, src)) {
this._reset();
}
}
}, this.configuration.contextMenuDelayMS);
if (this.configuration.isPressHoldMode) {
this._pressHoldIntervalId = setTimeout(() => {
this._isDragEnabled = true;
this._touchmove(e);
}, this.configuration.pressHoldDelayMS);
} else if (!e.isTrusted) {
if (e.target !== this._lastTarget) {
this._lastTarget = e.target;
}
}
}
}
}
}
/**
* ...docs go here...
* @param e
* @returns
*/
_touchmove(e) {
if (this._shouldCancelPressHoldMove(e)) {
this._reset();
return;
}
if (this._shouldHandleMove(e) || this._shouldHandlePressHoldMove(e)) {
let target = this._getTarget(e);
if (this._dispatchEvent(e, `mousemove`, target)) {
this._lastTouch = e;
e.preventDefault();
return;
}
if (this._dragSource && !this._img && this._shouldStartDragging(e)) {
if (this._dispatchEvent(this._lastTouch, `dragstart`, this._dragSource)) {
this._dragSource = null;
return;
}
this._createImage(e);
this._dispatchEvent(e, `dragenter`, target);
}
if (this._img && this._dragSource) {
this._lastTouch = e;
e.preventDefault();
this._dispatchEvent(e, `drag`, this._dragSource);
if (target !== this._lastTarget) {
if (this._lastTarget)
this._dispatchEvent(this._lastTouch, `dragleave`, this._lastTarget);
this._dispatchEvent(e, `dragenter`, target);
this._lastTarget = target;
}
this._moveImage(e);
this._isDropZone = this._dispatchEvent(e, `dragover`, target);
if (this.configuration.allowDragScroll) {
const delta = this._getHotRegionDelta(e);
globalThis.scrollBy(delta.x, delta.y);
}
}
}
}
/**
* ...docs go here...
* @param e
* @returns
*/
_touchend(e) {
if (!(this._lastTouch && e.target && this._lastTarget)) {
this._reset();
return;
}
if (this._shouldHandle(e)) {
if (this._dispatchEvent(this._lastTouch, `mouseup`, e.target)) {
e.preventDefault();
return;
}
if (!this._img) {
this._dragSource = null;
this._dispatchEvent(this._lastTouch, `click`, e.target);
}
this._destroyImage();
if (this._dragSource) {
if (e.type.indexOf(`cancel`) < 0 && this._isDropZone) {
this._dispatchEvent(this._lastTouch, `drop`, this._lastTarget);
}
this._dispatchEvent(this._lastTouch, `dragend`, this._dragSource);
this._reset();
}
}
}
/**
* ...docs go here...
* @param e
* @returns
*/
_shouldHandle(e) {
return e && !e.defaultPrevented && e.touches && e.touches.length < 2;
}
/**
* ...docs go here...
* @param e
* @returns
*/
_shouldHandleMove(e) {
return !this.configuration.isPressHoldMode && this._shouldHandle(e);
}
/**
* ...docs go here...
* @param e
* @returns
*/
_shouldHandlePressHoldMove(e) {
return this.configuration.isPressHoldMode && this._isDragEnabled && e && e.touches && e.touches.length;
}
/**
* ...docs go here...
* @param e
* @returns
*/
_shouldCancelPressHoldMove(e) {
return this.configuration.isPressHoldMode && !this._isDragEnabled && this._getDelta(e) > this.configuration.pressHoldMargin;
}
/**
* ...docs go here...
* @param e
* @returns
*/
_shouldStartDragging(e) {
let delta = this._getDelta(e);
if (this.configuration.isPressHoldMode) {
return delta >= this.configuration.pressHoldThresholdPixels;
}
return delta > this.configuration.dragThresholdPixels;
}
/**
* ...docs go here...
*/
_reset() {
this._destroyImage();
this._dragSource = null;
this._lastTouch = null;
this._lastTarget = null;
this._ptDown = null;
this._isDragEnabled = false;
this._isDropZone = false;
this._dataTransfer = new DragDTO(this);
clearTimeout(this._pressHoldIntervalId);
}
/**
* ...docs go here...
* @param e
* @returns
*/
_getDelta(e) {
if (!this._ptDown) return 0;
const { x, y } = this._ptDown;
const p = pointFrom(e);
return ((p.x - x) ** 2 + (p.y - y) ** 2) ** 0.5;
}
/**
* ...docs go here...
* @param e
*/
_getHotRegionDelta(e) {
const { clientX: x, clientY: y } = e.touches[0];
const { innerWidth: w, innerHeight: h } = globalThis;
const { dragScrollPercentage, dragScrollSpeed } = this.configuration;
const v1 = dragScrollPercentage / 100;
const v2 = 1 - v1;
const dx = x < w * v1 ? -dragScrollSpeed : x > w * v2 ? +dragScrollSpeed : 0;
const dy = y < h * v1 ? -dragScrollSpeed : y > h * v2 ? +dragScrollSpeed : 0;
return { x: dx, y: dy };
}
/**
* ...docs go here...
* @param e
* @returns
*/
_getTarget(e) {
let pt = pointFrom(e), el = this._dropRoot.elementFromPoint(pt.x, pt.y);
while (el && getComputedStyle(el).pointerEvents == `none`) {
el = el.parentElement;
}
return el;
}
/**
* ...docs go here...
* @param e
*/
_createImage(e) {
if (this._img) {
this._destroyImage();
}
let src = this._imgCustom || this._dragSource;
this._img = src.cloneNode(true);
copyStyle(src, this._img);
this._img.style.top = this._img.style.left = `-9999px`;
if (!this._imgCustom) {
let rc = src.getBoundingClientRect(), pt = pointFrom(e);
this._imgOffset = { x: pt.x - rc.left, y: pt.y - rc.top };
this._img.style.opacity = `${this.configuration.dragImageOpacity}`;
}
this._moveImage(e);
document.body.appendChild(this._img);
}
/**
* ...docs go here...
*/
_destroyImage() {
if (this._img && this._img.parentElement) {
this._img.parentElement.removeChild(this._img);
}
this._img = null;
this._imgCustom = null;
}
/**
* ...docs go here...
* @param e
*/
_moveImage(e) {
requestAnimationFrame(() => {
if (this._img) {
let pt = pointFrom(e, true), s = this._img.style;
s.position = `absolute`;
s.pointerEvents = `none`;
s.zIndex = `999999`;
s.left = `${round(pt.x - this._imgOffset.x)}px`;
s.top = `${round(pt.y - this._imgOffset.y)}px`;
}
});
}
/**
* ...docs go here...
* @param srcEvent
* @param type
* @param target
* @returns
*/
_dispatchEvent(srcEvent, type, target) {
if (!(srcEvent && target)) return false;
const evt = newForwardableEvent(type, srcEvent, target);
evt.dataTransfer = this._dataTransfer;
target.dispatchEvent(evt);
return evt.defaultPrevented;
}
/**
* ...docs go here...
* @param el
* @returns
*/
_closestDraggable(element) {
for (let e = element; e !== null; e = e.parentElement) {
if (e.draggable) {
return e;
}
}
return null;
}
};
function enableDragDropTouch(dragRoot = document, dropRoot = document, options) {
new DragDropTouch(dragRoot, dropRoot, options);
}
if (import.meta.url.includes(`?autoload`)) {
enableDragDropTouch(document, document, {
forceListen: true
});
} else {
globalThis.DragDropTouch = {
enable: function(dragRoot = document, dropRoot = document, options) {
enableDragDropTouch(dragRoot, dropRoot, options);
}
};
}
export {
enableDragDropTouch
};