import Modifier from 'ember-modifier'; import {action} from '@ember/object'; import {guidFor} from '@ember/object/internals'; import {registerDestructor} from '@ember/destroyable'; import {inject as service} from '@ember/service'; export default class MovableModifier extends Modifier { @service dropdown; moveThreshold = 3; active = false; currentX = undefined; currentY = undefined; initialX = undefined; initialY = undefined; xOffset = 0; yOffset = 0; constructor(owner, args) { super(owner, args); registerDestructor(this, this.cleanup); } // Lifecycle hooks --------------------------------------------------------- modify(element, positional, named) { if (!this.didSetup) { this.elem = element; this.addStartEventListeners(); if (named.adjustOnResize) { this._resizeObserver = new ResizeObserver(() => { if (this.currentX === undefined || this.currentY === undefined) { return; } const {x, y} = named.adjustOnResize(element, {x: this.currentX, y: this.currentY}); if (x === this.currentX && y === this.currentY) { return; } this.currentX = x; this.initialX = x; this.xOffset = x; this.currentY = y; this.initialY = y; this.yOffset = y; this.setTranslate(x, y); }); this._resizeObserver.observe(element); } this.didSetup = true; } } cleanup = () => { this.removeEventListeners(); this.removeResizeObserver(); this.enableSelection(); }; // Custom methods ----------------------------------------------------------- addStartEventListeners() { this.elem.addEventListener('touchstart', this.dragStart, false); this.elem.addEventListener('mousedown', this.dragStart, false); } removeStartEventListeners() { this.elem.removeEventListener('touchstart', this.dragStart, false); this.elem.removeEventListener('mousedown', this.dragStart, false); } addActiveEventListeners() { window.addEventListener('touchend', this.dragEnd, {capture: true, passive: false}); window.addEventListener('touchmove', this.drag, {capture: true, passive: false}); window.addEventListener('mouseup', this.dragEnd, {capture: true, passive: false}); window.addEventListener('mousemove', this.drag, {capture: true, passive: false}); } removeActiveEventListeners() { window.removeEventListener('touchend', this.dragEnd, {capture: true, passive: false}); window.removeEventListener('touchmove', this.drag, {capture: true, passive: false}); window.removeEventListener('mouseup', this.dragEnd, {capture: true, passive: false}); window.removeEventListener('mousemove', this.drag, {capture: true, passive: false}); // Removing this immediately results in the click event behind re-enabled in the same // event loop meaning that it doesn't have the desired effect when dragging out of the canvas. // Putting in the next tick stops the immediate click event firing when finishing drag setTimeout(() => { window.removeEventListener('click', this.cancelClick, {capture: true, passive: false}); }, 1); } removeEventListeners() { this.removeStartEventListeners(); this.removeActiveEventListeners(); } removeResizeObserver() { this._resizeObserver?.disconnect(); } @action dragStart(e) { if (e.type === 'touchstart' || e.button === 0) { if (e.type === 'touchstart') { this.initialX = e.touches[0].clientX - this.xOffset; this.initialY = e.touches[0].clientY - this.yOffset; } else { this.initialX = e.clientX - this.xOffset; this.initialY = e.clientY - this.yOffset; } for (const elem of (e.path || e.composedPath())) { if (elem.matches('input, .ember-basic-dropdown-trigger')) { break; } if (elem === this.elem) { this.addActiveEventListeners(); break; } } } } @action drag(e) { e.preventDefault(); let eventX, eventY; if (e.type === 'touchmove') { eventX = e.touches[0].clientX; eventY = e.touches[0].clientY; } else { eventX = e.clientX; eventY = e.clientY; } if (!this.active) { if ( Math.abs(Math.abs(this.initialX - eventX) - Math.abs(this.xOffset)) > this.moveThreshold || Math.abs(Math.abs(this.initialY - eventY) - Math.abs(this.yOffset)) > this.moveThreshold ) { this.dropdown.closeDropdowns(); this.disableScroll(); this.disableSelection(); this.disablePointerEvents(); this.active = true; } } if (this.active) { this.currentX = eventX - this.initialX; this.currentY = eventY - this.initialY; this.xOffset = this.currentX; this.yOffset = this.currentY; this.setTranslate(this.currentX, this.currentY); } } @action dragEnd(e) { e.preventDefault(); e.stopPropagation(); this.active = false; this.initialX = this.currentX; this.initialY = this.currentY; this.removeActiveEventListeners(); this.enableScroll(); this.enableSelection(); // timeout required so immediate events blocked until the dragEnd has fully realised setTimeout(() => { this.enablePointerEvents(); }, 5); } @action cancelClick(e) { e.preventDefault(); e.stopPropagation(); } setTranslate(xPos, yPos) { this.elem.style.transform = `translate3d(${xPos}px, ${yPos}px, 0)`; } disableScroll() { this.originalOverflow = this.elem.style.overflow; this.elem.style.overflow = 'hidden'; } enableScroll() { this.elem.style.overflow = this.originalOverflow; } disableSelection() { window.getSelection().removeAllRanges(); const stylesheet = document.createElement('style'); stylesheet.id = `stylesheet-${guidFor(this)}`; document.head.appendChild(stylesheet); stylesheet.sheet.insertRule('* { user-select: none !important; }', 0); } enableSelection() { const stylesheet = document.getElementById(`stylesheet-${guidFor(this)}`); stylesheet?.remove(); } // disabling pointer events prevents inputs being activated when drag finishes, // preventing clicks stops any event handlers that may otherwise result in the // movable element being closed when the drag finishes disablePointerEvents() { this.elem.style.pointerEvents = 'none'; window.addEventListener('click', this.cancelClick, {capture: true, passive: false}); } enablePointerEvents() { this.elem.style.pointerEvents = ''; window.removeEventListener('click', this.cancelClick, {capture: true, passive: false}); } }