TryGhost/Ghost

View on GitHub
ghost/admin/app/modifiers/movable.js

Summary

Maintainability
C
1 day
Test Coverage
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});
    }
}