ソースを参照

smoother ElementMirror. auto-scrolling

Adam Shaw 7 年 前
コミット
ad2e479040
4 ファイル変更442 行追加25 行削除
  1. 174 0
      src/dnd/AutoScroller.ts
  2. 12 9
      src/dnd/ElementMirror.ts
  3. 33 16
      src/dnd/FeaturefulElementDragging.ts
  4. 223 0
      src/dnd/scroll.ts

+ 174 - 0
src/dnd/AutoScroller.ts

@@ -0,0 +1,174 @@
+import { ScrollControllerCache, ElScrollControllerCache, ElScrollController, WindowScrollControllerCache } from './scroll'
+
+interface Side { // rename to Edge?
+  controller: ScrollControllerCache
+  name: 'top' | 'left' | 'right' | 'bottom'
+  distance: number
+}
+
+// If available we are using native "performance" API instead of "Date"
+// Read more about it on MDN:
+// https://developer.mozilla.org/en-US/docs/Web/API/Performance
+const getTime = typeof performance === 'function' ? (performance as any).now : Date.now
+
+let uid = 0
+
+export default class AutoScroller {
+
+  id: string
+
+  constructor() {
+    this.id = String(uid++)
+  }
+
+  // options that can be set by caller
+  scrollerQuery: (Window | string)[] = [ window, '.fc-scroller' ]
+  edge: number = 50
+  maxSpeed: number = 300 // pixels per second
+  isAnimating: boolean = false
+  pointerScreenX: number
+  pointerScreenY: number
+
+  private windowController: WindowScrollControllerCache
+  private controllers: ScrollControllerCache[] // rename to caches?
+  private msSinceRequest: number
+
+  start(pageX: number, pageY: number, windowController: WindowScrollControllerCache) { // TODO: pass windowscrollcontrollercache
+    this.windowController = windowController
+    this.controllers = this.buildControllers()
+    this.handleMove(pageX, pageY)
+  }
+
+  handleMove(pageX: number, pageY: number) {
+    this.pointerScreenX = pageX - this.windowController.getScrollLeft() // audit all ordering
+    this.pointerScreenY = pageY - this.windowController.getScrollTop()
+
+    if (!this.isAnimating) {
+      this.isAnimating = true
+      this.requestAnimation(getTime())
+    }
+  }
+
+  stop() {
+    this.isAnimating = false // will stop animation
+
+    for (let controller of this.controllers) {
+      if (controller !== this.windowController) { // because window controller isnt our responsbility. TODO: rethink
+        controller.destroy()
+      }
+    }
+    this.controllers = null
+
+    this.windowController = null
+  }
+
+  requestAnimation(now) {
+    this.msSinceRequest = now
+    requestAnimationFrame(this.animate)
+  }
+
+  private animate = () => {
+    if (this.isAnimating) { // wasn't cancelled
+      let side = this.computeBestSide(
+        this.pointerScreenX + this.windowController.getScrollLeft(),
+        this.pointerScreenY + this.windowController.getScrollTop()
+      )
+
+      if (side) {
+        let now = getTime()
+        this.handleSide(side, (now - this.msSinceRequest) / 1000)
+        this.requestAnimation(now)
+      } else {
+        this.isAnimating = false
+      }
+    }
+  }
+
+  private handleSide(side: Side, seconds: number) {
+    let { edge } = this
+    let { controller } = side
+    let invEdge = edge - side.distance
+    let speed = ((invEdge * invEdge) / (edge * edge)) * // quadratic
+      this.maxSpeed * seconds
+
+    switch (side.name) {
+
+      case 'left':
+        controller.setScrollLeft(controller.getScrollLeft() - speed)
+        break
+
+      case 'right':
+        controller.setScrollLeft(controller.getScrollLeft() + speed)
+        break
+
+      case 'top':
+        controller.setScrollTop(controller.getScrollTop() - speed)
+        break
+
+      case 'bottom':
+        controller.setScrollTop(controller.getScrollTop() + speed)
+        break
+    }
+  }
+
+  // left/top are relative to document topleft
+  private computeBestSide(left, top): Side | null {
+    let { edge } = this
+    let bestSide: Side | null = null
+
+    for (let controller of this.controllers) {
+      let rect = controller.rect
+      let leftDist = left - rect.left
+      let rightDist = rect.right - left
+      let topDist = top - rect.top
+      let bottomDist = rect.bottom - top
+
+      // completely within the rect?
+      if (leftDist >= 0 && rightDist >= 0 && topDist >= 0 && bottomDist >= 0) {
+
+        if (topDist <= edge && controller.canScrollUp() && (!bestSide || bestSide.distance > topDist)) {
+          bestSide = { controller, name: 'top', distance: topDist }
+        }
+
+        if (bottomDist <= edge && controller.canScrollDown() && (!bestSide || bestSide.distance > bottomDist)) {
+          bestSide = { controller, name: 'bottom', distance: bottomDist }
+        }
+
+        if (leftDist <= edge && controller.canScrollLeft() && (!bestSide || bestSide.distance > leftDist)) {
+          bestSide = { controller, name: 'left', distance: leftDist }
+        }
+
+        if (rightDist <= edge && controller.canScrollRight() && (!bestSide || bestSide.distance > rightDist)) {
+          bestSide = { controller, name: 'right', distance: rightDist }
+        }
+      }
+    }
+
+    return bestSide
+  }
+
+  private buildControllers() {
+    return this.queryScrollerEls().map((el) => {
+      if (el === window) {
+        return this.windowController
+      } else {
+        return new ElScrollControllerCache(new ElScrollController(el as HTMLElement))
+      }
+    })
+  }
+
+  private queryScrollerEls() {
+    let els = []
+
+    for (let query of this.scrollerQuery) {
+      if (typeof query === 'object') {
+        els.push(query)
+      } else {
+        els.push(...Array.prototype.slice.call(document.querySelectorAll(query)))
+      }
+    }
+
+    return els
+  }
+
+}

