Source: bounded-drag.js

/**
 * @function addDrag
 * @summary
 * Makes a Movie Clip draggable
 * @description
 * Required Parameters:
 *
 * options.draggable = Movie Clip to be dragged
 *
 * Notes:
 * 1.  If the draggable movie clip does not already have it's hitArea set, it will be added
 *     automatically here.
 * 2.  The draggable onclick handler function is set here to evt.preventDefault(), so the slate
 *     click-out is not triggered when the object is dragged.  You can still add other onclick handlers
 *     if there is other custom actions you need when the object is released (click event fired).
 *
 * Optional Parameters:
 *
 * * options.dragUpdate = function which is called at each drag update with an object containing
 *   the Movie Clip's x, y and raw event {x: Number, y: Number, event: object}
 * * options.dragEnd = function which is called at the end of drag gesture with an object containing
 *   the Movie Clip's x, y and raw event {x: Number, y: Number, event: object}
 * * options.container = Movie Clip defining bounds that draggable can be moved.  If container is
 *   not specified, then the parent movie clip of the draggable is used
 * * options.draggable.bounds = bounds object defining edges of draggable that is bounded
 *
 * The bounds object is a hotspot based on fractional units of the object's width & height.
 * Each bound direction (left, right, top, bottom) represents the fraction of the width or height
 * that the boundary is from the center.
 *
 * To constrain the draggable object within the container, so the center (0,0) of the draggable object
 * cannot move outside the container:
 * ```
 *   draggable.bounds = {
 *     left: 0,
 *     right: 0,
 *     top: 0,
 *     bottom: 0
 *   }
 * ```
 * To constrain the draggable object within the container, so the outside edges of the draggable object cannot
 * move outside the container:
 * ```
 *   draggable.bounds = {
 *     left: -1,
 *     right: -1,
 *     top: -1,
 *     bottom: -1
 *   }
 * ```
 * To constrain the draggable object within the container, so the inside edges of the draggable object cannot
 * move outside the container:
 * ```
 *   draggable.bounds = {
 *     left: 1,
 *     right: 1,
 *     top: 1,
 *     bottom: 1
 *   }
 * ```
 * Returns: ```{removeEventHandlers<function>}```
 *
 * Internal functions are also returned for unit testing.
 *
 * @param {Object} options
 * @param {createjs.MovieClip} options.draggable
 * @param {createjs.MovieClip=} options.container
 * @param {function({x: Number, y: Number, event: object})=} options.dragUpdate
 * @param {function({x: Number, y: Number, event: object})=} options.dragEnd
 * @param {boolean=} options.forceDragRelease (defaults false) if true, then draggable is released when the mouse is
 *   no longer over the boundaries of the object
 * @params {boolean=} options.doLogging (defaults false)
 */
