UNPKG

drag-drop-plus

Version:

Drag any object in DOM and drop it anywhere in DOM. Works well with Mouse, Touch and Keyboard events. Suuports accessibility to some extent.

651 lines (519 loc) 19.1 kB
class DragDrop { static MOVE_BY = 20; // px static SCROLL_ZONE_HEIGHT = 80; // px static SCROLL_AMOUNT = 10; // px static MOVE_KEYS = { 38: "N", 40: "S", 39: "E", 37: "W" }; // e.keyCodes static DROP_ANIM_DURATION = 300; // milliseconds // Attributes to work on #DRAG_BOX_ATTR = "data-draggable"; #DROP_ZONE_ATTR = "data-dropzone"; #DRAGGING_BOX_ATTR = "data-dragging-box"; // Usfull vars #boxToDrag = null; #boxIsFocused = false; #boxIsSelected = false; #boxMouseSelected = false; #draggingBox = null; #draggingFrom = null; #dropZones = []; #dropBox = null; #mouse = { mx: null, my: null, boxX: null, boxY: null, dx: null, dy: null }; #mouseLastCoords = { x: 0, y: 0 }; // Information for abstraction - read-only #candrop = { value: null, dropbox: null }; constructor() { this.#makeElementsAccessible(this.#DRAG_BOX_ATTR, true); this.#addEventListners(); } // Set Drag-Boxes and DropZones #setDragDropElements(envObject) { if (Object.entries(envObject).length === 0) return; const { boxAttr, dropboxAttr, dragboxAttr } = envObject; this.#DRAG_BOX_ATTR = boxAttr ?? "nullAttribute"; this.#DROP_ZONE_ATTR = dropboxAttr ?? "nullAttribute"; this.#DRAGGING_BOX_ATTR = dragboxAttr ?? "nullAttribute"; // Add accissibilty to default elements this.#makeElementsAccessible(this.#DRAG_BOX_ATTR, true); this.#makeElementsAccessible(this.#DROP_ZONE_ATTR, true); } // Make draggable elements accissible by keyboard. #makeElementsAccessible(attr, bool) { setTimeout(() => { const boxes = document.querySelectorAll(`[${attr}]`); if (boxes.length === 0) { throw new Error(`No element found with Attribute "${attr}"`); } boxes.forEach((box) => box.setAttribute("tabindex", bool ? "0" : "")); }, 10); } // Add Event Listners #addEventListners() { // Mouse drag-drop document.addEventListener("mousedown", this.#eventCallback); document.addEventListener("mouseup", this.#eventCallback); document.addEventListener("mousemove", this.#eventCallback); // Key Events for accessibilty document.addEventListener("keydown", this.#eventCallback); document.addEventListener("keyup", this.#eventCallback); // Mobile touch drag-drop // { passive: false } to prevent reload on swipe-down in mobile phones document.addEventListener("touchstart", this.#eventCallback, { passive: false, }); document.addEventListener("touchend", this.#eventCallback, { passive: false, }); document.addEventListener("touchmove", this.#eventCallback, { passive: false, }); } // Remove Event Listners #removeEventListners() { // Mouse drag-drop document.removeEventListener("mousedown", this.#eventCallback); document.removeEventListener("mouseup", this.#eventCallback); document.removeEventListener("mousemove", this.#eventCallback); // Key Events for accessibilty document.removeEventListener("keydown", this.#eventCallback); document.removeEventListener("keyup", this.#eventCallback); // Mobile touch drag-drop // { passive: false } to prevent reload on swipe-down in mobile phones document.removeEventListener("touchstart", this.#eventCallback, { passive: false, }); document.removeEventListener("touchend", this.#eventCallback, { passive: false, }); document.removeEventListener("touchmove", this.#eventCallback, { passive: false, }); } // Callback map #eventCallback = (event) => { switch (event.type) { case "mousedown": this.#onMouseDown(event); break; case "mouseup": this.#onMouseUp(event); break; case "mousemove": this.#onMouseMove(event); break; case "touchstart": this.#onTouchStart(event); break; case "touchend": this.#onTouchEnd(event); break; case "touchmove": this.#onTouchMove(event); break; case "keydown": this.#onKeyDown(event); break; case "keyup": this.#onKeyUp(event); break; } }; // Handle mousedown event on draggable box #onMouseDown(event) { const target = event.target.hasAttribute(this.#DRAG_BOX_ATTR) ? event.target : event.target.closest(`[${this.#DRAG_BOX_ATTR}]`); if (!target || this.#boxIsSelected) return; // restricts text selection onMouseMove event.preventDefault(); this.#setPropertiesOnDragStart(event, target); this.#setInitialCoords(event); this.#createDraggingBox(event); window.requestAnimationFrame(() => this.#scrollOnScrollZone(event)); } // Handle touch-start event on draggable box #onTouchStart(event) { const target = event.target.hasAttribute(this.#DRAG_BOX_ATTR) ? event.target : event.target.closest(`[${this.#DRAG_BOX_ATTR}]`); if (!target || this.#boxIsSelected) return; this.#setPropertiesOnDragStart(event, target); this.#setInitialCoords(event); this.#createDraggingBox(event); window.requestAnimationFrame(() => this.#scrollOnScrollZone(event)); } // Create a clone of the draggable box to use as the dragging element #createDraggingBox(event) { // return if box is already dragging. if (this.#draggingBox) return; // dragging will be done on new Node this.#draggingBox = this.#boxToDrag.cloneNode(true); // hide original node this.#boxToDrag.style.opacity = 0; // remove drag_box_attr and add dragging_box attr to dragging box this.#draggingBox.removeAttribute(this.#DRAG_BOX_ATTR); this.#draggingBox.setAttribute(this.#DRAGGING_BOX_ATTR, ""); let { width, height, x, y } = this.#boxToDrag.getBoundingClientRect(); x += window.scrollX; y += window.scrollY; this.#draggingBox.style.cssText = ` position: absolute; width: ${width}px; height: ${height}px; top: ${y}px; left: ${x}px; box-shadow: 0 0 10px #00000080; outline: 1px solid gray; z-index: 999; user-select: none; `; document.querySelector("body").append(this.#draggingBox); this.#makeElementsAccessible(this.#DRAG_BOX_ATTR, false); this.#makeElementsAccessible(this.#DROP_ZONE_ATTR, true); this.onDragStart(event); } // Scroll screen when box is near to top-bottom ends #scrollOnScrollZone(event) { if (this.#boxIsSelected) { const viewportHeight = window.innerHeight; const cursorPosition = this.#mouseLastCoords.y; const draggingBoxTop = parseInt(this.#draggingBox.style.top); const scrollAmount = DragDrop.SCROLL_AMOUNT; // Check for mouseY within 80px from top. To scroll up // Also scrollY > 0 is checked to not update Box-Top when scrollY == 0 if (cursorPosition < DragDrop.SCROLL_ZONE_HEIGHT && window.pageYOffset) { window.scrollBy(0, -scrollAmount); // Move draggingBox with scroll this.#draggingBox.style.top = draggingBoxTop - scrollAmount + "px"; } // Check for mouseY within 80px from bottom. To scroll down. else if (cursorPosition > viewportHeight - DragDrop.SCROLL_ZONE_HEIGHT) { window.scrollBy(0, scrollAmount); // Move draggingBox with scroll this.#draggingBox.style.top = draggingBoxTop + scrollAmount + "px"; } window.requestAnimationFrame(() => this.#scrollOnScrollZone(event)); } } // Handle mouseup event on draggable box #onMouseUp(event) { const target = event.target.closest(`[${this.#DRAGGING_BOX_ATTR}]`) ?? event.target; if (target !== this.#draggingBox) return this.#removeDraggingBox(event); // Get dropbox where mouse left the dragging box if (this.#boxIsSelected) this.#dropBox = this.#pointerInsideDropbox(); // If there is a valid dropbox, drop the element if (this.#dropBox) this.#dropDraggingBox(event); this.#boxMouseSelected = false; this.#mouseLastCoords = { x: 0, y: 0 }; // Remove the dragging box this.#removeDraggingBox(event); } // Handle touchend event on draggable box #onTouchEnd(event) { const target = event.target.closest(`[${this.#DRAGGING_BOX_ATTR}]`) ?? event.target; // Get dropbox where mouse left the dragging box if (this.#boxIsSelected) this.#dropBox = this.#pointerInsideDropbox(); // If there is a valid dropbox, drop the element if (this.#dropBox) this.#dropDraggingBox(event); this.#boxMouseSelected = false; this.#mouseLastCoords = { x: 0, y: 0 }; // Remove the dragging box this.#removeDraggingBox(event); } // Handle KeyUp event on Document #onKeyUp(event) { if (event.key !== "Tab") return true; // Check if drop-Zone is focused const dropzoneIsFocused = document.activeElement.hasAttribute( this.#DROP_ZONE_ATTR ); // Update Abstracted Object if (dropzoneIsFocused) { this.#candrop = { value: true, dropbox: document.activeElement }; } else { this.#candrop = { value: false, dropbox: null }; } // Custom function call this.onTabKeypress(event); // Don't select any other elements if already picked an element if (this.#boxIsSelected) return event.preventDefault(); // Make a Box selectable this.#boxIsFocused = document.activeElement.hasAttribute( this.#DRAG_BOX_ATTR ); // Select box to drag if draggable box is selected if (this.#boxIsFocused) this.#boxToDrag = document.activeElement; } // Handle KeyDown event on Document #onKeyDown(event) { // which direction to move const direction = DragDrop.MOVE_KEYS[event.keyCode]; // On [SPACE] keyDown, //if box has focus() if (event.key === " " && this.#boxIsFocused) { // prevent the scroll when SPACE key pressed event.preventDefault(); // Do nothing if already selected by mouse if (this.#boxMouseSelected) return true; // Pick the box if no box-selected if (!this.#boxIsSelected) { this.#boxIsSelected = true; // Mark selected // set for go-back transition this.#setInitialCoords(null); // Make inviewport, if already not, when selected. this.#keepElementInViewport(this.#boxToDrag); } // Drop the box if already a box selected else { this.#dropBox = this.#elementInsideDropbox(); const dropzoneIsFocused = document.activeElement.hasAttribute( this.#DROP_ZONE_ATTR ); // If dropzone is focused via [TAB] key if (dropzoneIsFocused) this.#dropBox = document.activeElement; // Drop the box if valid dropbox exists if (this.#dropBox) { this.#dropDraggingBox(event); } this.#removeDraggingBox(event); } } // On [ArrowKey Down] // and if not selected by mouse & there exists a dragging box else if (!this.#boxMouseSelected && direction && this.#draggingBox) { // prevent scroll on arrow keyDown event.preventDefault(); // Make dragging box in viewport if gone out of viewport this.#keepElementInViewport(this.#draggingBox); this.#moveBox(direction, event); } else return; // Generate a draggable Element if selected a Box if (this.#boxIsSelected) { this.#draggingFrom = this.#boxToDrag?.parentElement; this.#createDraggingBox(event); this.#dropZones = Array.from( document.querySelectorAll(`[${this.#DROP_ZONE_ATTR}]`) ); } } // Remove the clone of the draggable box #removeDraggingBox(event) { if (!this.#draggingBox) return; // position to start transition from const { x: x1, y: y1 } = this.#draggingBox.getBoundingClientRect(); // position to end transition at const { x: x2, y: y2 } = this.#boxToDrag.getBoundingClientRect(); // distance to transform const dx = Math.abs(x2 - x1); const dy = Math.abs(y2 - y1); // transition when distance > 200PX let animDuration = 0; if (Math.max(dx, dy) > 200) animDuration = DragDrop.DROP_ANIM_DURATION; Object.assign(this.#draggingBox.style, { transition: animDuration + "ms linear", top: window.scrollY + y2 + "px", left: window.scrollX + x2 + "px", }); setTimeout(() => { // Show hidden drag box and then focus() this.#boxToDrag.style.opacity = 1; this.#boxIsSelected = false; this.#boxToDrag.focus(); this.#draggingBox?.remove(); this.#draggingBox = null; this.#keepElementInViewport(this.#boxToDrag); }, animDuration); this.#makeElementsAccessible(this.#DRAG_BOX_ATTR, true); this.#makeElementsAccessible(this.#DROP_ZONE_ATTR, false); this.#candrop = { value: null, dropbox: null }; this.onDragEnd(event); } // Finally drop the Box by copying a #dropDraggingBox(event) { const newBox = this.#boxToDrag.cloneNode(true); // add drag box to dropzone this.#dropBox.appendChild(newBox); // remove drop-able property from target dropzone this.#dropBox.removeAttribute(this.#DROP_ZONE_ATTR); // add drop-able property to container box dragged from this.#draggingFrom.setAttribute(this.#DROP_ZONE_ATTR, ""); // also remove accessibility from target dropzone this.#dropBox.removeAttribute("tabindex"); // add accessibilty to the container box is dragged from this.#draggingFrom.setAttribute("tabindex", 0); this.#boxToDrag.remove(); // remove original this.#boxToDrag = newBox; // this.#boxToDrag.focus(); this.onDrop(event); } // Handle mousemove event on draggable box #onMouseMove(event) { if (!this.#boxIsSelected || !this.#boxMouseSelected) return; this.#dragBox(event); } // Handle touchmove event on draggable box #onTouchMove(event) { if (!this.#boxIsSelected || !this.#boxMouseSelected) return; // prevent loading on mobile devices event.preventDefault(); this.#dragBox(event); } // Drag box by updating positions #dragBox(event) { const { clientX, clientY } = event.touches ? event.touches[0] : event; this.#mouseLastCoords = { x: clientX, y: clientY }; // calculate how much to move let mdx, mdy; mdx = clientX - this.#mouse.mx; mdy = clientY - this.#mouse.my; let left = window.scrollX + this.#mouse.mx + mdx - this.#mouse.dx; let top = window.scrollY + this.#mouse.my + mdy - this.#mouse.dy; this.#draggingBox.style.top = top + "px"; this.#draggingBox.style.left = left + "px"; const dropbox = this.#pointerInsideDropbox(); this.#candrop = { value: !!dropbox, dropbox }; this.onDrag(event); } // Move Box with arrow keys #moveBox(direction, event) { const curr_y = parseInt(this.#draggingBox.style.top); const curr_x = parseInt(this.#draggingBox.style.left); const moveBy = DragDrop.MOVE_BY; switch (direction) { case "N": this.#draggingBox.style.top = curr_y - moveBy + "px"; // y - 20 + px break; case "S": this.#draggingBox.style.top = curr_y + moveBy + "px"; break; case "E": this.#draggingBox.style.left = curr_x + moveBy + "px"; // x + 20 + px break; case "W": this.#draggingBox.style.left = curr_x - moveBy + "px"; break; default: break; } const dropbox = this.#elementInsideDropbox(); this.#candrop = { value: !!dropbox, dropbox }; this.onKeyDrag(event) === null ? this.onKeyDrag(event) : this.onDrag(event); } // Set some values and properties onDragStart #setPropertiesOnDragStart(event, target) { this.#boxToDrag = target; this.#draggingFrom = target.parentElement; this.#boxIsFocused = true; this.#boxIsSelected = true; this.#boxMouseSelected = true; const { clientX, clientY } = event.touches ? event.touches[0] : event; // update mouse coords this.#mouseLastCoords = { x: clientX, y: clientY }; // identify dropzones this.#dropZones = Array.from( document.querySelectorAll(`[${this.#DROP_ZONE_ATTR}]`) ); } // save Starting points of mouse and dragging box. Helps while mouseMove #setInitialCoords(event) { const { left, top } = this.#boxToDrag.getBoundingClientRect(); if (event) { var { clientX, clientY } = event.touches ? event.touches[0] : event; } // else { var clientX = left; var clientY = top; } this.#mouse = { mx: clientX, my: clientY, boxX: left, boxY: top, dx: clientX - left, dy: clientY - top, }; } // Get container to drop. When dragging with touch or mouse #pointerInsideDropbox() { let { x: pointerX, y: pointerY } = this.#mouseLastCoords; let dropbox = null; this.#dropZones.forEach((zone) => { let { top, bottom, left, right } = zone.getBoundingClientRect(); if ( pointerY >= top && pointerY <= bottom && pointerX >= left && pointerX <= right ) dropbox = zone; }); return dropbox; } // Get container to drop. When dragging with KeyEvents #elementInsideDropbox() { const { top, left, width, height } = this.#draggingBox.getBoundingClientRect(); const boxCenterX = left + width / 2; const boxCenterY = top + height / 2; let dropbox = null; this.#dropZones.forEach((zone) => { let boundRect = zone.getBoundingClientRect(); if ( boxCenterY >= boundRect.top && boxCenterY <= boundRect.bottom && boxCenterX >= boundRect.left && boxCenterX <= boundRect.right ) dropbox = zone; }); return dropbox; } // Brings an element into viewport if its not #keepElementInViewport(ele) { const boundingRect = ele.getBoundingClientRect(); // Width - Height of the viewport in a cross-browser compatible way. // viewportWidth const VW = window.innerWidth || document.documentElement.clientWidth; // viewportHeight const VH = window.innerHeight || document.documentElement.clientHeight; const isElementInViewport = boundingRect.bottom > 0 && boundingRect.right > 0 && boundingRect.left < VW && boundingRect.top < VH; if (!isElementInViewport) { ele.scrollIntoView({ block: "center", behavior: "smooth" }); } } // ABSTRACTION // values to read get droppable() { return this.#candrop; } get draggingBox() { return this.#draggingBox; } get boxToDrag() { return this.#boxToDrag; } // Set Drag-Boxes and DropZones get setDragDropElements() { return this.#setDragDropElements; } // Add Event Listners get addEventListners() { return this.#addEventListners; } // Remove Event Listners get removeEventListners() { return this.#removeEventListners; } // For additional functionality onDragStart = () => null; onDrag = () => null; onKeyDrag = () => null; onDragEnd = () => null; onDrop = () => null; onTabKeypress = () => null; }