src/Gesto.ts

import { Client, OnDrag, GestoOptions, GestoEvents } from "./types";
import {
    getEventClients, isMouseEvent, isMultiTouch,
} from "./utils";
import EventEmitter, { TargetParam } from "@scena/event-emitter";
import { addEvent, removeEvent, now, IObject, getWindow, isWindow } from "@daybrush/utils";
import { ClientStore } from "./ClientStore";

const INPUT_TAGNAMES = ["textarea", "input"];
/**
 * You can set up drag, pinch events in any browser.
 */
class Gesto extends EventEmitter<GestoEvents> {
    public options: GestoOptions = {};

    private flag = false;
    private pinchFlag = false;
    private data: IObject<any> = {};
    private isDrag = false;
    private isPinch = false;

    private clientStores: ClientStore[] = [];
    private targets: Array<Element | Window> = [];
    private prevTime: number = 0;
    private doubleFlag: boolean = false;
    private _useMouse = false;
    private _useTouch = false;
    private _useDrag = false;
    private _dragFlag = false;
    private _isTrusted = false;
    private _isMouseEvent = false;
    private _isSecondaryButton = false;
    private _preventMouseEvent = false;
    private _prevInputEvent: any = null;
    private _isDragAPI = false;
    private _isIdle = true;
    private _preventMouseEventId = 0;
    private _window: WindowProxy = window;

    /**
     *
     */
    constructor(targets: Array<Element | Window> | Element | Window, options: GestoOptions = {}) {
        super();
        const elements = [].concat(targets as any) as Array<Element | Window>;
        const firstTarget = elements[0];

        this._window = isWindow(firstTarget) ? firstTarget : getWindow(firstTarget);
        this.options = {
            checkInput: false,
            container: firstTarget && !("document" in firstTarget)  ? getWindow(firstTarget) : firstTarget,
            preventRightClick: true,
            preventWheelClick: true,
            preventClickEventOnDragStart: false,
            preventClickEventOnDrag: false,
            preventClickEventByCondition: null,
            preventDefault: true,
            checkWindowBlur: false,
            keepDragging: false,
            pinchThreshold: 0,
            events: ["touch", "mouse"],
            ...options,
        };

        const { container, events, checkWindowBlur } = this.options;

        this._useDrag = events!.indexOf("drag") > -1;
        this._useTouch = events!.indexOf("touch") > -1;
        this._useMouse = events!.indexOf("mouse") > -1;
        this.targets = elements;

        if (this._useDrag) {
            elements.forEach(el => {
                addEvent(el, "dragstart", this.onDragStart);
            });
        }
        if (this._useMouse) {
            elements.forEach(el => {
                addEvent(el, "mousedown", this.onDragStart);
                addEvent(el, "mousemove", this._passCallback);
            });
            addEvent(container!, "contextmenu", this._onContextMenu);
        }
        if (checkWindowBlur) {
            addEvent(getWindow(), "blur", this.onBlur);
        }
        if (this._useTouch) {
            const passive = {
                passive: false,
            };
            elements.forEach(el => {
                addEvent(el, "touchstart", this.onDragStart, passive);
                addEvent(el, "touchmove", this._passCallback, passive);
            });
        }
    }
    /**
     * Stop Gesto's drag events.
     */
    public stop() {
        this.isDrag = false;
        this.data = {};
        this.clientStores = [];
        this.pinchFlag = false;
        this.doubleFlag = false;
        this.prevTime = 0;
        this.flag = false;
        this._isIdle = true;

        this._allowClickEvent();
        this._dettachDragEvent();
        this._isDragAPI = false;
    }
    /**
     * The total moved distance
     */
    public getMovement(clients?: Client[]) {
        return this.getCurrentStore().getMovement(clients) + this.clientStores.slice(1).reduce((prev, cur) => {
            return prev + cur.movement;
        }, 0);
    }
    /**
     * Whether to drag
     */
    public isDragging(): boolean {
        return this.isDrag;
    }
    /**
     * Whether the operation of gesto is finished and is in idle state
     */
    public isIdle(): boolean {
        return this._isIdle;
    }
    /**
     * Whether to start drag
     */
    public isFlag(): boolean {
        return this.flag;
    }
    /**
     * Whether to start pinch
     */
    public isPinchFlag() {
        return this.pinchFlag;
    }
    /**
     * Whether to start double click
     */
    public isDoubleFlag() {
        return this.doubleFlag;
    }
    /**
     * Whether to pinch
     */
    public isPinching() {
        return this.isPinch;
    }

