src/DragScroll.ts

import EventEmitter from "@scena/event-emitter";
import { isFunction, isString, now } from "@daybrush/utils";
import { CheckScrollOptions, DragScrollEvents, DragScrollOptions, Rect } from "./types";

function getDefaultScrollPosition(e: { container: HTMLElement, direction: number[] }) {
    let container = e.container;

    if (container === document.body) {
        return [
            container.scrollLeft || document.documentElement.scrollLeft,
            container.scrollTop || document.documentElement.scrollTop,
        ];
    }
    return [
        container.scrollLeft,
        container.scrollTop,
    ];
}

function checkDefaultScrollEvent(container: HTMLElement | Window, callback: () => void) {
    container.addEventListener("scroll", callback);

    return () => {
        container.removeEventListener("scroll", callback);
    }
}

function getContainerElement(container: DragScrollOptions["container"]): HTMLElement {
    if (!container) {
        return null;
    } else if (isString(container)) {
        return document.querySelector<HTMLElement>(container);
    } if (isFunction(container)) {
        return container();
    } else if (container instanceof Element) {
        return container;
    } else if ("current" in container) {
        return container.current;
    } else if ("value" in container) {
        return container.value;
    }
}

/**
 * @sort 1
 */
class DragScroll extends EventEmitter<DragScrollEvents> {
    private _startRect: Rect | null = null;
    private _startPos: number[] = [];
    private _prevTime: number = 0;
    private _timer: number = 0;
    private _prevScrollPos: number[] = [0, 0];
    private _isWait = false;
    private _flag = false;
    private _currentOptions: DragScrollOptions | null = null;
    private _lock = false;
    private _unregister: (() => void) | null = null;
    /**
     */
    public dragStart(e: any, options: DragScrollOptions) {
        const container = getContainerElement(options.container);

        if (!container) {
            this._flag = false;
            return;
        }
        let top = 0;
        let left = 0;
        let width = 0;
        let height = 0;

        if (container === document.body) {
            width = window.innerWidth;
            height = window.innerHeight;
        } else {
            const rect = container.getBoundingClientRect();

            top = rect.top;
            left = rect.left;
            width = rect.width;
            height = rect.height;
        }

        this._flag = true;
        this._startPos = [e.clientX, e.clientY];
        this._startRect = { top, left, width, height };
        this._prevScrollPos = this._getScrollPosition([0, 0], options);
        this._currentOptions = options;
        this._registerScrollEvent(options);
    }
    public drag(e: any, options: DragScrollOptions) {
        clearTimeout(this._timer);
        if (!this._flag) {
            return;
        }
        const {
            clientX,
            clientY,
        } = e;
        const {
            threshold = 0,
        } = options;
        const {
            _startRect,
            _startPos,
        } = this;


        this._currentOptions = options;
        const direction = [0, 0];

        if (_startRect.top > clientY - threshold) {
            if (_startPos[1] > _startRect.top || clientY < _startPos[1]) {
                direction[1] = -1;
            }
        } else if (_startRect.top + _startRect.height < clientY + threshold) {
            if (_startPos[1] < _startRect.top + _startRect.height || clientY > _startPos[1]) {
                direction[1] = 1;
            }
        }
        if (_startRect.left > clientX - threshold) {
            if (_startPos[0] > _startRect.left || clientX < _startPos[0]) {
                direction[0] = -1;
            }
        } else if (_startRect.left + _startRect.width < clientX + threshold) {
            if (_startPos[0] < _startRect.left + _startRect.width || clientX > _startPos[0]) {
                direction[0] = 1;
            }
        }

        if (!direction[0] && !direction[1]) {
            return false;
        }
        return this._continueDrag({
            ...options,
            direction,
            inputEvent: e,
            isDrag: true,
        });
    }
    /**
     */
    public checkScroll(options: CheckScrollOptions) {
        if (this._isWait) {
            return false;
        }
        const {
            prevScrollPos = this._prevScrollPos,
            direction,
            throttleTime = 0,
            inputEvent,
            isDrag,
        } = options;
        const nextScrollPos = this._getScrollPosition(direction || [0, 0], options);
        const offsetX = nextScrollPos[0] - prevScrollPos[0];
        const offsetY = nextScrollPos[1] - prevScrollPos[1];

        const nextDirection = direction || [
            offsetX ? Math.abs(offsetX) / offsetX : 0,
            offsetY ? Math.abs(offsetY) / offsetY : 0,
        ];
        this._prevScrollPos = nextScrollPos;
        this._lock = false;

        if (!offsetX && !offsetY) {
            return false;
        }
        /**
         * @event DragScroll#move
         */
        this.emit("move", {
            offsetX: nextDirection[0] ? offsetX : 0,
            offsetY: nextDirection[1] ? offsetY : 0,
            inputEvent,
        });

        if (throttleTime && isDrag) {
            clearTimeout(this._timer);
            this._timer = window.setTimeout(() => {
                this._continueDrag(options);
            }, throttleTime);
        }
        return true;
    }
    /**
     *
     */
    public dragEnd() {
        this._flag = false;
        this._lock = false;
        clearTimeout(this._timer);
        this._unregisterScrollEvent();
    }
    private _getScrollPosition(direction: number[], options: DragScrollOptions) {
        const {
            container,
            getScrollPosition = getDefaultScrollPosition,
        } = options;
        return getScrollPosition({ container: getContainerElement(container), direction });
    }
    private _continueDrag(options: CheckScrollOptions) {
        const {
            container,
            direction,
            throttleTime,
            useScroll,
            isDrag,
            inputEvent,
        } = options;

        if (!this._flag || (isDrag && this._isWait)) {
            return;
        }
        const nowTime = now();
        const distTime = Math.max(throttleTime + this._prevTime - nowTime, 0);

        if (distTime > 0) {
            clearTimeout(this._timer);
            this._timer = window.setTimeout(() => {
                this._continueDrag(options);
            }, distTime);

            return false;
        }
        this._prevTime = nowTime;
        const prevScrollPos = this._getScrollPosition(direction, options);

        this._prevScrollPos = prevScrollPos;

        if (isDrag) {
            this._isWait = true;
        }

        // unregister native scroll event
        if (!useScroll) {
            this._lock = true;
        }
        const param = {
            container: getContainerElement(container),
            direction,
            inputEvent,
        };
        options.requestScroll?.(param);
        /**
         * @event DragScroll#scroll
         */
        this.emit("scroll", param);

        this._isWait = false;
        return useScroll || this.checkScroll({
            ...options,
            prevScrollPos,
            direction,
            inputEvent,
        });
    }

    private _registerScrollEvent(options: DragScrollOptions) {
        this._unregisterScrollEvent();
        const checkScrollEvent = options.checkScrollEvent;

        if (!checkScrollEvent) {
            return;
        }
        const callback = checkScrollEvent === true ? checkDefaultScrollEvent : checkScrollEvent;
        const container = getContainerElement(options.container);

        if (checkScrollEvent === true && (container === document.body || container === document.documentElement)) {
            this._unregister = checkDefaultScrollEvent(window, this._onScroll);
        } else {
            this._unregister = callback(container, this._onScroll);
        }
    }
    private _unregisterScrollEvent() {
        this._unregister?.();
        this._unregister = null;
    }

    private _onScroll = () => {
        const options = this._currentOptions;
        if (this._lock || !options) {
            return;
        }

        this.emit("scrollDrag", {
            next: (inputEvent: any) => {
                this.checkScroll({
                    container: options.container,
                    inputEvent,
                });
            },
        });
    };
}

export default DragScroll;