components/LogicDragDrop.js

/* global platypus */
import {Container, Rectangle} from 'pixi.js';
import createComponentClass from '../factory.js';

const
    claimHitArea = new Rectangle(-2000, -2000, 4000, 4000),
    allDraggedItems = [];

export default createComponentClass(/** @lends platypus.components.LogicDragDrop.prototype */{
    id: 'LogicDragDrop',
    
    properties: {
        //TODO: Impement Multi-Drag

        /**
         * Sets how far a `pressmove` event can deviate from the original `pointerdown` before a stickyClick becomes unstuck (ie normal drag-drop). `0` means any movement will unstick the click. `10` means the mouse needs to move more than 10 units before the unstick occurs. `stickyClick` must be enabled for this property to matter.
         * 
         * @property stickiness
         * @type Number
         * @default 0
         */
        stickiness: 0,

        /**
         * Sets whether an entity can be dragged at initial. Change via disable-drag().
         *
         * @property dragDisabled
         * @type Boolean
         * @default false
         */
        dragDisabled: false

    },

    publicProperties: {
        /**
         * Whether dragged items must stay within the camera frame.
         * 
         * @property stayInViewport
         * @type Boolean
         * @default false
         */
        stayInViewport: false,

        /**
         * Sets whether a click-move should start the dragging behavior in addition to click-drag. Defaults to `true` on desktop and `false` on mobile devices.
         *
         * @property stickyClick
         * @type Boolean
         * @default undefined
         */
        stickyClick: undefined
    },
    
    /**
     * A component that allows an object to be dragged and dropped. Can use collision to prevent dropping the objects in certain locations.
     *
     * @memberof platypus.components
     * @uses platypus.Component
     * @constructs
     * @listens platypus.Entity#camera-update
     * @listens platypus.Entity#component-added
     * @listens platypus.Entity#handle-logic
     * @listens platypus.Entity#handle-post-collision-logic
     * @listens platypus.Entity#pointerdown
     * @listens platypus.Entity#pointermove
     * @listens platypus.Entity#prepare-logic
     * @listens platypus.Entity#pressmove
     * @listens platypus.Entity#pressup
     */
    initialize: function () {
        this.aabb = this.owner.parent.worldCamera.viewport;
        this.cameraScaleX = 1;
        this.cameraScaleY = 1;
        this.nextX = this.owner.x;
        this.nextY = this.owner.y;
        this.grabOffsetX = 0;
        this.grabOffsetY = 0;
        this.state = this.owner.state;
        this.state.set('dragging', false);
        this.dragId = null;
        this.originalContainer = null
        this.dragContainer = new Container();

        this.dragContainer.zIndex = Infinity;

        if (this.stickyClick === undefined) {
            this.stickyClick = !platypus.supports.mobile;
        }
        this.releaseStick = false;
    },

    events: {
        "camera-update": function ({scaleX, scaleY}) {
            this.cameraScaleX = scaleX;
            this.cameraScaleY = scaleY;
            this.checkCamera();
        },
        
        "prepare-logic": function () {
            this.checkCamera(); // may end dragging
        },

        "handle-logic": function () {
            let defaultCancelled = false;

            if (this.state.get('dragging')) {
                const
                    {dragContainer, nextX, nextY, owner} = this;

                owner.triggerEvent('dragging', {
                    dragContainer,
                    entity: owner,
                    x: nextX,
                    y: nextY,
                    pointer: {
                        x: nextX + this.grabOffsetX,
                        y: nextY + this.grabOffsetY
                    },
                    preventDefault: () => {
                        defaultCancelled = true;
                    }
                });

                if (!defaultCancelled) {
                    owner.x = nextX;
                    owner.y = nextY;
                }
            }
        },

        "pointerdown": function (eventData) {
            if (this.dragDisabled) {
                return;
            }

            if (this.sticking) {
                this.sticking = null;
                this.nextX = eventData.x - this.grabOffsetX;
                this.nextY = eventData.y - this.grabOffsetY;
                if (this.stayInViewport) {
                    this.checkCamera();
                }
                this.releaseStick = true; // Delay release until logic runs in case of collision checks, etc.
            } else {
                const
                    {owner} = this;

                if (this.dragId === null) {
                    const
                        {dragContainer} = this;
                    let defaultCancelled = false;

                    this.dragId = eventData.event.pointerId;

                    owner.triggerEvent('drag-start', {
                        dragContainer,
                        entity: owner,
                        preventDefault: () => {
                            defaultCancelled = true;
                        }
                    });

                    if (defaultCancelled) {
                        this.dragId = null;
                    } else {
                        const
                            {state} = this,
                            {x, y} = owner;

                        this.nextX = x;
                        this.nextY = y;
                        this.grabOffsetX = (eventData.x >> 0) - x;
                        this.grabOffsetY = (eventData.y >> 0) - y;
                        state.set('dragging', true);
                        owner.dragMode = true;
                        this.sticking = this.stickyClick ? {x, y} : null;
                        this.claimPointer();
        
                        // put in top layer
                        this.checkCamera(); //update dragContainer dimensions
                        this.originalContainer = owner.container.parent;
                        dragContainer.addChild(owner.container);
                        owner.parent.stage.addChild(dragContainer);
                        allDraggedItems.push(this);
                    }
                }
            }
            
            eventData.pixiEvent.stopPropagation();
        },

        "pressup": function (eventData) {
            if (this.releaseStick) {
                this.release();
                this.releaseStick = false;
                return;
            }

            if (!this.state.get('dragging')) {
                return;
            }

            if (this.dragId !== null && !this.sticking) {
                this.release();
            }
            
            eventData.pixiEvent.stopPropagation();
        },

        "input-off": function () {
            if (this.state.get('dragging') && this.dragId !== null) {
                this.release();
            }
        },

        "pointermove": function (eventData) {
            if (!this.state.get('dragging')) {
                return;
            }

            if (this.sticking) {
                this.nextX = eventData.x - this.grabOffsetX;
                this.nextY = eventData.y - this.grabOffsetY;
                if (this.stayInViewport) {
                    this.checkCamera();
                }
                
                eventData.event.preventDefault();
                eventData.pixiEvent.stopPropagation();
            }
        },

        "pressmove": function (eventData) {
            if (!this.state.get('dragging')) {
                return;
            }
            
            if (this.dragId !== null) {
                const
                    {stickiness, sticking} = this;

                this.nextX = eventData.x - this.grabOffsetX;
                this.nextY = eventData.y - this.grabOffsetY;
                if (this.stayInViewport) {
                    this.checkCamera();
                }
                if (sticking && (((this.nextX - sticking.x) ** 2) + ((this.nextY - sticking.y) ** 2) > stickiness ** 2)) {
                    this.sticking = null;
                }
                
                eventData.event.preventDefault();
                eventData.pixiEvent.stopPropagation();
            }
        },

        "disable-drag": function (disable) {
            //If we don't send in a value, we toggle the dragDisabled.
            this.dragDisabled = typeof disable === 'boolean' ? disable : !this.dragDisabled;

            if (this.dragDisabled && this.state.get('dragging')) {
                this.release();
            }
        }
    },
    
    methods: {// These are methods that are called by this component.
        checkCamera () {
            const
                {state, stayInViewport} = this,
                dragging = state?.get('dragging');

            if (dragging || stayInViewport) {
                const
                    {aabb} = this;

                if (!aabb.containsPoint(dragging ? this.nextX + this.grabOffsetX : this.owner.x, dragging ? this.nextY + this.grabOffsetY : this.owner.y)) {
                    if (this.stayInViewport) {
                        if (dragging) {
                            this.nextX = Math.min(aabb.right, Math.max(aabb.left, this.nextX + this.grabOffsetX)) - this.grabOffsetX;
                            this.nextY = Math.min(aabb.bottom, Math.max(aabb.top, this.nextY + this.grabOffsetY)) - this.grabOffsetY;
                        } else {
                            this.owner.x = Math.min(aabb.right, Math.max(aabb.left, this.owner.x));
                            this.owner.y = Math.min(aabb.bottom, Math.max(aabb.top, this.owner.y));
                        }
                    } else {
                        this.release();
                    }
                } else { // adjust container if needed.
                    const
                        {cameraScaleX, cameraScaleY, dragContainer} = this;
    
                    dragContainer.scale.x = cameraScaleX;
                    dragContainer.scale.y = cameraScaleY;
                    dragContainer.x = -(aabb.left * cameraScaleX);
                    dragContainer.y = -(aabb.top * cameraScaleY);
                }
            }
        },

        claimPointer () {
            this.lastHitArea = this.owner.container.hitArea;

            this.owner.container.hitArea = claimHitArea; // capture all the clicks!
        },

        releasePointer () {
            this.owner.container.hitArea = this.lastHitArea;
        },

        releaseContainer () {
            const
                {originalContainer, owner} = this,
                index = allDraggedItems.indexOf(this);

            if (index >= 0) {
                allDraggedItems.splice(index, 1);
            }

            if (originalContainer) {
                owner.parent.stage.removeChild(this.dragContainer);
                originalContainer.addChild(owner.container);
                this.originalContainer = null;
            }
        },

        release () {
            const
                {dragContainer, owner, state} = this,
                dropX = owner.x,
                dropY = owner.y;
            let defaultCancelled = false;

            this.sticking = null;

            this.dragId = null;
            state.set('dragging', false);
            owner.dragMode = false;
            this.releasePointer();

            this.releaseContainer();

            owner.triggerEvent('drop', {
                dragContainer,
                entity: owner,
                x: dropX,
                y: dropY,
                preventDefault: () => {
                    defaultCancelled = true;
                }
            });

            if (!defaultCancelled) {
                //do default drop actions here
                owner.x = dropX;
                owner.y = dropY;
                owner.triggerEvent('drop-complete');
            }
        },
        
        destroy () {
            if (this.state.get('dragging')) {
                this.dragId = null;
                this.releaseContainer();
                this.state.set('dragging', false);
            }
            this.owner.dragMode = false;
            this.state = null;
        }
    }
});