Source: constrained-drag.js

/**
 * @function addConstrainedDrag
 * @summary
 * Constrain a draggable along a line.  The mousewheel also moves the draggable on all desktop browsers
 * except Microsoft Edge.
 * @description
 * The snippet makes 2 changes to the draggable movie clip:
 * 1. automatically adds an invisible rectangle to the draggable movie clip as the hit area
 * based on the nominal bounds.  If the draggable is text, you need to add a shape to the movie clip
 * with either a background color or alpha = 0, so Animate CC will calculate the desired nominal bounds.
 * 2. adds an onclick handler which calls evt.preventDefault, so mousing/touching down on the draggable does not
 * trigger the default slate click-out
 *
 * The coordinates of the line are based on the cross-hairs (registration point) of the
 * draggable movieclip.  These are the values x and y draggable as viewed in it's parent object.
 *
 * Pass in an optional callback function in the options object with the key "dragUpdate"
 * The dragUpdate function will be called at each mousemovement at maximum of 16 ms (60 fps)
 * The dragUpdate function is called back with this object:
 * ```
 *   {fraction: distance of draggable from line.a,
 *    event: raw Event object,
 *    dx: change in x position in coordinates of parent,
 *    dy: change in y position in coordinates of parent}
 * ```
 *
 * Pass in an optional callback function in the options object with the key "dragEnd"
 * The dragEnd function will be called when the "panend" event is fired (end of drag).
 * The dragEnd function is called back with this object:
 * ```
 *   {fraction: distance of draggable from line.a,
 *    event: raw Event object}
 * ```
 *
 * Pass in an optional releaseTolerance (between 0 and 1), which allows draggable to catch up to mouse/touch
 * position.  Default value is 0.1.  Tolerance in pixels is releaseTolerance * canvas width
 *
 * Inertia Options:
 * * inertiaOnMobile defaults to true
 * * inertiaOnDesktop defaults to false
 * * inertiaMs defaults to 500 (0.5 seconds)
 * * inertiaInitialFactor defaults to 2, input smaller value for greater inertiaMs
 * * inertiaDeltaFactor defaults to 0.9, max = 0.99, input smaller value for shorter inertiaMs
 * * inertiaThresholdVelocity defaults to 0.1, input smaller value to allow a slower movement
 *
 * ```
 * Returns: {
 *   removeEventHandlers<function>,
 *   restoreEventHandlers<function>
 * }
 * ```
 *
 * Internal functions are also returned for unit testing.
 * @param {Object} options
 * @param {createjs.MovieClip} options.draggable
 * @param {Object} options.line Object with keys a and b for the endpoints
 * @param {Object} options.line.a Object with keys x and y for coordinates
 * @param {number} options.line.a.x x coordinate
 * @param {number} options.line.a.y y coordinate
 * @param {Object} options.line.b Object with keys x and y for coordinates
 * @param {number} options.line.b.x x coordinate
 * @param {number} options.line.b.y y coordinate
 * @param {function(Number)=} options.dragUpdate
 * @param {boolean=} options.mouseWheelDisabled (defaults false) if true, then no mousewheel handler
 * @param {number=} options.mouseWheelSpeed defaults to 1.0 (100% of actual speed)
 * @param {boolean=} options.forceDragRelease (defaults true) if true, then draggable is released when the mouse is
 *   no longer over the boundaries of the object
 * @param {number=} options.releaseTolerance a number between 0 and 1 to set a tolerance in the in draggable direction
 *   for the mouse/touch position to be off the draggable before it is released.
 *   Pixels allowed is releaseTolerance * canvas width
 * @param {boolean=} options.naturalScrolling (default true) if true, then 2 finger gesture moves
 *   draggable in opposite direction (i.e. pulling down moves content down)
 * @param {boolean=} options.inertiaOnMobile if false, then no inertia on mobile
 * @param {boolean=} options.inertiaOnDesktop if false, then no inertia on desktop
 * @param {number=} options.inertiaMs (default 500) number of milliseconds for inertia on dragEnd
 * @param {number=} options.inertialIntialFactor (default 2) multiplier for starting value of inertia
 *   applied to deltaX and deltaY of dragEnd event
 * @param {number=} options.inertialDeltaFactor (default 0.9) mulitplier on each inertia movement,
 *   so movement at each update is progressively smaller, minimum value 0, maximum value 0.99
 * @param {number=} options.inertiaVelocityThreshold (default 0.1) if dragEnd event has a velocity below
 *   the threshold, then no inertia is created.  Allows user to stop at desired location easier by stopping
 *   inertia when the last drag movement is slow.
 * @param {boolean=} options.loop (default false) if true, dragging off either end will loop back from the other end
 *   Note: looping only works for vertical or horizontal lines
 * @param {boolean=} options.doLogging if true, then log the fraction in dragUpdate
 * @param {boolean=} options.allowDraggableClick if true, then don't make clicks call preventDefault
 * @returns {{getLength: getLength, getNearestPoint: getNearestPoint, testSegment: testSegment}}
 */
