Source: utils/gestures.js

/**
 * @namespace qui.utils.gestures
 */

import $ from '$qui/lib/jquery.module.js'

import * as CSS    from '$qui/utils/css.js'
import * as Window from '$qui/window.js'


const LONG_PRESS_DATA_KEY = 'qui.utils.gestures.longPress'


/**
 * Drag Move Callback Function.
 * @callback qui.utils.gestures.DragMoveCallback
 * @param {Number} elemX the new element x coordinate, relative to page
 * @param {Number} elemY the new element y coordinate, relative to page
 * @param {Number} deltaX the x coordinate variation, relative to initial drag point
 * @param {Number} deltaY the y coordinate variation, relative to initial drag point
 * @param {Number} pageX the x coordinate, relative to page
 * @param {Number} pageY the y coordinate, relative to page
 */

/**
 * Drag Begin Callback Function.
 * @callback qui.utils.gestures.DragBeginCallback
 * @param {Number} elemX the initial element x coordinate, relative to page
 * @param {Number} elemY the initial element y coordinate, relative to page
 * @param {Number} pageX the x coordinate, relative to page
 * @param {Number} pageY the y coordinate, relative to page
 * @returns {Boolean} `false` to prevent dragging
 */

/**
 * Drag End Callback Function.
 * @callback qui.utils.gestures.DragEndCallback
 * @param {Number} elemX the final element x coordinate, relative to page
 * @param {Number} elemY the final element y coordinate, relative to page
 * @param {Number} deltaX the final x coordinate variation, relative to initial drag point
 * @param {Number} deltaY the final y coordinate variation, relative to initial drag point
 * @param {Number} pageX the x coordinate inside, relative to page
 * @param {Number} pageY the y coordinate inside, relative to page
 */

/**
 * Setup an HTML element for dragging.
 *
 * All coordinates passed to the callbacks are CSS pixels, so they can be compared or combined with *em* units and CSS
 * lengths directly. Use {@link qui.utils.css.unscalePx} on any `offset()` or `position()` value measured against them.
 *
 * @alias qui.utils.gestures.enableDragging
 * @param {jQuery} element the dragged element
 * @param {qui.utils.gestures.DragMoveCallback} onMove
 * @param {qui.utils.gestures.DragBeginCallback} onBegin
 * @param {qui.utils.gestures.DragEndCallback} onEnd
 * @param {?String} [direction] indicates dragging direction: `"x"`, `"y"` or `null` for both; defaults to `null`
 */
export function enableDragging(element, onMove, onBegin, onEnd, direction) {
    let beginPageX = 0, beginPageY = 0
    let beginElemX = 0, beginElemY = 0
    let moveFrameHandle = null
    let pendingMoveArgs = null

    /* A pointer can report far more often than the screen refreshes -- and every one of these callbacks reads and
     * writes layout -- so deliver at most one per frame, carrying the newest position. Dropping the positions in
     * between is safe because onMove is given absolute coordinates and deltas measured from where the drag began,
     * never from the previous move. */
    function deliverMove() {
        moveFrameHandle = null

        let args = pendingMoveArgs
        pendingMoveArgs = null

        if (args && onMove) {
            onMove(...args)
        }
    }

    function flushMove() {
        if (moveFrameHandle != null) {
            window.cancelAnimationFrame(moveFrameHandle)
            deliverMove()
        }
    }

    function pointerDown(e) {
        let elemOffset = element.offset()
        beginElemX = CSS.unscalePx(elemOffset.left)
        beginElemY = CSS.unscalePx(elemOffset.top)

        e.pageX = CSS.unscalePx(e.pageX)
        e.pageY = CSS.unscalePx(e.pageY)

        beginPageX = e.pageX
        beginPageY = e.pageY

        if (onBegin) {
            if (onBegin(beginElemX, beginElemY, e.pageX, e.pageY) === false) {
                return
            }
        }

        Window.$body.on('pointermove', pointerMove)
                    .on('pointerup pointercancel pointerleave', pointerUp)
    }

    function pointerUp(e) {
        Window.$body.off('pointermove', pointerMove)
                    .off('pointerup pointercancel pointerleave', pointerUp)

        /* Deliver whatever the last frame did not get to, so that the sequence of moves a handler sees still ends
         * where the pointer actually was, before it is told the drag is over */
        flushMove()

        e.pageX = CSS.unscalePx(e.pageX)
        e.pageY = CSS.unscalePx(e.pageY)

        if (direction === 'x') { /* Constrain moving to horizontal axis */
            e.pageY = beginPageY
        }
        if (direction === 'y') { /* Constrain moving to vertical axis */
            e.pageX = beginPageX
        }

        let deltaX = e.pageX - beginPageX
        let deltaY = e.pageY - beginPageY

        let elemX = beginElemX + deltaX
        let elemY = beginElemY + deltaY

        beginPageX = beginPageY = 0
        beginElemX = beginElemY = 0

        if (onEnd) {
            onEnd(elemX, elemY, deltaX, deltaY, e.pageX, e.pageY)
        }
    }

    function pointerMove(e) {
        e.pageX = CSS.unscalePx(e.pageX)
        e.pageY = CSS.unscalePx(e.pageY)

        if (direction === 'x') { /* Constrain moving to horizontal axis */
            e.pageY = beginPageY
        }
        if (direction === 'y') { /* Constrain moving to vertical axis */
            e.pageX = beginPageX
        }

        let deltaX = e.pageX - beginPageX
        let deltaY = e.pageY - beginPageY

        let elemX = beginElemX + deltaX
        let elemY = beginElemY + deltaY

        if (onMove) {
            pendingMoveArgs = [elemX, elemY, deltaX, deltaY, e.pageX, e.pageY]
            if (moveFrameHandle == null) {
                moveFrameHandle = window.requestAnimationFrame(deliverMove)
            }
        }

        e.preventDefault()
    }

    element.data('qui.utils.gestures.dragging', {
        pointerDown: pointerDown,
        pointerUp: pointerUp,
        pointerMove: pointerMove
    })

    let touchAction = 'none'
    if (direction === 'x') {
        touchAction = 'pan-y'
    }
    else if (direction === 'y') {
        touchAction = 'pan-x'
    }

    element.css('touch-action', touchAction)
    element.attr('touch-action', touchAction) /* Required for pep.js (on iOS) */
    element.on('pointerdown', pointerDown)
}