    /**
     * If a scroll event occurs, it is corrected by the scroll distance.
     */
    public scrollBy(deltaX: number, deltaY: number, e: any, isCallDrag: boolean = true) {
        if (!this.flag) {
            return;
        }
        this.clientStores[0].move(deltaX, deltaY);
        isCallDrag && this.onDrag(e, true);
    }
    /**
     * Create a virtual drag event.
     */
    public move([deltaX, deltaY]: number[], inputEvent: any): TargetParam<OnDrag> {
        const store = this.getCurrentStore();
        const nextClients = store.prevClients;

        return this.moveClients(nextClients.map(({ clientX, clientY }) => {
            return {
                clientX: clientX + deltaX,
                clientY: clientY + deltaY,
                originalClientX: clientX,
                originalClientY: clientY,
            };
        }), inputEvent, true);
    }
    /**
     * The dragStart event is triggered by an external event.
     */
    public triggerDragStart(e: any) {
        this.onDragStart(e, false);
    }
    /**
     * Set the event data while dragging.
     */
    public setEventData(data: IObject<any>) {
        const currentData = this.data;

        for (const name in data) {
            currentData[name] = data[name];
        }
        return this;
    }
    /**
     * Set the event data while dragging.
     * Use `setEventData`
     * @deprecated
     */
    public setEventDatas(data: IObject<any>) {
        return this.setEventData(data);
    }
    /**
     * Get the current event state while dragging.
     */
    public getCurrentEvent(inputEvent: any = this._prevInputEvent) {
        return {
            data: this.data,
            datas: this.data,
            ...this._getPosition(),
            movement: this.getMovement(),
            isDrag: this.isDrag,
            isPinch: this.isPinch,
            isScroll: false,
            inputEvent,
        };
    }
    /**
     * Get & Set the event data while dragging.
     */
    public getEventData() {
        return this.data;
    }
    /**
     * Get & Set the event data while dragging.
     * Use getEventData method
     * @depreacated
     */
    public getEventDatas() {
        return this.data;
    }
    /**
     * Unset Gesto
     */
    public unset() {
        const targets = this.targets;
        const container = this.options.container!;

        this.off();
        removeEvent(this._window, "blur", this.onBlur);

        if (this._useDrag) {
            targets.forEach(el => {
                removeEvent(el, "dragstart", this.onDragStart);
            });
        }
        if (this._useMouse) {
            targets.forEach(target => {
                removeEvent(target, "mousedown", this.onDragStart);
            });
            removeEvent(container, "contextmenu", this._onContextMenu);
        }
        if (this._useTouch) {
            targets.forEach(target => {
                removeEvent(target, "touchstart", this.onDragStart);
            });
            removeEvent(container, "touchstart", this.onDragStart);
        }
        this._prevInputEvent = null;
        this._allowClickEvent();
        this._dettachDragEvent();
    }
    public onDragStart = (e: any, isTrusted = true) => {
        if (!this.flag && e.cancelable === false) {
            return;
        }
        const isDragAPI = e.type.indexOf("drag") >= -1;

        if (this.flag && isDragAPI) {
            return;
        }

        this._isDragAPI = true;
        const {
            container,
            pinchOutside,
            preventWheelClick,
            preventRightClick,
            preventDefault,
            checkInput,
            dragFocusedInput,
            preventClickEventOnDragStart,
            preventClickEventOnDrag,
            preventClickEventByCondition,
        } = this.options;
        const useTouch = this._useTouch;
        const isDragStart = !this.flag;

        this._isSecondaryButton = e.which === 3 || e.button === 2;

        if (
            (preventWheelClick && (e.which === 2 || e.button === 1))
            || (preventRightClick && (e.which === 3 || e.button === 2))
        ) {
            this.stop();
            return false;
        }

        if (isDragStart) {
            const activeElement = this._window.document.activeElement as HTMLElement;
            const target = e.target as HTMLElement;

            if (target) {
                const tagName = target.tagName.toLowerCase();
                const hasInput = INPUT_TAGNAMES.indexOf(tagName) > -1;
                const hasContentEditable = target.isContentEditable;

                if (hasInput || hasContentEditable) {
                    if (checkInput || (!dragFocusedInput && activeElement === target)) {
                        // force false or already focused.
                        return false;
                    }
                    // no focus
                    if (activeElement && (
                        activeElement === target
                        || (hasContentEditable && activeElement.isContentEditable && activeElement.contains(target))
                    )) {
                        if (dragFocusedInput) {
                            target.blur();
                        } else {
                            return false;
                        }
                    }
                } else if ((preventDefault || e.type === "touchstart") && activeElement) {
                    const activeTagName = activeElement.tagName.toLowerCase();

                    if (activeElement.isContentEditable || INPUT_TAGNAMES.indexOf(activeTagName) > -1) {
                        activeElement.blur();
                    }
                }

                if (preventClickEventOnDragStart || preventClickEventOnDrag || preventClickEventByCondition) {
                    addEvent(this._window, "click", this._onClick, true);
                }
            }
            this.clientStores = [new ClientStore(getEventClients(e))];
            this._isIdle = false;
            this.flag = true;
            this.isDrag = false;
            this._isTrusted = isTrusted;
            this._dragFlag = true;
            this._prevInputEvent = e;
            this.data = {};

            this.doubleFlag = now() - this.prevTime < 200;
            this._isMouseEvent = isMouseEvent(e);
            if (!this._isMouseEvent && this._preventMouseEvent) {
                this._allowMouseEvent();
            }

            const result = this._preventMouseEvent || this.emit("dragStart", {
                data: this.data,
                datas: this.data,
                inputEvent: e,
                isMouseEvent: this._isMouseEvent,
                isSecondaryButton: this._isSecondaryButton,
                isTrusted,
                isDouble: this.doubleFlag,
                ...this.getCurrentStore().getPosition(),
                preventDefault() {
                    e.preventDefault();
                },
                preventDrag: () => {
                    this._dragFlag = false;
                },
            });
            if (result === false) {
                this.stop();
            }
            if (this._isMouseEvent && this.flag && preventDefault) {
                e.preventDefault();
            }
        }
        if (!this.flag) {
            return false;
        }
        let timer = 0;

        if (isDragStart) {
            this._attchDragEvent();

            // wait pinch
            if (useTouch && pinchOutside) {
                timer = setTimeout(() => {
                    addEvent(container!, "touchstart", this.onDragStart, {
                        passive: false
                    });
                });
            }
        } else if (useTouch && pinchOutside) {
            // pinch is occured
            removeEvent(container!, "touchstart", this.onDragStart);
        }
        if (this.flag && isMultiTouch(e)) {
            clearTimeout(timer);
            if (isDragStart && (e.touches.length !== e.changedTouches.length)) {
                return;
            }
            if (!this.pinchFlag) {
                this.onPinchStart(e);
            }
        }

    }
    public onDrag = (e: any, isScroll?: boolean) => {
        if (!this.flag) {
            return;
        }
        const {
            preventDefault,
        } = this.options;
        if (!this._isMouseEvent && preventDefault) {
            e.preventDefault();
        }
        this._prevInputEvent = e;
        const clients = getEventClients(e);
        const result = this.moveClients(clients, e, false);

        if (this._dragFlag) {
            if (this.pinchFlag || result.deltaX || result.deltaY) {
                const dragResult = this._preventMouseEvent || this.emit("drag", {
                    ...result,
                    isScroll: !!isScroll,
                    inputEvent: e,
                });

                if (dragResult === false) {
                    this.stop();
                    return;
                }
            }
            if (this.pinchFlag) {
                this.onPinch(e, clients);
            }
        }

        this.getCurrentStore().getPosition(clients, true);
    }
    public onDragEnd = (e?: any) => {
        if (!this.flag) {
            return;
        }
        const {
            pinchOutside,
            container,
            preventClickEventOnDrag,
            preventClickEventOnDragStart,
            preventClickEventByCondition,
        } = this.options;
        const isDrag = this.isDrag;

        if (preventClickEventOnDrag || preventClickEventOnDragStart || preventClickEventByCondition) {
            requestAnimationFrame(() => {
                this._allowClickEvent();
            });
        }
        if (!preventClickEventByCondition && !preventClickEventOnDragStart && preventClickEventOnDrag && !isDrag) {
            this._allowClickEvent();
        }

        if (this._useTouch && pinchOutside) {
            removeEvent(container!, "touchstart", this.onDragStart);
        }
        if (this.pinchFlag) {
            this.onPinchEnd(e);
        }
        const clients = e?.touches ? getEventClients(e) : [];
        const clientsLength = clients.length;

        if (clientsLength === 0 || !this.options.keepDragging) {
            this.flag = false;
        } else {
            this._addStore(new ClientStore(clients));
        }


        const position = this._getPosition();
        const currentTime = now();
        const isDouble = !isDrag && this.doubleFlag;

        this._prevInputEvent = null;
        this.prevTime = isDrag || isDouble ? 0 : currentTime;

        if (!this.flag) {
            this._dettachDragEvent();

            this._preventMouseEvent || this.emit("dragEnd", {
                data: this.data,
                datas: this.data,
                isDouble,
                isDrag: isDrag,
                isClick: !isDrag,
                isMouseEvent: this._isMouseEvent,
                isSecondaryButton: this._isSecondaryButton,
                inputEvent: e,
                isTrusted: this._isTrusted,
                ...position,
            });

            this.clientStores = [];

            if (!this._isMouseEvent) {
                this._preventMouseEvent = true;

                // Prevent the problem of touch event and mouse event occurring simultaneously
                clearTimeout(this._preventMouseEventId);
                this._preventMouseEventId = setTimeout(() => {
                    this._preventMouseEvent = false;
                }, 200);
            }
            this._isIdle = true;
        }
    }
    public onPinchStart(e: TouchEvent) {
        const { pinchThreshold } = this.options;

        if (this.isDrag && this.getMovement() > pinchThreshold!) {
            return;
        }
        const store = new ClientStore(getEventClients(e));

        this.pinchFlag = true;
        this._addStore(store);

        const result = this.emit("pinchStart", {
            data: this.data,
            datas: this.data,
            angle: store.getAngle(),
            touches: this.getCurrentStore().getPositions(),
            ...store.getPosition(),
            inputEvent: e,
            isTrusted: this._isTrusted,
            preventDefault() {
                e.preventDefault();
            },
            preventDrag: () => {
                this._dragFlag = false;
            },
        });

        if (result === false) {
            this.pinchFlag = false;
        }
    }
    public onPinch(e: TouchEvent, clients: Client[]) {
        if (!this.flag || !this.pinchFlag || clients.length < 2) {
            return;
        }

        const store = this.getCurrentStore();
        this.isPinch = true;

        this.emit("pinch", {
            data: this.data,
            datas: this.data,
            movement: this.getMovement(clients),
            angle: store.getAngle(clients),
            rotation: store.getRotation(clients),
            touches: store.getPositions(clients),
            scale: store.getScale(clients),
            distance: store.getDistance(clients),
            ...store.getPosition(clients),
            inputEvent: e,
            isTrusted: this._isTrusted,
        });
    }
    public onPinchEnd(e: TouchEvent) {
        if (!this.pinchFlag) {
            return;
        }
        const isPinch = this.isPinch;

        this.isPinch = false;
        this.pinchFlag = false;
        const store = this.getCurrentStore();
        this.emit("pinchEnd", {
            data: this.data,
            datas: this.data,
            isPinch,
            touches: store.getPositions(),
            ...store.getPosition(),
            inputEvent: e,
        });
    }
    private getCurrentStore() {
        return this.clientStores[0];
    }
    private moveClients(clients: Client[], inputEvent: any, isAdd: boolean): TargetParam<OnDrag> {
        const position = this._getPosition(clients, isAdd);

        const isPrevDrag = this.isDrag;

        if (position.deltaX || position.deltaY) {
            this.isDrag = true;
        }
        let isFirstDrag = false;

        if (!isPrevDrag && this.isDrag) {
            isFirstDrag = true;
        }

        return {
            data: this.data,
            datas: this.data,
            ...position,
            movement: this.getMovement(clients),
            isDrag: this.isDrag,
            isPinch: this.isPinch,
            isScroll: false,
            isMouseEvent: this._isMouseEvent,
            isSecondaryButton: this._isSecondaryButton,
            inputEvent,
            isTrusted: this._isTrusted,
            isFirstDrag,
        };
    }
    private onBlur = () => {
        this.onDragEnd();
    }
    private _addStore(store: ClientStore) {
        this.clientStores.splice(0, 0, store);
    }
    private _getPosition(clients?: Client[], isAdd?: boolean) {
        const store = this.getCurrentStore();
        const position = store.getPosition(clients, isAdd);

        const { distX, distY } = this.clientStores.slice(1).reduce((prev, cur) => {
            const storePosition = cur.getPosition();

            prev.distX += storePosition.distX;
            prev.distY += storePosition.distY;
            return prev;
        }, position);

        return {
            ...position,
            distX,
            distY,
        };
    }
    private _allowClickEvent = () => {
        removeEvent(this._window, "click", this._onClick, true);
    };
    private _attchDragEvent() {
        const win = this._window;
        const container = this.options.container!;
        const passive = {
            passive: false
        };

        if (this._isDragAPI) {
            addEvent(container, "dragover", this.onDrag, passive);
            addEvent(win, "dragend", this.onDragEnd);
        }
        if (this._useMouse) {
            addEvent(container, "mousemove", this.onDrag);
            addEvent(win, "mouseup", this.onDragEnd);
        }

        if (this._useTouch) {
            addEvent(container, "touchmove", this.onDrag, passive);
            addEvent(win, "touchend", this.onDragEnd, passive);
            addEvent(win, "touchcancel", this.onDragEnd, passive);
        }
    };
    private _dettachDragEvent() {
        const win = this._window;
        const container = this.options.container!;

        if (this._isDragAPI) {
            removeEvent(container, "dragover", this.onDrag);
            removeEvent(win, "dragend", this.onDragEnd);
        }
        if (this._useMouse) {
            removeEvent(container, "mousemove", this.onDrag);
            removeEvent(win, "mouseup", this.onDragEnd);
        }

        if (this._useTouch) {
            removeEvent(container, "touchstart", this.onDragStart);
            removeEvent(container, "touchmove", this.onDrag);
            removeEvent(win, "touchend", this.onDragEnd);
            removeEvent(win, "touchcancel", this.onDragEnd);
        }
    };
    private _onClick = (e: MouseEvent) => {
        this._allowClickEvent();
        this._allowMouseEvent();

        const preventClickEventByCondition = this.options.preventClickEventByCondition;
        if (preventClickEventByCondition?.(e)) {
            return;
        }
        e.stopPropagation();
        e.preventDefault();
    }
    private _onContextMenu = (e: MouseEvent) => {
        const options = this.options;
        if (!options.preventRightClick) {
            e.preventDefault();
        } else {
            this.onDragEnd(e);
        }
    }
    private _allowMouseEvent() {
        this._preventMouseEvent = false;
        clearTimeout(this._preventMouseEventId);
    }
    private _passCallback = () => { };
}

export default Gesto;