+ 12 - 9
src/dnd/ElementMirror.ts

@@ -1,6 +1,7 @@
 import { removeElement, applyStyle } from '../util/dom-manip'
 import { whenTransitionDone } from '../util/dom-event'
 import { Rect } from '../util/geom'
+import { WindowScrollControllerCache } from './scroll'
 
 /*
 An effect in which an element follows the movement of a pointer across the screen.
@@ -10,13 +11,14 @@ Must call start + handleMove + stop.
 export default class ElementMirror {
 
   isVisible: boolean = false // must be explicitly enabled
-  origX?: number
-  origY?: number
+  origScreenX?: number
+  origScreenY?: number
   deltaX?: number
   deltaY?: number
   sourceEl: HTMLElement | null = null
   mirrorEl: HTMLElement | null = null
-  sourceElRect: Rect | null = null
+  windowController: WindowScrollControllerCache
+  sourceElRect: Rect | null = null // screen coords relative to viewport
 
   // options that can be set directly by caller
   parentNode: HTMLElement = document.body
@@ -24,18 +26,19 @@ export default class ElementMirror {
   zIndex: number = 9999
   revertDuration: number = 0
 
-  start(sourceEl: HTMLElement, left: number, top: number) {
+  start(sourceEl: HTMLElement, pageX: number, pageY: number, windowController: WindowScrollControllerCache) {
     this.sourceEl = sourceEl
-    this.origX = left
-    this.origY = top
+    this.origScreenX = pageX - windowController.getScrollLeft()
+    this.origScreenY = pageY - windowController.getScrollTop()
     this.deltaX = 0
     this.deltaY = 0
+    this.windowController = windowController
     this.updateElPosition()
   }
 
-  handleMove(left: number, top: number) {
-    this.deltaX = left - this.origX!
-    this.deltaY = top - this.origY!
+  handleMove(pageX: number, pageY: number) {
+    this.deltaX = (pageX - this.windowController.getScrollLeft()) - this.origScreenX!
+    this.deltaY = (pageY - this.windowController.getScrollTop()) - this.origScreenY!
     this.updateElPosition()
   }
 

+ 33 - 16
src/dnd/FeaturefulElementDragging.ts

@@ -2,6 +2,8 @@ import { default as PointerDragging, PointerDragEvent } from './PointerDragging'
 import { preventSelection, allowSelection, preventContextMenu, allowContextMenu } from '../util/misc'
 import ElementMirror from './ElementMirror'
 import ElementDragging from './ElementDragging'
+import AutoScroller from './AutoScroller'
+import { WindowScrollControllerCache, WindowScrollController } from './scroll'
 
 /*
 Monitors dragging on an element. Has a number of high-level features:
@@ -12,8 +14,9 @@ Monitors dragging on an element. Has a number of high-level features:
 export default class FeaturefulElementDragging extends ElementDragging {
 
   pointer: PointerDragging
+  windowScrollController: WindowScrollControllerCache
   mirror: ElementMirror
-  mirrorNeedsRevert: boolean = false
+  autoScroller: AutoScroller
 
   // options that can be directly set by caller
   // the caller can also set the PointerDragging's options as well
@@ -21,7 +24,8 @@ export default class FeaturefulElementDragging extends ElementDragging {
   minDistance: number = 0
   touchScrollAllowed: boolean = true
 
-  isWatchingPointer: boolean = false
+  mirrorNeedsRevert: boolean = false
+  isInteracting: boolean = false // is the user validly moving the pointer? lasts until pointerup
   isDragging: boolean = false // is it INTENTFULLY dragging? lasts until after revert animation
   isDelayEnded: boolean = false
   isDistanceSurpassed: boolean = false
@@ -38,6 +42,7 @@ export default class FeaturefulElementDragging extends ElementDragging {
     pointer.emitter.on('pointerup', this.onPointerUp)
 
     this.mirror = new ElementMirror()
+    this.autoScroller = new AutoScroller()
   }
 
   destroy() {
@@ -45,23 +50,26 @@ export default class FeaturefulElementDragging extends ElementDragging {
   }
 
   onPointerDown = (ev: PointerDragEvent) => {
-    if (!this.isDragging) { // mainly so new drag doesn't happen while revert animation is going
-      this.isWatchingPointer = true
+    if (!this.isDragging) { // so new drag doesn't happen while revert animation is going
+
+      this.isInteracting = true
       this.isDelayEnded = false
       this.isDistanceSurpassed = false
 
       preventSelection(document.body)
       preventContextMenu(document.body)
 
-      this.origX = ev.pageX
-      this.origY = ev.pageY
-
       this.emitter.trigger('pointerdown', ev)
-      this.mirror.start(ev.subjectEl as HTMLElement, ev.pageX, ev.pageY)
 
-      // if moving is being ignored, don't fire any initial drag events
       if (!this.pointer.shouldIgnoreMove) {
-        // actions that could fire dragstart...
+        // actions related to initiating dragstart+dragmove+dragend...
+
+        this.origX = ev.pageX
+        this.origY = ev.pageY
+
+        this.windowScrollController = new WindowScrollControllerCache(new WindowScrollController())
+        this.mirror.start(ev.subjectEl as HTMLElement, ev.pageX, ev.pageY, this.windowScrollController)
+        this.autoScroller.start(ev.pageX, ev.pageY, this.windowScrollController)
 
         this.startDelay(ev)
 
@@ -73,9 +81,12 @@ export default class FeaturefulElementDragging extends ElementDragging {
   }
 
   onPointerMove = (ev: PointerDragEvent) => {
-    if (this.isWatchingPointer) { // if false, still waiting for previous drag's revert
+    if (this.isInteracting) { // if false, still waiting for previous drag's revert
+
       this.emitter.trigger('pointermove', ev)
+
       this.mirror.handleMove(ev.pageX, ev.pageY)
+      this.autoScroller.handleMove(ev.pageX, ev.pageY)
 
       if (!this.isDistanceSurpassed) {
         let dx = ev.pageX - this.origX!
@@ -96,18 +107,24 @@ export default class FeaturefulElementDragging extends ElementDragging {
   }
 
   onPointerUp = (ev: PointerDragEvent) => {
-    if (this.isWatchingPointer) { // if false, still waiting for previous drag's revert
-      this.isWatchingPointer = false
+    if (this.isInteracting) { // if false, still waiting for previous drag's revert
+      this.isInteracting = false
+
+      allowSelection(document.body)
+      allowContextMenu(document.body)
 
       this.emitter.trigger('pointerup', ev) // can potentially set mirrorNeedsRevert
 
+      if (!this.pointer.shouldIgnoreMove) { // because these things wouldn't have been started otherwise
+        this.autoScroller.stop()
+        this.windowScrollController.destroy()
+        this.windowScrollController = null
+      }
+
       if (this.isDragging) {
         this.tryStopDrag(ev)
       }
 
-      allowSelection(document.body)
-      allowContextMenu(document.body)
-
       if (this.delayTimeoutId) {
         clearTimeout(this.delayTimeoutId)
         this.delayTimeoutId = null

+ 223 - 0
src/dnd/scroll.ts

@@ -0,0 +1,223 @@
+import { Rect } from '../util/geom'
+import { computeRect } from '../util/dom-geom'
+
+// TODO: join with RTL scroller normalization utils
+
+export abstract class ScrollController {
+
+  abstract getScrollTop(): number
+  abstract getScrollLeft(): number
+  abstract setScrollTop(number): void
+  abstract setScrollLeft(number): void
+  abstract getClientWidth(): number
+  abstract getClientHeight(): number
+  abstract getScrollWidth(): number
+  abstract getScrollHeight(): number
+
+  getMaxScrollTop() {
+    return this.getScrollHeight() - this.getClientHeight()
+  }
+
+  getMaxScrollLeft() {
+    return this.getScrollWidth() - this.getClientWidth()
+  }
+
+  canScrollUp() {
+    return this.getScrollTop() > 0
+  }
+
+  canScrollDown() {
+    return this.getScrollTop() < this.getMaxScrollTop()
+  }
+
+  canScrollLeft() {
+    return this.getScrollLeft() > 0
+  }
+
+  canScrollRight() {
+    return this.getScrollLeft() < this.getMaxScrollLeft()
+  }
+
+}
+
+export class ElScrollController extends ScrollController {
+
+  el: HTMLElement
+
+  constructor(el: HTMLElement) {
+    super()
+    this.el = el
+  }
+
+  getScrollTop() {
+    return this.el.scrollTop
+  }
+
+  getScrollLeft() {
+    return this.el.scrollLeft
+  }
+
+  setScrollTop(n: number) {
+    this.el.scrollTop = n
+  }
+
+  setScrollLeft(n: number) {
+    this.el.scrollLeft = n
+  }
+
+  getScrollWidth() {
+    return this.el.scrollWidth
+  }
+
+  getScrollHeight() {
+    return this.el.scrollHeight
+  }
+
+  getClientHeight() {
+    return this.el.clientHeight
+  }
+
+  getClientWidth() {
+    return this.el.clientWidth
+  }
+
+}
+
+export class WindowScrollController extends ElScrollController {
+
+  constructor() {
+    super(document.documentElement)
+  }
+
+  getScrollTop() {
+    return window.scrollY
+  }
+
+  getScrollLeft() {
+    return window.scrollX
+  }
+
+  setScrollTop(n: number) {
+    window.scroll(window.scrollX, n)
+  }
+
+  setScrollLeft(n: number) {
+    window.scroll(n, window.scrollY)
+  }
+
+}
+
+export abstract class ScrollControllerCache extends ScrollController {
+
+  rect: Rect
+  scrollController: ScrollController
+
+  protected scrollTop: number
+  protected scrollLeft: number
+  protected scrollWidth: number
+  protected scrollHeight: number
+  protected clientWidth: number
+  protected clientHeight: number
+
+  constructor(scrollController: ScrollController) {
+    super()
+    this.scrollController = scrollController
+    this.scrollTop = scrollController.getScrollTop()
+    this.scrollLeft = scrollController.getScrollLeft()
+    this.scrollWidth = scrollController.getScrollWidth()
+    this.scrollHeight = scrollController.getScrollHeight()
+    this.clientWidth = scrollController.getClientWidth()
+    this.clientHeight = scrollController.getClientHeight()
+    this.rect = this.computeRect() // do last in case it needs cached values
+    this.getEventTarget().addEventListener('scroll', this.handleScroll)
+  }
+
+  destroy() {
+    this.getEventTarget().removeEventListener('scroll', this.handleScroll)
+  }
+
+  handleScroll = () => {
+    this.scrollTop = this.scrollController.getScrollTop()
+    this.scrollLeft = this.scrollController.getScrollLeft()
+    this._handleScroll()
+  }
+
+  _handleScroll() { }
+
+  abstract computeRect(): Rect
+  abstract getEventTarget(): EventTarget
+
+  getScrollTop() {
+    return this.scrollTop
+  }
+
+  getScrollLeft() {
+    return this.scrollLeft
+  }
+
+  setScrollTop(n) {
+    this.scrollController.setScrollTop(n)
+    this.scrollTop = Math.max(Math.min(n, this.getMaxScrollTop()), 0) // in meantime before handleScroll
+    this._handleScroll()
+  }
+
+  setScrollLeft(n) {
+    this.scrollController.setScrollLeft(n)
+    this.scrollLeft = Math.max(Math.min(n, this.getMaxScrollLeft()), 0) // in meantime before handleScroll
+    this._handleScroll()
+  }
+
+  getClientWidth() {
+    return this.clientWidth
+  }
+
+  getClientHeight() {
+    return this.clientHeight
+  }
+
+  getScrollWidth() {
+    return this.scrollWidth
+  }
+
+  getScrollHeight() {
+    return this.scrollHeight
+  }
+
+}
+
+export class ElScrollControllerCache extends ScrollControllerCache {
+
+  scrollController: ElScrollController
+
+  computeRect() {
+    return computeRect(this.scrollController.el)
+  }
+
+  getEventTarget(): EventTarget {
+    return this.scrollController.el
+  }
+
+}
+
+export class WindowScrollControllerCache extends ScrollControllerCache {
+
+  scrollController: WindowScrollController
+
+  computeRect(): Rect {
+    return { // computeViewportRect needed anymore?
+      left: this.scrollLeft,
+      right: this.scrollLeft + this.clientWidth, // clientWidth best?
+      top: this.scrollTop,
+      bottom: this.scrollTop + this.clientHeight // clientHeight best?
+    }
+  }
+
+  getEventTarget(): EventTarget {
+    return window
+  }
+
+  _handleScroll() {
+    this.rect = this.computeRect()
+  }
+
+}