/**
 * Disable previously configured dragging support on an HTML element.
 * @alias qui.utils.gestures.disableDragging
 * @param {jQuery} element the dragged element
 */
export function disableDragging(element) {
    let draggingData = element.data('qui.utils.gestures.dragging')
    if (!draggingData) {
        return
    }

    Window.$body.off('pointermove', draggingData.pointerMove)
                .off('pointerup pointercancel pointerleave', draggingData.pointerUp)

    element.css('touch-action', '')
    element.attr('touch-action', '') /* Required for pep.js (on iOS) */
    element.off('pointerdown', draggingData.pointerDown)
}

/**
 * Long Press Callback Function.
 * @callback qui.utils.gestures.LongPressCallback
 * @param {jQuery} element the pressed element
 * @param {jQuery.Event} event the pointer event that started the press
 */

/**
 * Setup long press detection on an HTML element.
 *
 * Detection is based on pointer events, which never block scrolling; a press is automatically abandoned as soon as
 * the browser starts scrolling with the same pointer, or as soon as the pointer moves further than `moveThreshold`.
 *
 * When a `selector` is given, presses are detected for matching descendants of `element` rather than for `element`
 * itself. This allows handling long presses on a large number of children with a single set of event handlers.
 *
 * @alias qui.utils.gestures.enableLongPress
 * @param {jQuery} element the element on which presses are detected
 * @param {qui.utils.gestures.LongPressCallback} onLongPress called when the element has been pressed for at least
 * `duration` milliseconds
 * @param {?qui.utils.gestures.LongPressCallback} [onShortPress] called when the element has been released before
 * `duration` milliseconds have elapsed
 * @param {?String} [selector] an optional selector restricting detection to matching descendants of `element`
 * @param {Number} [duration] how long the element must be pressed for a long press, in milliseconds (defaults to
 * `500`)
 * @param {Number} [moveThreshold] how far the pointer may move before the press is abandoned, in pixels (defaults to
 * `10`)
 */
export function enableLongPress(
    element,
    {onLongPress, onShortPress = null, selector = null, duration = 500, moveThreshold = 10}
) {
    let timeoutHandle = null
    let pressedElement = null
    let pressedPointerId = null
    let startPageX = 0
    let startPageY = 0

    function abandon() {
        if (timeoutHandle != null) {
            clearTimeout(timeoutHandle)
            timeoutHandle = null
        }

        pressedElement = null
        pressedPointerId = null

        Window.$body.off('pointermove', pointerMove)
                    .off('pointerup pointercancel', pointerUp)
    }

    function isPrimaryPointer(e) {
        /* jQuery normalizes pointerId and pointerType, but not isPrimary, which must be read from the original
         * event; events triggered programmatically have no original event and are considered primary */
        return !e.originalEvent || e.originalEvent.isPrimary !== false
    }

    function pointerDown(e) {
        /* Only the primary pointer (first finger, left mouse button) can start a press */
        if (!isPrimaryPointer(e) || (e.pointerType === 'mouse' && e.button !== 0)) {
            return
        }

        abandon() /* Any previous press is implicitly abandoned */

        pressedElement = $(this)
        pressedPointerId = e.pointerId
        startPageX = e.pageX
        startPageY = e.pageY

        timeoutHandle = setTimeout(function () {

            timeoutHandle = null

            let element = pressedElement
            abandon()
            onLongPress(element, e)

        }, duration)

        Window.$body.on('pointermove', pointerMove)
                    .on('pointerup pointercancel', pointerUp)
    }

    function pointerMove(e) {
        /* Ignore any pointer other than the one that started the press */
        if (!pressedElement || e.pointerId !== pressedPointerId) {
            return
        }

        if (Math.abs(e.pageX - startPageX) > moveThreshold || Math.abs(e.pageY - startPageY) > moveThreshold) {
            abandon()
        }
    }

    function pointerUp(e) {
        /* Ignore any pointer other than the one that started the press */
        if (!pressedElement || e.pointerId !== pressedPointerId) {
            return
        }

        /* A pending timeout means the press has been released before becoming a long press */
        let shortPress = timeoutHandle != null

        let element = pressedElement
        abandon()

        if (shortPress && onShortPress) {
            onShortPress(element, e)
        }
    }

    element.data(LONG_PRESS_DATA_KEY, {pointerDown: pointerDown, abandon: abandon, selector: selector})
    element.on('pointerdown', selector, pointerDown)
}

/**
 * Disable previously configured long press support on an HTML element.
 * @alias qui.utils.gestures.disableLongPress
 * @param {jQuery} element the element on which presses are detected
 */
export function disableLongPress(element) {
    let longPressData = element.data(LONG_PRESS_DATA_KEY)
    if (!longPressData) {
        return
    }

    longPressData.abandon()
    element.off('pointerdown', longPressData.selector, longPressData.pointerDown)
    element.removeData(LONG_PRESS_DATA_KEY)
}