function addDrag (options) {
  var draggable = options.draggable
  if (!(draggable instanceof window.createjs.MovieClip)) {
    console.log('Error setting up addDrag function: options.draggable must be a MovieClip')
    return
  }
  draggable.bounds = draggable.bounds ||
    {
      left: 0,
      top: 0,
      right: 0,
      bottom: 0
    }
  var container = options.container || draggable.parent
  /*
   * Set the hitArea of the draggable movie clip
   */
  var draggableWidth = draggable.nominalBounds.width * draggable.scaleX
  var draggableHeight = draggable.nominalBounds.height * draggable.scaleY
  var containerWidth = container.nominalBounds.width * container.scaleX
  var containerHeight = container.nominalBounds.height * container.scaleY
  if (!(draggable.hitArea instanceof window.createjs.Shape)) {
    draggable.hitArea = new window.createjs.Shape()
    draggable.hitArea
      .graphics
      .beginFill('#000')
      .drawRect(-draggableWidth / 2, -draggableHeight / 2, draggableWidth, draggableHeight)
  }
  /*
   * Prevent the default slate click-out
   */
  draggable.on('click', function (evt) { evt.preventDefault() })
  var dragUpdate
  if (typeof options.dragUpdate === 'function') {
    if (window.$b.slateAutoCloseTimer) {
      dragUpdate = function (updateOptions) {
        window.$b.slateAutoCloseTimer.reset()
        options.dragUpdate(updateOptions)
      }
    } else {
      dragUpdate = options.dragUpdate
    }
  }
  var dragEnd
  if (typeof options.dragEnd === 'function') {
    dragEnd = options.dragEnd
  }
  /*
   * The values of evt.delta X & Y are cumulative since the drag gesture begin,
   * so the change for the current update is the difference from the lastEvt.
   *
   * Occasionally, the panend event is not dispatched, so a timer is used to reset the
   * lastEvt as a precaution.
   */
  var lastEvt = null
  var waitTime = options.waitTime || 1000
  var timerId = null
  var canvas = window.$b.stage.canvas
  var refWidth = window.$b.adParameters.refWidth
  var refHeight = window.$b.adParameters.refHeight
  var getDragBounds = getDragBoundsFunction({ draggable: draggable, container: container })
  var isDesktopSafariEdge = !window.$b.isMobile() && (window.$b.isSafari() || window.$b.isEdge())
  var isWindows = /Windows/i.test(window.navigator.userAgent)
  var isWindowsFirefox = isWindows && window.$b.isFirefox()
  var isMobile = window.$b.isMobile()
  var forceRelease = !!options.forceDragRelease
  var doLogging = !!options.doLogging
  var parent = draggable.parent
  var stage = window.$b.stage
  /*
   * The event CanvasSizeChanged is called at the end of blink/animate-cc-resize-canvas.
   * Since dx & dy are calculated by a difference of evt and lastEvt, we must null out lastEvt
   * after the draggable is released so the object does not jump.
   */
  if (window.$b) {
    window.$b.on('CanvasSizeChanged', function (evt) {
      getDragBounds = getDragBoundsFunction({ draggable: draggable, container: container })
      lastEvt = null
    })
    window.$b.on('AdPaused', function (evt) {
      lastEvt = null
    })
  }
  /*
   * On JWPlayer Mobile, the AdPaused event is not fired.  Detect if an ad is paused by
   * testing if the value of previousTime is not updated while the App is paused in the background.
   */
  if (isMobile) {
    var dt = 250
    var previousTime = Date.now()
    var currentTime
    window.setInterval(function () {
      currentTime = Date.now()
      if ((currentTime - previousTime) > 2 * dt) {
        window.$b.resetHandler()
        lastEvt = null
        if (doLogging) {
          var pauseTime = ((currentTime - previousTime) / 1000).toFixed(2)
          console.log('Mobile JavaScript engine paused for ' + pauseTime + 's')
        }
      }
      previousTime = currentTime
    }, dt)
  }
  var draggableBounds
  var pointInBounds = function (point) {
    draggableBounds = {
      top: draggable.y - draggableHeight / 2,
      bottom: draggable.y + draggableHeight / 2,
      left: draggable.x - draggableWidth / 2,
      right: draggable.x + draggableWidth / 2
    }
    return (
      point &&
      (point.x >= draggableBounds.left) &&
      (point.x <= draggableBounds.right) &&
      (point.y >= draggableBounds.top) &&
      (point.y <= draggableBounds.bottom)
    )
  }
  var mouseInBounds = function () {
    var point = parent.globalToLocal(stage.mouseX, stage.mouseY)
    var status = pointInBounds(point)
    if (doLogging && !status) {
      console.log('mouse out of bounds: ', point.x, point.y)
      console.log('draggableBounds:', draggableBounds)
    }
    return status
  }
  var containerBounds = {
    top: container.y - containerHeight / 2,
    bottom: container.y + containerHeight / 2,
    left: container.x - containerWidth / 2,
    right: container.x + containerWidth / 2
  }
  var pointInContainer = function (point) {
    return (
      point &&
      (point.x >= containerBounds.left) &&
      (point.x <= containerBounds.right) &&
      (point.y >= containerBounds.top) &&
      (point.y <= containerBounds.bottom)
    )
  }
  var mouseInContainer = function () {
    var point = container.parent.globalToLocal(stage.mouseX, stage.mouseY)
    var status = pointInContainer(point)
    if (doLogging && !status) {
      console.log('mouse out of container: ', point.x, point.y)
    }
    return status
  }
  if (doLogging) {
    console.log('containerBounds:', containerBounds)
  }
  var mouseStatus = true
  var updateHandler = draggable.addEventListener('pan', function (evt) {
    if (timerId) {
      clearTimeout(timerId)
    }
    /*
     * Safari and Edge to do fire stage.onmouseleave nor document.onmouseleave when the edge of the
     * iframe goes beyond the window.  To force draggable objects to be released for these browsers,
     * the handler is forcibly reset if a pan event is not received after 0.5 seconds, which
     * will occur when the user drags out of the iframe.  Also for Windows Firefox.
     */
    timerId = setTimeout(function (evt) {
      lastEvt = null
      ;(isWindowsFirefox || isDesktopSafariEdge) && window.$b.resetHandler()
    }, waitTime)
    if (lastEvt) {
      var dx = (evt.deltaX - lastEvt.deltaX) * refWidth / canvas.clientWidth
      var dy = (evt.deltaY - lastEvt.deltaY) * refHeight / canvas.clientHeight
      if (doLogging) {
        console.log('dx, dy: ', dx, dy)
      }
      /*
       * NaN's rarely occur, but if they do, they throw the draggable off the screen
       */
      if (isNaN(dx) || isNaN(dy)) {
        lastEvt = evt
        return
      }
      var point = {
        x: draggable.x + dx,
        y: draggable.y + dy
      }
      mouseStatus = boundDragPoint({ draggable: draggable, container: container, point: point, getDragBounds: getDragBounds })
      if (!mouseStatus) {
        window.$b.resetHandler()
        lastEvt = null
        return
      }
      draggable.x = point.x
      draggable.y = point.y
      if (dragUpdate) {
        dragUpdate({ x: draggable.x, y: draggable.y, event: evt })
      }
      if (forceRelease && !mouseInBounds(point)) {
        if (doLogging) {
          console.log('mouse not over draggable')
        }
        window.$b.resetHandler()
        lastEvt = null
        return
      }
    }
    lastEvt = evt
  })
  draggable.addEventListener('panstart', updateHandler)
  var endHandler = draggable.addEventListener('panend', function (evt) {
    lastEvt = null
    if (dragEnd) dragEnd({ x: draggable.x, y: draggable.y, event: evt })
  })

  function removeEventHandlers () {
    draggable.removeEventListener('pan', updateHandler)
    draggable.removeEventListener('panstart', updateHandler)
    draggable.removeEventListener('panend', endHandler)
  }

  function restoreEventHandlers () {
    draggable.addEventListener('pan', updateHandler)
    draggable.addEventListener('panstart', updateHandler)
    draggable.addEventListener('panend', endHandler)
  }

  function getDragBoundsFunction (options) {
    var draggable = options.draggable
    var container = options.container
    var parent = draggable.parent
    /*
     * Precompute values that are constant.  Only the scale can change.
     */
    var containerWidth = container.nominalBounds.width
    var containerHeight = container.nominalBounds.height
    var left0 = 0
    var left1 = -draggable.nominalBounds.width * draggable.bounds.left * 0.5
    var right0 = containerWidth
    var right1 = draggable.nominalBounds.width * draggable.bounds.right * 0.5
    var top0 = 0
    var top1 = -draggable.nominalBounds.height * draggable.bounds.top * 0.5
    var bottom0 = containerHeight
    var bottom1 = draggable.nominalBounds.height * draggable.bounds.bottom * 0.5
    window.devicePixelRatio = window.devicePixelRatio || 1
    /*
     * The coordinates of the draggable is in it's parents coordinate system.
     * If the container is not the draggable's parent, then the coordinates
     * must be transformed.
     */
    if (container !== parent) {
      var upperLeft = container.localToLocal(left0, top0, parent)
      var lowerRight = container.localToLocal(right0, bottom0, parent)
      left0 = upperLeft.x - containerWidth / 2
      top0 = upperLeft.y - containerHeight / 2
      right0 = lowerRight.x - containerWidth / 2
      bottom0 = lowerRight.y - containerHeight / 2
    }

    return function () {
      return {
        left: left0 + left1 * draggable.scaleX,
        right: right0 + right1 * draggable.scaleX,
        top: top0 + top1 * draggable.scaleY,
        bottom: bottom0 + bottom1 * draggable.scaleY
      }
    }
  }

  /**
   * Bound the point that the draggable object will be moved to.
   * Returns false if the cursor is both out of the container and outside the draggable, so the
   * calling function can release the draggable.
   *
   * @param options
   * @returns {boolean}
   */
  function boundDragPoint (options) {
    var draggable = options.draggable
    var container = options.container
    var point = options.point
    var getDragBounds = options.getDragBounds
    var bounds = getDragBounds(draggable, container)
    point.x = Math.max(point.x, bounds.left)
    point.x = Math.min(point.x, bounds.right)
    point.y = Math.max(point.y, bounds.top)
    point.y = Math.min(point.y, bounds.bottom)
    /*
     * Only check if the mouse is out of bounds if the draggable object is on the edge of the container.
     * If it is, then release the draggable.
     */
    var status = true
    if (
      point.x === bounds.left ||
      point.x === bounds.right ||
      point.y === bounds.top ||
      point.y === bounds.bottom
    ) {
      /*
       * CreateJS loses track of the mouse position after an app switch on mobile devices,
       * so mouseInContainer and mouseInBounds do not work correctly afterwards.
       * Just release the draggable on mobile if the object is on the edge of the container.
       */
      if (isMobile) {
        status = false
      } else if (!mouseInContainer() && !mouseInBounds()) {
        if (doLogging) {
          console.log('mouseStatus = false')
        }
        status = false
      }
    }
    return status
  }

  return {
    getDragBoundsFunction: getDragBoundsFunction,
    boundDragPoint: boundDragPoint,
    updateHandler: updateHandler,
    endHandler: endHandler,
    removeEventHandlers: removeEventHandlers,
    restoreEventHandlers: restoreEventHandlers
  }
}

window.$b = window.$b || {}
window.$b.snippets = window.$b.snippets || {}
var snippets = window.$b.snippets
snippets.addDrag = addDrag

if (window.module) {
  window.module.exports = addDrag
}