function addConstrainedDrag (options) {
  if (typeof snippets.initializeGestures !== 'function') {
    console.error('Constrained Drag requires Initilize Gestures snippet')
    return
  }
  var draggable = options.draggable
  if (!(draggable instanceof window.createjs.MovieClip)) {
    console.error('Constrained Drag must be called with a MovieClip options.draggable')
    return
  }
  var parent = draggable.parent
  var stage = window.$b.stage
  var line = options.line
  var isNumber = function (x) {
    return typeof x === 'number'
  }
  if (!line || !line.a || !isNumber(line.a.x) || !isNumber(line.a.y) ||
    !line.b || !isNumber(line.b.x) || !isNumber(line.b.y)) {
    console.error('Constrained Drag must be called with options.line = {a: {x ,y}, b: {x, y}}')
    return
  }
  var isMobile = window.$b.isMobile()
  var mouseWheelScrollSign = options.naturalScrolling === false ? 1 : -1
  var doLogging = !!options.doLogging
  var mouseWheelDisabled = options.mouseWheelDisabled === true
  var mouseWheelSpeed = isNumber(options.mouseWheelSpeed) ? options.mouseWheelSpeed : 1.0
  mouseWheelSpeed = Math.min(mouseWheelSpeed, 1)
  mouseWheelSpeed = Math.max(mouseWheelSpeed, 0)
  var forceDragRelease = !(options.forceDragRelease === false)
  var releaseTolerance = isNumber(options.releaseTolerance) ? options.releaseTolerance : 0.1
  releaseTolerance = Math.min(releaseTolerance, 1)
  releaseTolerance = Math.max(releaseTolerance, 0)
  var inertiaMs = isNumber(options.inertiaMs) ? options.inertiaMs : 500
  var inertiaInitialFactor = isNumber(options.inertiaInitialFactor) ? options.inertiaInitialFactor : 2
  var inertiaDeltaFactor = isNumber(options.inertiaDeltaFactor) ? options.inertiaDeltaFactor : 0.9
  inertiaDeltaFactor = Math.max(inertiaDeltaFactor, 0)
  inertiaDeltaFactor = Math.min(inertiaDeltaFactor, 0.99)
  var inertiaVelocityThreshold = isNumber(options.inertiaVelocityThreshold) ? options.inertiaVelocityThreshold : 0.1
  var inertiaOnDesktop = !isMobile && (options.inertiaOnDesktop !== false)
  var inertiaOnMobile = isMobile && (options.inertiaOnMobile !== false)
  var inertiaOn = inertiaOnDesktop || inertiaOnMobile
  var loop = !!options.loop
  var xOnly = (line.a.y === line.b.y)
  var yOnly = (line.a.x === line.b.x)
  var xMin = Math.min(line.a.x, line.b.x)
  var xMax = Math.max(line.a.x, line.b.x)
  var yMin = Math.min(line.a.y, line.b.y)
  var yMax = Math.max(line.a.y, line.b.y)
  var dragUpdate
  var inertia = function () {}
  var cancelInertia = function () {}
  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
  var hasDragEnd = false
  if (typeof options.dragEnd === 'function') {
    dragEnd = options.dragEnd
    hasDragEnd = true
  }
  /*
   * Add inertia only on mobile
   */
  var dragEndTimer
  if (inertiaOn) {
    var cloneEvent = function (type, event) {
      var evt = new Event(type)
      return Object.setPrototypeOf(evt, event)
    }
    /*
     * 30 ms = 33.3 fps
     * Autoscroll for 0.5 second unless velocity is below threshold
     */
    var dtEvent = 30
    var dtTotal = inertiaMs
    var tEvent
    var inertiaCancelled
    inertia = function (evt) {
      if (typeof evt !== 'object') return Promise.resolve()
      if (Math.abs(evt.velocity) < inertiaVelocityThreshold) return Promise.resolve()
      return new Promise(function (resolve, reject) {
        doLogging && console.log('inertia started, evt', evt)
        inertiaCancelled = false
        tEvent = 0
        function moveIncrement (deltaOptions) {
          /*
           * Simulate a mousewheel event which does not require a delta from lastEvt
           */
          var newEvent = cloneEvent(evt.type, evt)
          newEvent.type = wheelType
          newEvent.deltaX = deltaOptions.dx * deltaOptions.deltaFactor * mouseWheelScrollSign
          newEvent.deltaY = deltaOptions.dy * deltaOptions.deltaFactor * mouseWheelScrollSign
          newEvent.deltaTime = dtEvent
          updateFunction(newEvent)
        }
        var deltaOpts = {
          dx: evt.deltaX * (dtEvent / evt.deltaTime),
          dy: evt.deltaY * (dtEvent / evt.deltaTime),
          deltaFactor: inertiaInitialFactor
        }
        var updateIncrement = function () {
          if (inertiaCancelled || (tEvent >= dtTotal)) {
            dragEndTimer = null
            resolve()
            return
          }
          moveIncrement(deltaOpts)
          deltaOpts.deltaFactor *= inertiaDeltaFactor
          tEvent += dtEvent
          dragEndTimer = setTimeout(updateIncrement, dtEvent)
        }
        updateIncrement()
      })
    }
    cancelInertia = function () {
      inertiaCancelled = true
    }
    /*
     * This wrapped function is called only by the 'panend' event.
     */
    dragEnd = function (dragEndOpts) {
      inertia(dragEndOpts.event)
        .then(function () {
          doLogging && console.log('inertia resolved')
          hasDragEnd && options.dragEnd(dragEndOpts)
        })
    }
  }

  /*
   * Set the hitArea of the draggable movie clip
   */
  if (!(draggable.hitArea instanceof window.createjs.Shape)) {
    var hitArea = new window.createjs.Shape()
    hitArea
      .graphics
      .beginFill('#000')
      .drawRect(draggable.nominalBounds.x, draggable.nominalBounds.y,
        draggable.nominalBounds.width, draggable.nominalBounds.height)
    draggable.hitArea = hitArea
    draggable.mouseChildren = false
  }
  /*
   * The event point is in the coordinates of the parent object.
   * To determine if this point is in the boundaries of the of draggable,
   * the draggable units must account for the registration point and scaling applied.
   *
   * If the draggable has a mask, then use only the coordinates of the mask to determine
   * if the event should be used or discarded.
   */
  var xMin0, xMax0, yMin0, yMax0
  var xMinPt, xMaxPt, yMinPt, yMaxPt
  var useMask = draggable.mask instanceof createjs.Shape

  /*
   * Precalculate variables needed for function pointInBounds
   */
  if (useMask) {
    /*
     * The mask coordinate system is the same as the draggable.
     */
    var maskBounds = getShapeBounds(draggable.mask)
    /*
     * These are the initial mask bounds in the coordinates system of the parent.
     */
    xMinPt = draggable.mask.x
    xMaxPt = xMinPt + maskBounds.width
    yMinPt = draggable.mask.y
    yMaxPt = yMinPt + maskBounds.height
  } else {
    /*
     * These are the base draggable bounds in the coordinate system of the parent.
     */
    xMin0 = (draggable.nominalBounds.x - draggable.regX) * draggable.scaleX
    xMax0 = xMin0 + draggable.nominalBounds.width * draggable.scaleX
    yMin0 = (draggable.nominalBounds.y - draggable.regY) * draggable.scaleY
    yMax0 = yMin0 + draggable.nominalBounds.height * draggable.scaleY
  }
  var pointInBounds = function (point) {
    if (!useMask) {
      /*
       * When the draggable moves, these are its new bounds in the coordinate system of the parent.
       */
      xMinPt = draggable.x + xMin0
      xMaxPt = draggable.x + xMax0
      yMinPt = draggable.y + yMin0
      yMaxPt = draggable.y + yMax0
    }
    return (
      (point.x >= xMinPt - tolX) &&
      (point.x <= xMaxPt + tolX) &&
      (point.y >= yMinPt - tolY) &&
      (point.y <= yMaxPt + tolY)
    )
  }
  /*
   * Uses closure variables isMobile, refWidth, refHeight, canvas and stage
   */
  function getMousePoint (options) {
    var evt = options.event
    var parent = options.parent
    var point
    /*
     * On mobile, stage.mouseX & Y does not give the correct coordinates, so they must be calculated from the
     * raw event coordinates (event.center) for gesture events.  Click events do not have evt.center.
     */
    if (isMobile && evt.center) {
      var evtOffset = options.offset || window.$b.gestures.getOffset(stage.canvas)
      var x = (evt.center.x - evtOffset.x) * refWidth / canvas.clientWidth * stage.scaleX
      var y = (evt.center.y - evtOffset.y) * refHeight / canvas.clientHeight * stage.scaleY
      point = parent.globalToLocal(x, y)
    } else {
      point = parent.globalToLocal(stage.mouseX, stage.mouseY)
    }
    return point
  }
  function mouseInBounds (options) {
    var point = getMousePoint(options)
    return pointInBounds(point)
  }
  /*
   * Unless options.allowDraggableClick is true, stop propagation of the event
   */
  if (options.allowDraggableClick !== true) {
    draggable.on('click', function (evt) {
      evt.preventDefault()
      evt.stopPropagation()
    })
  }
  /*
   * The event is updated with a value of deltaX and deltaY that is cumulative since the panstart event, so
   * need to keep track of the last event to get the incremental delta position.
   * The timer is used to reset the last event when dragging has stopped.  Mobile needs a longer waitTime.
   */
  var lineLength = getLength({ a: line.a, b: line.b })
  var lastEvt = null
  var timerId = null
  var waitTime = isMobile ? 1000 : 500
  var canvas = window.$b.stage.canvas
  var refWidth = window.$b.adParameters.refWidth
  var refHeight = window.$b.adParameters.refHeight
  var draggableRegX = draggable.regX * draggable.scaleX
  var draggableRegY = draggable.regY * draggable.scaleY
  var fraction
  var offset
  /*
   * Allow a tolerance in the draggable direction before it releases
   * Tolerance is in both directions if not xOnly and not yOnly
   */
  var tol = (refWidth * releaseTolerance) * refWidth / canvas.clientWidth
  var tolX = (xOnly || !yOnly) ? tol : 0
  var tolY = (yOnly || !xOnly) ? tol : 0
  window.$b.on('CanvasSizeChanged', function (evt) {
    if (window.$b.gestures) {
      offset = window.$b.gestures.getOffset(canvas)
    }
    tol = (refWidth * releaseTolerance) * refWidth / canvas.clientWidth
    tolX = (xOnly || !yOnly) ? tol : 0
    tolY = (yOnly || !xOnly) ? tol : 0
  })
  var mouseWheelHandler = function (evt) {
    if (!offset && window.$b.gestures) {
      offset = window.$b.gestures.getCachedOffset()
    }
    if (offset) {
      var x = (evt.clientX - offset.x) * refWidth / canvas.clientWidth
      var y = (evt.clientY - offset.y) * refHeight / canvas.clientHeight
      var obj = window.$b.stage.getObjectUnderPoint(x, y, 0)
      /*
       * The update function is attached to the draggable, since each time addConstrainedDrag is called,
       * the update function will have a new invocation with different closure variables.
       */
      obj && obj.hasEventListener('pan') && obj.updateFunction(evt)
    }
    evt.stopPropagation()
    return false
  }
  var wheelType
  /*
   * Using the mousewheel on Microsoft Edge causes the entire window to move instead of just the canvas
   */
  if (!mouseWheelDisabled && !window.$b.isEdge()) {
    /*
     * Some browsers (i.e. Firefox) use the wheel event instead of mousewheel
     */
    if (canvas.onmousewheel !== undefined) {
      canvas.onmousewheel = mouseWheelHandler
      wheelType = 'mousewheel'
    } else {
      canvas.onwheel = mouseWheelHandler
      wheelType = 'wheel'
    }
  }

  function checkInitialPosition () {
    var point = {
      x: draggable.x - draggableRegX,
      y: draggable.y - draggableRegY
    }
    var point0 = { x: point.x, y: point.y }
    var callUpdate = false
    if (xOnly) {
      if (point.x > xMax) {
        console.log('Initial draggable position is > xMax')
        callUpdate = true
      } else if (point.x < xMin) {
        console.log('Initial draggable position is < xMin')
        callUpdate = true
      }
      point.x = Math.min(point.x, xMax)
      point.x = Math.max(point.x, xMin)
      fraction = Math.abs(point.x - xMin) / lineLength
      draggable.x = point.x + draggableRegX
    } else if (yOnly) {
      if (point.y > yMax) {
        console.log('Initial draggable position is > yMax')
        callUpdate = true
      } else if (point.y < yMin) {
        console.log('Initial draggable position is < yMin')
        callUpdate = true
      }
      point.y = Math.min(point.y, yMax)
      point.y = Math.max(point.y, yMin)
      fraction = Math.abs(point.y - yMax) / lineLength
      draggable.y = point.y + draggableRegY
    } else {
      point = getNearestPoint({ line: line, point: point })
      if ((point.x !== point0.x) || (point.y !== point0.y)) {
        fraction = getLength({ b: point, a: line.a }) / lineLength
        draggable.x = point.x + draggableRegX
        draggable.y = point.y + draggableRegY
        console.log('Initial draggable position is beyond the constrained line')
        callUpdate = true
      }
    }
    if (callUpdate && dragUpdate) {
      var dx = point.x - point0.x
      var dy = point.y - point0.y
      dragUpdate({ event: null, fraction: fraction, dx: dx, dy: dy })
    }
    return point
  }
  checkInitialPosition()

  function updateFunction (evt) {
    timerId && clearTimeout(timerId)
    timerId = setTimeout(function () {
      if (doLogging) {
        console.log('constrained drag timed out between events')
      }
      lastEvt = null
      hasDragEnd && options.dragEnd({ fraction: fraction, event: evt })
    }, waitTime)
    if (lastEvt || evt.type === wheelType) {
      if (!offset && window.$b.gestures) {
        /*
         * window.$b.gestures is undefined until Hammer.js is loaded, so place the call to
         * this function in updateFunction which is not called until a pan event is fired.
         */
        offset = window.$b.gestures.getCachedOffset()
      }
      var dx = 0
      var dy = 0
      if (evt.type === wheelType) {
        /*
         * Multiply by a factor for a mechanical mousewheel (deltaMode = 1).
         */
        var factor = evt.deltaMode === 1 ? 20 : 1
        factor *= mouseWheelScrollSign
        dx = evt.deltaX * refWidth / canvas.clientWidth * mouseWheelSpeed * factor
        dy = evt.deltaY * refHeight / canvas.clientHeight * mouseWheelSpeed * factor
      } else {
        dx = (evt.deltaX - lastEvt.deltaX) * refWidth / canvas.clientWidth
        dy = (evt.deltaY - lastEvt.deltaY) * refHeight / canvas.clientHeight
        dragEndTimer && cancelInertia()
      }
      /*
       * NaN's rarely occur, but if they do, they throw the draggable off the screen
       */
      if (isNaN(dx) || isNaN(dy)) {
        lastEvt = null
        timerId && clearTimeout(timerId)
        timerId = null
        hasDragEnd && options.dragEnd({ fraction: fraction, event: evt })
        return
      }
      if (forceDragRelease && !mouseInBounds({ event: evt, offset: offset, parent: parent })) {
        if (doLogging) {
          console.log('mouse not over draggable')
        }
        timerId && clearTimeout(timerId)
        timerId = null
        cancelInertia()
        hasDragEnd && options.dragEnd({ fraction: fraction, event: evt })
        lastEvt = null
        window.$b.resetHandler()
        return
      }
      var deltaReturn = moveByDelta({ dx: dx, dy: dy })
      var actualDx = deltaReturn.dx
      var actualDy = deltaReturn.dy
      if (doLogging) {
        console.log('fraction = ' + fraction)
      }
      if (dragUpdate) {
        dragUpdate({ fraction: fraction, event: evt, dx: actualDx, dy: actualDy })
      }
    }
    doLogging && !lastEvt && console.log('lastEvt = null')
    lastEvt = evt
  }

  function moveByDelta (options) {
    var dx = isNumber(options.dx) ? options.dx : 0
    var dy = isNumber(options.dy) ? options.dy : 0
    /*
     * The position in the parent object is the draggable's position offset
     * by the draggable's scaled registration point.
     */
    var point0 = {
      x: draggable.x - draggableRegX,
      y: draggable.y - draggableRegY
    }
    var point = {
      x: point0.x + dx,
      y: point0.y + dy
    }
    if (xOnly) {
      if (!loop) {
        point.x = Math.min(point.x, xMax)
        point.x = Math.max(point.x, xMin)
      } else {
        if (point.x > xMax) {
          point.x = xMin + (point.x - xMax)
        }
        if (point.x < xMin) {
          point.x = xMax - (xMin - point.x)
        }
      }
      point.y = point0.y
      fraction = Math.abs(point.x - xMin) / lineLength
      draggable.x = point.x + draggableRegX
    } else if (yOnly) {
      /*
       * y direction is opposite convention
       */
      if (!loop) {
        point.y = Math.min(point.y, yMax)
        point.y = Math.max(point.y, yMin)
      } else {
        if (point.y > yMax) {
          point.y = yMin - (yMax - point.y)
        }
        if (point.y < yMin) {
          point.y = yMax + (yMin - point.y)
        }
      }
      point.x = point0.x
      fraction = Math.abs(point.y - yMax) / lineLength
      draggable.y = point.y + draggableRegY
    } else {
      point = getNearestPoint({ line: line, point: point })
      fraction = getLength({ b: point, a: line.a }) / lineLength
      draggable.x = point.x + draggableRegX
      draggable.y = point.y + draggableRegY
    }
    if (doLogging) {
      console.log('fraction = ' + fraction)
    }
    var actualDx = point.x - point0.x
    var actualDy = point.y - point0.y
    return ({ dx: actualDx, dy: actualDy })
  }

  draggable.updateFunction = updateFunction
  var updateHandler = draggable.addEventListener('pan', updateFunction)
  var startHandler = draggable.addEventListener('panstart', updateFunction)
  var endHandler = draggable.addEventListener('panend', function (evt) {
    timerId && clearTimeout(timerId)
    timerId = null
    if (dragEnd) {
      dragEnd({ fraction: fraction, event: evt })
    }
    lastEvt = null
    doLogging && console.log('panend fraction', fraction)
  })

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

  function restoreEventHandlers () {
    draggable.addEventListener('pan', updateHandler)
    draggable.addEventListener('panstart', startHandler)
    draggable.addEventListener('panend', endHandler)
  }
  /*
   * 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) {
      doLogging && console.log('CanvasSizeChanged, lastEvt set to null')
      lastEvt = null
    })
    window.$b.on('AdPaused', function (evt) {
      doLogging && console.log('AdPaused, lastEvt set to null')
      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 = 500
    var previousTime = Date.now()
    var currentTime
    var heartbeat = 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
      setTimeout(heartbeat, dt)
    }
    heartbeat()
  }

  function getLength (options) {
    var a = options.a
    var b = options.b
    var dx = b.x - a.x
    var dy = b.y - a.y
    return Math.sqrt(dx * dx + dy * dy)
  }

  function getNearestPoint (options) {
    var line = options.line
    var point = options.point
    var x
    var y
    if (line.a.x !== line.b.x) {
      if (line.b.y !== line.a.y) {
        var slope = (line.b.y - line.a.y) / (line.b.x - line.a.x)
        // x = (yc - y1 + x1 * m + xc / m) * (m / (m**2 + 1)
        x = (point.y - line.a.y + line.a.x * slope + point.x / slope) * (slope / (slope * slope + 1))
        // y(line) = y1 + (x - x1) * slope = m * x + (y1 - x1 * m)
        y = slope * x + line.a.y - line.a.x * slope
      } else {
        x = point.x
        y = line.a.y
      }
    } else {
      x = line.a.x
      y = point.y
    }
    return testSegment({ line: line, point: { x: x, y: y } })
  }

  function testSegment (options) {
    var line = options.line
    var a = line.a
    var b = line.b
    var p = options.point
    var dy = b.y - a.y
    var dx = b.x - a.x
    var aDotB = dy * dy + dx * dx
    dy = p.y - a.y
    dx = p.x - a.x
    var pDotA = dy * dy + dx * dx
    dy = p.y - b.y
    dx = p.x - b.x
    var pDotB = dy * dy + dx * dx
    if ((pDotA < aDotB) && (pDotB < aDotB)) {
      return { x: p.x, y: p.y }
    } else if (pDotB > pDotA) {
      return { x: a.x, y: a.y }
    } else {
      return { x: b.x, y: b.y }
    }
  }

  function moveToFraction (options) {
    var dx = 0
    var dy = 0
    if (isNumber(options.fraction) && options.fraction >= 0 && options.fraction <= 1) {
      fraction = options.fraction
      var x0 = draggable.x
      var y0 = draggable.y
      var x
      var y
      if (xOnly) {
        x = fraction * lineLength + xMin
        draggable.x = x + draggableRegX
        dx = draggable.x - x0
      } else if (yOnly) {
        /*
         * y direction is opposite convention
         */
        y = yMax - fraction * lineLength
        draggable.y = y + draggableRegY
        dy = draggable.y - y0
      } else {
        y = line.a.y + fraction * (line.b.y - line.a.y)
        x = line.a.x + fraction * (line.b.x - line.a.x)
        draggable.x = x + draggableRegX
        dx = draggable.x - x0
        draggable.y = y + draggableRegY
        dy = draggable.y - y0
      }
    }
    return { dx: dx, dy: dy }
  }

  function getFraction () {
    return fraction
  }

  function getFractionAtPoint (options) {
    if (!isNumber(options.x) || !isNumber(options.y)) {
      console.error('gerFractionAtPoint must be passed options.x and options.y')
      return {}
    }
    var fraction
    if (xOnly) {
      fraction = (options.x - parent.nominalBounds.x) / parent.nominalBounds.width
    } else if (yOnly) {
      fraction = (parent.nominalBounds.y + parent.nominalBounds.height - options.y) / parent.nominalBounds.height
    } else {
      var point = getNearestPoint({ line: line, point: options })
      var height = draggable.nominalBounds.height
      var width = draggable.nominalBounds.width
      var draggableLength = Math.sqrt(height * height + width * width)
      fraction = getLength({ b: point, a: line.a }) / (lineLength + draggableLength)
    }
    fraction = Math.max(0, fraction)
    fraction = Math.min(1, fraction)
    return { fraction: fraction }
  }

  return {
    line: line,
    moveByDelta: moveByDelta,
    moveToFraction: moveToFraction,
    getFraction: getFraction,
    getFractionAtPoint: getFractionAtPoint,
    getLength: getLength,
    getNearestPoint: getNearestPoint,
    testSegment: testSegment,
    getMousePoint: getMousePoint,
    mouseInBounds: mouseInBounds,
    updateHandler: updateHandler,
    startHandler: startHandler,
    endHandler: endHandler,
    removeEventHandlers: removeEventHandlers,
    restoreEventHandlers: restoreEventHandlers,
    dragUpdate: dragUpdate,
    dragEnd: dragEnd,
    inertia: inertia,
    cancelInertia: cancelInertia,
    checkInitialPosition: checkInitialPosition
  }
}

