Source: view/viewScreens/ClickDrag.js

/** @module */

import { minIndex } from '@paretoman/votekit-utilities'

/**
 * ClickDrag gives draggable behavior to objects on a canvas.
 * If anything changes, an item is added to the "changes" array.
 * Calling screen.setEventHandlers(clickDrag.eventHandlers) sets the eventhandlers on the screen.
 * @param {Screen} screen
 * @param {Changes} changes
 * @constructor
 */
export default function ClickDrag(dragm, viewEntities, screen, changes, viewSettings) {
    const self = this

    // private variables
    let drag = {}
    const draggables = dragm.list

    const grabCanvas = screen.tooltips

    // Mouse Listeners
    // As a sidenote, it is interesting that we don't need to call model.update here
    // because we are using a game loop that will call model.update.
    const start = function (event) {
        // don't interact with stuff underneath a tooltip
        if (event.target.closest('.tooltipBox') !== null) {
            return
        }

        const mouse = getMouse(event)
        const extra = (event.isTouch) ? 10 : 0
        const nd = draggables.length
        // We are in the hitboxes of these draggables.
        const hitList = []
        for (let i = 0; i < nd; i++) {
            const d = draggables[i]
            if ((d.o.exists || viewSettings.showGhosts) && hitTest(d, mouse, extra)) {
                hitList.push(i)
            }
        }
        if (hitList.length > 0) {
            const distances2 = hitList.map((i) => {
                const d = draggables[i]
                const offX = d.r.x - mouse.x
                const offY = d.r.y - mouse.y
                return offX ** 2 + offY ** 2
            })
            // pick up
            const iHitListClosest = minIndex(distances2)
            const iDraggableClosest = hitList[iHitListClosest]

            const d = draggables[iDraggableClosest]
            drag.iDragging = iDraggableClosest
            drag.isDragging = true
            drag.offX = d.r.x - mouse.x
            drag.offY = d.r.y - mouse.y
            d.g.pickUp()
            grabCanvas.dataset.cursor = 'grabbing' // CSS data attribute
        }
        startClickDetect(mouse)
    }

    const move = function (event) {
        const mouse = getMouse(event)
        if (drag.isDragging) { // because the mouse is moving
            if (event.isTouch) {
                event.preventDefault()
                event.stopPropagation()
            }
            const dragging = draggables[drag.iDragging]

            const w = screen.width
            const h = screen.height
            dragging.r.setXYView({
                x: clamp(mouse.x + drag.offX, 0, w),
                y: clamp(mouse.y + drag.offY, 0, h),
            })
            changes.add(['draggables'])
        } else {
            // see if we're hovering over something grabbable
            // because we want the user to see if they can grab something
            const nd = draggables.length
            for (let i = 0; i < nd; i++) {
                const d = draggables[i]
                if ((viewSettings.showGhosts || d.o.exists) && hitTest(d, mouse, 0)) {
                    grabCanvas.dataset.cursor = 'grab'
                    return
                }
            }
            grabCanvas.dataset.cursor = '' // nothing to grab
        }
        moveClickDetect(mouse)
    }

    const end = function () {
        endClickDetect()
        if (drag.iDragging !== undefined) {
            const dragging = draggables[drag.iDragging]
            dragging.g.drop()
        }
        drag = {}
    }

    // Touch Listeners
    const touchmove = (e) => {
        const pass = passTouch(e)
        move(pass)
    }
    const touchstart = (e) => {
        const pass = passTouch(e)
        start(pass)
    }
    const touchend = (e) => {
        const pass = passTouch(e)
        move(pass)
        end(pass)

        // prevent mousedown from firing unless we're on a tooltip
        if (e.target.closest('.tooltipBox') === null) {
            e.preventDefault()
        }
    }

    self.eventHandlers = {
        start, move, end, touchmove, touchstart, touchend,
    }

    /**
     * Make a touch event look like a mouse event, with a flag.
     * @param {Event} e - The event from the DOM
     * @returns {Event} - The same event it received, plus some added properties.
     */
    function passTouch(e) {
        e.isTouch = true
        return e
    }

    /** Fix position relative to parent
     *  https://stackoverflow.com/questions/2614461/javascript-get-mouse-position-relative-to-parent-element
     */
    function getMouse(e) {
        const rect = screen.wrap.getBoundingClientRect()
        const c = (e.isTouch) ? e.changedTouches[0] : e
        const x = c.clientX - rect.left
        const y = c.clientY - rect.top
        const mouse = { x, y }
        return mouse
    }

    /**
     * Check whether m, e.g. a mouse, hits d, a draggable object.
     * @param {Object} d - An entry in the draggables array.
     * @param {Object} m - An object with properties x and y, e.g. a mouse.
     * @param {Number} extra - Extra slack to catch touches outside of the hitbox.
     * @returns {Boolean} - Whether m hits d.
     */
    function hitTest(d, m, extra) {
        // Only drag an object if we're near it.
        const x = d.r.x - m.x
        const y = d.r.y - m.y
        if (d.p.isCircle) {
            const { r } = d.g
            const hit = x * x + y * y < (r + extra) * (r + extra)
            return hit
        } if (d.p.isSquare) {
            const { w, h } = d.g
            const hit = Math.abs(x) < 0.5 * w + extra && Math.abs(y) < 0.5 * h + extra
            return hit
        }
        return false
    }

    // click detection //

    let couldBeClick
    let startPos
    function startClickDetect(mouse) {
        couldBeClick = true
        startPos = { ...mouse }
    }
    function moveClickDetect(mouse) {
        if (couldBeClick) {
            const xDist = Math.abs(startPos.x - mouse.x)
            const yDist = Math.abs(startPos.y - mouse.y)
            if (xDist > 5) couldBeClick = false
            if (yDist > 5) couldBeClick = false
        }
    }
    function endClickDetect() {
        if (couldBeClick) {
            couldBeClick = false
            if (drag.isDragging) { // because the mouse is moving
                const dragging = draggables[drag.iDragging]
                dragging.r.click()
            } else {
                // We are not dragging anything, and we clicked,
                // and we're inside the screen because this could be a click,
                // so let's do the click action for blank space.
                viewEntities.clickEmpty(startPos)
            }
        }
    }
}

/** https://stackoverflow.com/a/24719569 */
function clamp(value, min, max) {
    if (value < min) return min
    if (value > max) return max
    return value
}