coherent-gameface-interaction-manager
Version:
Library for the most common UI interactions
384 lines (311 loc) • 13 kB
JavaScript
/*---------------------------------------------------------------------------------------------
* Copyright (c) Coherent Labs AD. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import DragBase from '../utils/drag-base';
import { createHash } from '../utils/utility-functions';
import actions from './actions';
import touchGestures from './touch-gestures';
/**
* Makes an element draggable
*/
class Dropzone extends DragBase {
/**
*
* @typedef {Object} DropzoneOptions
* @property {string} element
* @property {string[]} dropzones
* @property {string} draggedClass
* @property {string} dropzoneActiveClass
* @property {'switch'|'add'|'shift'|'none'} dropType If there is already an element in the dropzone
* @property {function} onDragStart
* @property {function} onDragMove
* @property {function} onDragEnd
* @property {function} onDropZoneEnter
* @property {function} onDropZoneLeave
* @property {function} onDrop
*/
/**
*
* @param {DropzoneOptions} options
*/
constructor(options) {
super(options);
const hash = createHash();
this.dropzones = [];
this.draggedOver = null;
this.actionName = `drag-and-drop-${hash}`;
this.automaticAction = `move-to-${hash}`;
this.onMouseEnter = this.onMouseEnter.bind(this);
this.onMouseLeave = this.onMouseLeave.bind(this);
this._touchEnabled = false;
this.touchEvents = [];
this.init();
}
/**
* Enables or disabled touch events
* @param {boolean} enabled
*/
set touchEnabled(enabled) {
if (this._touchEnabled === enabled) return;
this._touchEnabled = enabled;
this._touchEnabled ? this.addTouchEvents() : this.removeTouchEvents();
}
/**
* @returns {void}
*/
init() {
if (this.enabled) return;
this.draggableElements = document.querySelectorAll(this.options.element);
if (this.draggableElements.length === 0) {
return console.error(`${this.options.element} is not a valid element selector.`);
}
this.draggableElements.forEach(element => element.addEventListener('mousedown', this.onMouseDown));
this.createDropzones();
if (this.dropzones.length === 0) return;
this.dropzones.forEach((dropzone) => {
dropzone.addEventListener('mouseenter', this.onMouseEnter);
dropzone.addEventListener('mouseleave', this.onMouseLeave);
});
this.registerDragActions();
this.enabled = true;
}
/**
* Deinitializes the dragging
* @returns {void}
*/
deinit() {
if (!this.enabled) return;
this.draggableElements.forEach(element => element.removeEventListener('mousedown', this.onMouseDown));
this.dropzones.forEach((dropzone) => {
dropzone.removeEventListener('mouseenter', this.onMouseEnter);
dropzone.removeEventListener('mouseleave', this.onMouseLeave);
});
this.removeActions();
this.enabled = false;
}
/**
* mousedown event handler
* @param {MouseEvent} event
*/
onMouseDown(event) {
this.draggedElement = event.currentTarget;
this.draggedElement.style.position = 'absolute';
this.draggedElement.style.pointerEvents = 'none';
this.setPointerOffset(event.clientX, event.clientY, this.draggedElement);
actions.execute(this.actionName, {
x: event.clientX + this.bodyScrollOffset.x - this.offset.x,
y: event.clientY + this.bodyScrollOffset.y - this.offset.y,
index: this.draggedItemIndex,
});
this.options.dragStyle && this.draggedElement.classList.add(this.options.dragStyle);
this.options.onDragStart && this.options.onDragStart(this.draggedElement);
document.addEventListener('mousemove', this.onMouseMove);
document.addEventListener('mouseup', this.onMouseUp);
}
/**
* mousemove event handler
* @param {MouseEvent} event
*/
onMouseMove(event) {
actions.execute(this.actionName, {
x: event.clientX + this.bodyScrollOffset.x - this.offset.x,
y: event.clientY + this.bodyScrollOffset.y - this.offset.y,
index: this.draggedItemIndex,
});
}
/**
* mouseup event handler
*/
onMouseUp() {
document.removeEventListener('mousemove', this.onMouseMove);
document.removeEventListener('mouseup', this.onMouseUp);
this.draggedElement.style.position = '';
this.draggedElement.style.pointerEvents = '';
this.draggedElement.style.left = '';
this.draggedElement.style.top = '';
this.options.onDragEnd && this.options.onDragEnd(this.draggedElement);
this.options.dragStyle && this.draggedElement.classList.remove(this.options.dragStyle);
this.draggedOver && this.handleDrop();
this.draggedElement = null;
}
/**
* Handler for the mouseenter event
* @param {MouseEvent} event
* @returns {void}
*/
onMouseEnter(event) {
if (!this.draggedElement) return;
this.draggedOver = event.currentTarget;
this.options.dropzoneActiveClass && this.draggedOver.classList.add(this.options.dropzoneActiveClass);
}
/**
* Handler for the mouseleave event
* @returns {void}
*/
onMouseLeave() {
if (!this.draggedElement) return;
if (this.options.dropzoneActiveClass && this.draggedOver) {
this.draggedOver.classList.remove(this.options.dropzoneActiveClass);
}
this.draggedOver = null;
}
/**
* Register dragging as an action to be able to use it externally
*/
registerDragActions() {
actions.register(this.actionName, ({ x, y, index }) => {
if (!this.draggableElements[index]) return console.error(`There is no draggable element at index ${index}`);
this.draggableElements[index].style.left = `${x}px`;
this.draggableElements[index].style.top = `${y}px`;
this.options.onDragMove && this.options.onDragMove({ x, y });
});
actions.register(this.automaticAction, this.automaticMove.bind(this));
this.actionsRegistered = true;
}
/**
* Removes the registered actions
*/
removeActions() {
actions.remove(this.actionName);
actions.remove(this.automaticAction);
}
/* eslint-disable max-lines-per-function*/
/**
* Adds touch events to the draggable elements
*/
addTouchEvents() {
this.draggableElements.forEach((element) => {
this.touchEvents.push(
touchGestures.drag({
element,
onDragStart: (event) => {
this.onMouseDown({ currentTarget: event.currentTarget, clientX: event.x, clientY: event.y });
},
onDrag: ({ x, y }) => {
this.onMouseMove({ clientX: x, clientY: y });
const elementOver = document.elementFromPoint(x, y);
let dropzone = this.options.dropzones.reduce((acc, dropzone) => {
if (acc) return acc;
return (acc = elementOver.closest(dropzone));
}, null);
if (!dropzone) {
dropzone = this.dropzones.includes(elementOver) ? elementOver : null;
}
if (dropzone) {
this.onMouseEnter({ currentTarget: dropzone });
return;
}
this.onMouseLeave();
},
onDragEnd: () => {
this.onMouseUp();
},
})
);
});
}
/* eslint-enable max-lines-per-function */
/**
* Removes the touch gestures
*/
removeTouchEvents() {
this.touchEvents.forEach(event => event.remove());
this.touchEvents = [];
}
/**
* Automatically drags an element to a dropzone
* @param {Object} options
* @param {number} options.elementIndex The index of the element you want to move
* @param {number} options.dropzoneIndex The index of the dropzone you want to move the element to
*/
automaticMove({ elementIndex, dropzoneIndex }) {
const { x, y } = this.dropzones[dropzoneIndex].getBoundingClientRect();
const { x: elementX, y: elementY } = this.draggableElements[elementIndex].getBoundingClientRect();
const direction = {
x: Math.sign(x - elementX),
y: Math.sign(y - elementY),
};
this.draggableElements[elementIndex].style.position = 'absolute';
const loop = (newX, newY) => {
if (newX === x && newY === y) {
this.dropzones[dropzoneIndex].appendChild(this.draggableElements[elementIndex]);
this.draggableElements[elementIndex].style.position = '';
return;
}
newX = newX !== x ? direction.x + newX : newX;
newY = newY !== y ? direction.y + newY : newY;
this.draggableElements[elementIndex].style.left = `${newX}px`;
this.draggableElements[elementIndex].style.top = `${newY}px`;
if (newX !== x || newY !== y) requestAnimationFrame(() => loop(newX, newY));
};
loop(elementX, elementY);
}
/**
* Saves the dropzones
*/
createDropzones() {
this.dropzones = this.options.dropzones.reduce((acc, dropzone) => {
const dropzoneElements = [...document.querySelectorAll(dropzone)];
if (dropzoneElements.length === 0) {
console.error(`${dropzone} is not a valid html element to be used as a dropzone`);
}
return acc.concat(dropzoneElements);
}, []);
}
/**
* Handler when you drop the dragged item
*/
handleDrop() {
let dropType = this.options.dropType;
const eventData = {
preventDefault: () => {
dropType = 'ignore';
},
target: this.draggedElement,
dropzone: this.draggedOver,
};
this.options.onDrop && this.options.onDrop(eventData);
if (dropType === 'ignore') return;
if (this.draggedOver.children.length === 0) dropType = 'add';
switch (dropType) {
case 'add':
this.draggedOver.appendChild(this.draggedElement);
break;
case 'shift':
this.shiftElements();
break;
case 'switch':
this.draggedElement.parentNode.appendChild(this.draggedOver.children[0]);
this.draggedOver.appendChild(this.draggedElement);
break;
case 'none':
break;
default:
break;
}
this.onMouseLeave();
}
/**
* Shifts the element to the nearest empty space
*/
shiftElements() {
const dropzoneIndex = this.dropzones.findIndex(dropzone => dropzone === this.draggedOver);
const dropzoneChildren = this.dropzones.map((dropzone) => {
if (dropzone.children[0] === this.draggedElement) return [];
return [...dropzone.children];
});
const closestEmptyIndex = dropzoneChildren.reduce((acc, el, index) => {
if (el.length === 0) acc = Math.abs(dropzoneIndex - index) < Math.abs(dropzoneIndex - acc) ? index : acc;
return acc;
}, 100000);
const directionEmpty = Math.sign(dropzoneIndex - closestEmptyIndex) > 0 ? 0 : 1;
const directionDropzone = Math.sign(dropzoneIndex - closestEmptyIndex) < 0 ? 0 : 1;
dropzoneChildren.splice(dropzoneIndex + directionDropzone, 0, [this.draggedElement]);
dropzoneChildren.splice(closestEmptyIndex + directionEmpty, 1);
dropzoneChildren.forEach((children, index) => {
children.forEach(child => this.dropzones[index].appendChild(child));
});
}
}
export default Dropzone;