/**
 * @function getShapeBounds
 * @summary
 * Returns object with the bounds of the shape (graphics) object
 * @description
 * Works with shapes created by the Animate CC editor creates shapes or shapes created by users with a draw command.
 *
 * @param shape {createjs.Shape} shape you want to know the bounds
 * @returns {Object} {x, y, width, height}
 */
function getShapeBounds (shape) {
  if ((typeof shape !== 'object') || (!(shape.graphics instanceof createjs.Graphics))) {
    console.log('getShapeBounds: shape does not have a graphics object')
    return {}
  }
  var command = shape.graphics.command
  if (command && (typeof command.w === 'number') && (typeof command.h === 'number')) {
    return { x: command.x, y: command.y, width: command.w, height: command.h }
  }
  var instructions = shape.graphics.getInstructions()
  var xArray = instructions
    .map(function (inst) { return inst.x })
    .filter(function (x) { return !isNaN(x) })
  var xMin = Math.min.apply(null, xArray)
  var width = Math.max.apply(null, xArray) - xMin
  var yArray = instructions
    .map(function (inst) { return inst.y })
    .filter(function (y) { return !isNaN(y) })
  var yMin = Math.min.apply(null, yArray)
  var height = Math.max.apply(null, yArray) - yMin
  return { x: xMin, y: yMin, width: width, height: height }
}

window.$b = window.$b || {}
window.$b.snippets = window.$b.snippets || {}
var snippets = window.$b.snippets
snippets.addConstrainedDrag = addConstrainedDrag
snippets.getShapeBounds = getShapeBounds

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