|
|
@@ -1,503 +0,0 @@
|
|
|
-import { getScrollParent } from '../util/dom-geom'
|
|
|
-import {
|
|
|
- getEvIsTouch,
|
|
|
- getEvX,
|
|
|
- getEvY,
|
|
|
- isPrimaryMouseButton,
|
|
|
- preventDefault
|
|
|
-} from '../util/dom-event'
|
|
|
-import {
|
|
|
- firstDefined,
|
|
|
- preventSelection,
|
|
|
- allowSelection,
|
|
|
- debounce
|
|
|
-} from '../util/misc'
|
|
|
-import { default as ListenerMixin, ListenerInterface } from './ListenerMixin'
|
|
|
-import GlobalEmitter from './GlobalEmitter'
|
|
|
-
|
|
|
-
|
|
|
-export interface DragListenerOptions {
|
|
|
- delay?: number
|
|
|
- distance?: number
|
|
|
- subjectEl?: HTMLElement
|
|
|
- scroll?: boolean
|
|
|
- [handlerName: string]: any
|
|
|
-}
|
|
|
-
|
|
|
-/* Tracks a drag's mouse movement, firing various handlers
|
|
|
-----------------------------------------------------------------------------------------------------------------------*/
|
|
|
-// TODO: use Emitter
|
|
|
-
|
|
|
-export default class DragListener {
|
|
|
-
|
|
|
- listenTo: ListenerInterface['listenTo']
|
|
|
- stopListeningTo: ListenerInterface['stopListeningTo']
|
|
|
-
|
|
|
- options: DragListenerOptions
|
|
|
- subjectEl: HTMLElement
|
|
|
-
|
|
|
- // coordinates of the initial mousedown
|
|
|
- originX: any
|
|
|
- originY: any
|
|
|
-
|
|
|
- // the wrapping element that scrolls, or MIGHT scroll if there's overflow.
|
|
|
- // within the body. so, can't be the body/document/window. might be null
|
|
|
- scrollEl: HTMLElement
|
|
|
-
|
|
|
- isInteracting: boolean = false
|
|
|
- isDistanceSurpassed: boolean = false
|
|
|
- isDelayEnded: boolean = false
|
|
|
- isDragging: boolean = false
|
|
|
- isTouch: boolean = false
|
|
|
- skipBinding: boolean = false // if true, don't watch mouse/touch events
|
|
|
-
|
|
|
- delay: any
|
|
|
- delayTimeoutId: any
|
|
|
- minDistance: any
|
|
|
-
|
|
|
- shouldCancelTouchScroll: boolean = true
|
|
|
- scrollAlwaysKills: boolean = false
|
|
|
-
|
|
|
- isAutoScroll: boolean = false
|
|
|
-
|
|
|
- scrollBounds: any // { top, bottom, left, right }
|
|
|
- scrollTopVel: any // pixels per second
|
|
|
- scrollLeftVel: any // pixels per second
|
|
|
- scrollIntervalId: any // ID of setTimeout for scrolling animation loop
|
|
|
-
|
|
|
- // defaults
|
|
|
- scrollSensitivity: number = 30 // pixels from edge for scrolling to start
|
|
|
- scrollSpeed: number = 200 // pixels per second, at maximum speed
|
|
|
- scrollIntervalMs: number = 50 // millisecond wait between scroll increment
|
|
|
-
|
|
|
-
|
|
|
- constructor(options: DragListenerOptions) {
|
|
|
- this.options = options || {}
|
|
|
- }
|
|
|
-
|
|
|
-
|
|
|
- // Interaction (high-level)
|
|
|
- // -----------------------------------------------------------------------------------------------------------------
|
|
|
-
|
|
|
-
|
|
|
- startInteraction(ev, extraOptions: any = {}) {
|
|
|
-
|
|
|
- if (ev.type === 'mousedown') {
|
|
|
- if (GlobalEmitter.get().shouldIgnoreMouse()) {
|
|
|
- return
|
|
|
- } else if (!isPrimaryMouseButton(ev)) {
|
|
|
- return
|
|
|
- } else {
|
|
|
- ev.preventDefault() // prevents native selection in most browsers
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- if (!this.isInteracting) {
|
|
|
-
|
|
|
- // process options
|
|
|
- this.delay = firstDefined(extraOptions.delay, this.options.delay, 0)
|
|
|
- this.minDistance = firstDefined(extraOptions.distance, this.options.distance, 0)
|
|
|
- this.subjectEl = this.options.subjectEl
|
|
|
-
|
|
|
- preventSelection(document.body)
|
|
|
-
|
|
|
- this.isInteracting = true
|
|
|
- this.isTouch = getEvIsTouch(ev)
|
|
|
- this.isDelayEnded = false
|
|
|
- this.isDistanceSurpassed = false
|
|
|
-
|
|
|
- this.originX = getEvX(ev)
|
|
|
- this.originY = getEvY(ev)
|
|
|
- this.scrollEl = getScrollParent(ev.target)
|
|
|
-
|
|
|
- this.bindHandlers()
|
|
|
- this.initAutoScroll()
|
|
|
- this.handleInteractionStart(ev)
|
|
|
- this.startDelay(ev)
|
|
|
-
|
|
|
- if (!this.minDistance) {
|
|
|
- this.handleDistanceSurpassed(ev)
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
-
|
|
|
- handleInteractionStart(ev) {
|
|
|
- this.trigger('interactionStart', ev)
|
|
|
- }
|
|
|
-
|
|
|
-
|
|
|
- endInteraction(ev, isCancelled) {
|
|
|
- if (this.isInteracting) {
|
|
|
- this.endDrag(ev)
|
|
|
-
|
|
|
- if (this.delayTimeoutId) {
|
|
|
- clearTimeout(this.delayTimeoutId)
|
|
|
- this.delayTimeoutId = null
|
|
|
- }
|
|
|
-
|
|
|
- this.destroyAutoScroll()
|
|
|
- this.unbindHandlers()
|
|
|
-
|
|
|
- this.isInteracting = false
|
|
|
- this.handleInteractionEnd(ev, isCancelled)
|
|
|
-
|
|
|
- allowSelection(document.body)
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
-
|
|
|
- handleInteractionEnd(ev, isCancelled) {
|
|
|
- this.trigger('interactionEnd', ev, isCancelled || false)
|
|
|
- }
|
|
|
-
|
|
|
-
|
|
|
- // Binding To DOM
|
|
|
- // -----------------------------------------------------------------------------------------------------------------
|
|
|
-
|
|
|
-
|
|
|
- bindHandlers() {
|
|
|
- // some browsers (Safari in iOS 10) don't allow preventDefault on touch events that are bound after touchstart,
|
|
|
- // so listen to the GlobalEmitter singleton, which is always bound, instead of the document directly.
|
|
|
- let globalEmitter = GlobalEmitter.get()
|
|
|
-
|
|
|
- if (this.skipBinding) {
|
|
|
- //
|
|
|
- } else if (this.isTouch) {
|
|
|
- this.listenTo(globalEmitter, {
|
|
|
- touchmove: this.handleTouchMove,
|
|
|
- touchend: this.endInteraction,
|
|
|
- scroll: this.handleTouchScroll
|
|
|
- })
|
|
|
- } else {
|
|
|
- this.listenTo(globalEmitter, {
|
|
|
- mousemove: this.handleMouseMove,
|
|
|
- mouseup: this.endInteraction
|
|
|
- })
|
|
|
- }
|
|
|
-
|
|
|
- this.listenTo(globalEmitter, {
|
|
|
- selectstart: preventDefault, // don't allow selection while dragging
|
|
|
- contextmenu: preventDefault // long taps would open menu on Chrome dev tools
|
|
|
- })
|
|
|
- }
|
|
|
-
|
|
|
-
|
|
|
- unbindHandlers() {
|
|
|
- this.stopListeningTo(GlobalEmitter.get())
|
|
|
- }
|
|
|
-
|
|
|
-
|
|
|
- // Drag (high-level)
|
|
|
- // -----------------------------------------------------------------------------------------------------------------
|
|
|
-
|
|
|
-
|
|
|
- // extraOptions ignored if drag already started
|
|
|
- startDrag(ev, extraOptions?) {
|
|
|
- this.startInteraction(ev, extraOptions) // ensure interaction began
|
|
|
-
|
|
|
- if (!this.isDragging) {
|
|
|
- this.isDragging = true
|
|
|
- this.handleDragStart(ev)
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
-
|
|
|
- handleDragStart(ev) {
|
|
|
- this.trigger('dragStart', ev)
|
|
|
- }
|
|
|
-
|
|
|
-
|
|
|
- handleMove(ev) {
|
|
|
- let dx = getEvX(ev) - this.originX
|
|
|
- let dy = getEvY(ev) - this.originY
|
|
|
- let minDistance = this.minDistance
|
|
|
- let distanceSq // current distance from the origin, squared
|
|
|
-
|
|
|
- if (!this.isDistanceSurpassed) {
|
|
|
- distanceSq = dx * dx + dy * dy
|
|
|
- if (distanceSq >= minDistance * minDistance) { // use pythagorean theorem
|
|
|
- this.handleDistanceSurpassed(ev)
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- if (this.isDragging) {
|
|
|
- this.handleDrag(dx, dy, ev)
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
-
|
|
|
- // Called while the mouse is being moved and when we know a legitimate drag is taking place
|
|
|
- handleDrag(dx, dy, ev) {
|
|
|
- this.trigger('drag', dx, dy, ev)
|
|
|
- this.updateAutoScroll(ev) // will possibly cause scrolling
|
|
|
- }
|
|
|
-
|
|
|
-
|
|
|
- endDrag(ev) {
|
|
|
- if (this.isDragging) {
|
|
|
- this.isDragging = false
|
|
|
- this.handleDragEnd(ev)
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
-
|
|
|
- handleDragEnd(ev) {
|
|
|
- this.trigger('dragEnd', ev)
|
|
|
- }
|
|
|
-
|
|
|
-
|
|
|
- // Delay
|
|
|
- // -----------------------------------------------------------------------------------------------------------------
|
|
|
-
|
|
|
-
|
|
|
- startDelay(initialEv) {
|
|
|
- if (this.delay) {
|
|
|
- this.delayTimeoutId = setTimeout(() => {
|
|
|
- this.handleDelayEnd(initialEv)
|
|
|
- }, this.delay)
|
|
|
- } else {
|
|
|
- this.handleDelayEnd(initialEv)
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
-
|
|
|
- handleDelayEnd(initialEv) {
|
|
|
- this.isDelayEnded = true
|
|
|
-
|
|
|
- if (this.isDistanceSurpassed) {
|
|
|
- this.startDrag(initialEv)
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
-
|
|
|
- // Distance
|
|
|
- // -----------------------------------------------------------------------------------------------------------------
|
|
|
-
|
|
|
-
|
|
|
- handleDistanceSurpassed(ev) {
|
|
|
- this.isDistanceSurpassed = true
|
|
|
-
|
|
|
- if (this.isDelayEnded) {
|
|
|
- this.startDrag(ev)
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
-
|
|
|
- // Mouse / Touch
|
|
|
- // -----------------------------------------------------------------------------------------------------------------
|
|
|
-
|
|
|
-
|
|
|
- handleTouchMove(ev) {
|
|
|
-
|
|
|
- // prevent inertia and touchmove-scrolling while dragging
|
|
|
- if (this.isDragging && this.shouldCancelTouchScroll) {
|
|
|
- ev.preventDefault()
|
|
|
- }
|
|
|
-
|
|
|
- this.handleMove(ev)
|
|
|
- }
|
|
|
-
|
|
|
-
|
|
|
- handleMouseMove(ev) {
|
|
|
- this.handleMove(ev)
|
|
|
- }
|
|
|
-
|
|
|
-
|
|
|
- // Scrolling (unrelated to auto-scroll)
|
|
|
- // -----------------------------------------------------------------------------------------------------------------
|
|
|
-
|
|
|
-
|
|
|
- handleTouchScroll(ev) {
|
|
|
- // if the drag is being initiated by touch, but a scroll happens before
|
|
|
- // the drag-initiating delay is over, cancel the drag
|
|
|
- if (!this.isDragging || this.scrollAlwaysKills) {
|
|
|
- this.endInteraction(ev, true) // isCancelled=true
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
-
|
|
|
- // Utils
|
|
|
- // -----------------------------------------------------------------------------------------------------------------
|
|
|
-
|
|
|
-
|
|
|
- // Triggers a callback. Calls a function in the option hash of the same name.
|
|
|
- // Arguments beyond the first `name` are forwarded on.
|
|
|
- trigger(name, ...args) {
|
|
|
- if (this.options[name]) {
|
|
|
- this.options[name].apply(this, args)
|
|
|
- }
|
|
|
- // makes _methods callable by event name. TODO: kill this
|
|
|
- if (this['_' + name]) {
|
|
|
- this['_' + name].apply(this, args)
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
-
|
|
|
- // Auto-scroll
|
|
|
- // -----------------------------------------------------------------------------------------------------------------
|
|
|
-
|
|
|
-
|
|
|
- initAutoScroll() {
|
|
|
- let scrollEl = this.scrollEl
|
|
|
-
|
|
|
- this.isAutoScroll = Boolean(this.options.scroll && scrollEl)
|
|
|
-
|
|
|
- if (this.isAutoScroll) {
|
|
|
- // debounce makes sure rapid calls don't happen
|
|
|
- this.listenTo(scrollEl, 'scroll', debounce(this.handleDebouncedScroll, 100))
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
-
|
|
|
- destroyAutoScroll() {
|
|
|
- this.endAutoScroll() // kill any animation loop
|
|
|
-
|
|
|
- // remove the scroll handler if there is a scrollEl
|
|
|
- if (this.isAutoScroll) {
|
|
|
- this.stopListeningTo(this.scrollEl, 'scroll') // will probably get removed by unbindHandlers too :(
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
-
|
|
|
- // Computes and stores the bounding rectangle of scrollEl
|
|
|
- computeScrollBounds() {
|
|
|
- if (this.isAutoScroll) {
|
|
|
- this.scrollBounds = this.scrollEl.getBoundingClientRect()
|
|
|
- // TODO: use computeInnerRect in future. but prevents auto scrolling when on top of scrollbars
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
-
|
|
|
- // Called when the dragging is in progress and scrolling should be updated
|
|
|
- updateAutoScroll(ev) {
|
|
|
- let sensitivity = this.scrollSensitivity
|
|
|
- let bounds = this.scrollBounds
|
|
|
- let topCloseness
|
|
|
- let bottomCloseness
|
|
|
- let leftCloseness
|
|
|
- let rightCloseness
|
|
|
- let topVel = 0
|
|
|
- let leftVel = 0
|
|
|
-
|
|
|
- if (bounds) { // only scroll if scrollEl exists
|
|
|
-
|
|
|
- // compute closeness to edges. valid range is from 0.0 - 1.0
|
|
|
- topCloseness = (sensitivity - (getEvY(ev) - bounds.top)) / sensitivity
|
|
|
- bottomCloseness = (sensitivity - (bounds.bottom - getEvY(ev))) / sensitivity
|
|
|
- leftCloseness = (sensitivity - (getEvX(ev) - bounds.left)) / sensitivity
|
|
|
- rightCloseness = (sensitivity - (bounds.right - getEvX(ev))) / sensitivity
|
|
|
-
|
|
|
- // translate vertical closeness into velocity.
|
|
|
- // mouse must be completely in bounds for velocity to happen.
|
|
|
- if (topCloseness >= 0 && topCloseness <= 1) {
|
|
|
- topVel = topCloseness * this.scrollSpeed * -1 // negative. for scrolling up
|
|
|
- } else if (bottomCloseness >= 0 && bottomCloseness <= 1) {
|
|
|
- topVel = bottomCloseness * this.scrollSpeed
|
|
|
- }
|
|
|
-
|
|
|
- // translate horizontal closeness into velocity
|
|
|
- if (leftCloseness >= 0 && leftCloseness <= 1) {
|
|
|
- leftVel = leftCloseness * this.scrollSpeed * -1 // negative. for scrolling left
|
|
|
- } else if (rightCloseness >= 0 && rightCloseness <= 1) {
|
|
|
- leftVel = rightCloseness * this.scrollSpeed
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- this.setScrollVel(topVel, leftVel)
|
|
|
- }
|
|
|
-
|
|
|
-
|
|
|
- // Sets the speed-of-scrolling for the scrollEl
|
|
|
- setScrollVel(topVel, leftVel) {
|
|
|
-
|
|
|
- this.scrollTopVel = topVel
|
|
|
- this.scrollLeftVel = leftVel
|
|
|
-
|
|
|
- this.constrainScrollVel() // massages into realistic values
|
|
|
-
|
|
|
- // if there is non-zero velocity, and an animation loop hasn't already started, then START
|
|
|
- if ((this.scrollTopVel || this.scrollLeftVel) && !this.scrollIntervalId) {
|
|
|
- this.scrollIntervalId = setInterval(
|
|
|
- this.scrollIntervalFunc.bind(this),
|
|
|
- this.scrollIntervalMs
|
|
|
- )
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
-
|
|
|
- // Forces scrollTopVel and scrollLeftVel to be zero if scrolling has already gone all the way
|
|
|
- constrainScrollVel() {
|
|
|
- let el = this.scrollEl
|
|
|
-
|
|
|
- if (this.scrollTopVel < 0) { // scrolling up?
|
|
|
- if (el.scrollTop <= 0) { // already scrolled all the way up?
|
|
|
- this.scrollTopVel = 0
|
|
|
- }
|
|
|
- } else if (this.scrollTopVel > 0) { // scrolling down?
|
|
|
- if (el.scrollTop + el.clientHeight >= el.scrollHeight) { // already scrolled all the way down?
|
|
|
- this.scrollTopVel = 0
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- if (this.scrollLeftVel < 0) { // scrolling left?
|
|
|
- if (el.scrollLeft <= 0) { // already scrolled all the left?
|
|
|
- this.scrollLeftVel = 0
|
|
|
- }
|
|
|
- } else if (this.scrollLeftVel > 0) { // scrolling right?
|
|
|
- if (el.scrollLeft + el.clientWidth >= el.scrollWidth) { // already scrolled all the way right?
|
|
|
- this.scrollLeftVel = 0
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
-
|
|
|
- // This function gets called during every iteration of the scrolling animation loop
|
|
|
- scrollIntervalFunc() {
|
|
|
- let el = this.scrollEl
|
|
|
- let frac = this.scrollIntervalMs / 1000 // considering animation frequency, what the vel should be mult'd by
|
|
|
-
|
|
|
- // change the value of scrollEl's scroll
|
|
|
- if (this.scrollTopVel) {
|
|
|
- el.scrollTop = el.scrollTop + this.scrollTopVel * frac
|
|
|
- }
|
|
|
- if (this.scrollLeftVel) {
|
|
|
- el.scrollLeft = el.scrollLeft + this.scrollLeftVel * frac
|
|
|
- }
|
|
|
-
|
|
|
- this.constrainScrollVel() // since the scroll values changed, recompute the velocities
|
|
|
-
|
|
|
- // if scrolled all the way, which causes the vels to be zero, stop the animation loop
|
|
|
- if (!this.scrollTopVel && !this.scrollLeftVel) {
|
|
|
- this.endAutoScroll()
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
-
|
|
|
- // Kills any existing scrolling animation loop
|
|
|
- endAutoScroll() {
|
|
|
- if (this.scrollIntervalId) {
|
|
|
- clearInterval(this.scrollIntervalId)
|
|
|
- this.scrollIntervalId = null
|
|
|
-
|
|
|
- this.handleScrollEnd()
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
-
|
|
|
- // Get called when the scrollEl is scrolled (NOTE: this is delayed via debounce)
|
|
|
- handleDebouncedScroll() {
|
|
|
- // recompute all coordinates, but *only* if this is *not* part of our scrolling animation
|
|
|
- if (!this.scrollIntervalId) {
|
|
|
- this.handleScrollEnd()
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
-
|
|
|
- handleScrollEnd() {
|
|
|
- // Called when scrolling has stopped, whether through auto scroll, or the user scrolling
|
|
|
- }
|
|
|
-
|
|
|
-}
|
|
|
-
|
|
|
-ListenerMixin.mixInto(DragListener)
|