Jelajahi Sumber

overhaul interactions again

Adam Shaw 7 tahun lalu
induk
melakukan
87ea06a032

+ 11 - 19
src/Calendar.ts

@@ -918,31 +918,23 @@ export default class Calendar {
 
     let selection = parseSelection(selectionInput, this.dateEnv)
     if (selection) {
-      this.setSelectionState(selection)
+      this.dispatch({
+        type: 'SELECT',
+        selection: selection
+      })
       this.triggerSelect(selection, this.view)
     } // otherwise, throw error?
   }
 
 
   // public method
-  unselect() {
-    this.clearSelectionState()
-    this.triggerUnselect(this.view)
-  }
-
-
-  setSelectionState(selection) {
-    this.dispatch({
-      type: 'SELECT',
-      selection: selection
-    })
-  }
-
-
-  clearSelectionState() {
-    this.dispatch({
-      type: 'UNSELECT'
-    })
+  unselect(ev?: UIEvent) {
+    if (this.state.selection) {
+      this.dispatch({
+        type: 'UNSELECT'
+      })
+      this.triggerUnselect(this.view, ev)
+    }
   }
 
 

+ 11 - 3
src/agenda/TimeGrid.ts

@@ -56,8 +56,8 @@ export default class TimeGrid extends DateComponent {
   slatEls: HTMLElement[] // elements running horizontally across all columns
   nowIndicatorEls: HTMLElement[]
 
-  colCoordCache: any
-  slatCoordCache: any
+  colCoordCache: CoordCache
+  slatCoordCache: CoordCache
 
   rootBgContainerEl: HTMLElement
   bottomRuleEl: HTMLElement // hidden by default
@@ -89,6 +89,8 @@ export default class TimeGrid extends DateComponent {
       } else {
         segs[i].col = segs[i].dayIndex
       }
+
+      segs[i].component = this
     }
 
     return segs
@@ -588,7 +590,13 @@ export default class TimeGrid extends DateComponent {
         return {
           range: new UnzonedRange(start, end),
           isAllDay: false,
-          // el: this.colEls[colIndex]
+          el: this.colEls[colIndex],
+          rect: {
+            left: colCoordCache.getLeftOffset(colIndex),
+            right: colCoordCache.getRightOffset(colIndex),
+            top: slatTop,
+            bottom: slatTop + slatHeight
+          }
         }
       }
     }

+ 8 - 0
src/agenda/TimeGridHelperRenderer.ts

@@ -39,4 +39,12 @@ export default class TimeGridHelperRenderer extends HelperRenderer {
     return helperNodes // must return the elements rendered
   }
 
+  computeSize() {
+    this.component.computeSegVerticals(this.segs)
+  }
+
+  assignSize() {
+    this.component.assignSegVerticals(this.segs)
+  }
+
 }

+ 16 - 7
src/basic/DayGrid.ts

@@ -58,8 +58,8 @@ export default class DayGrid extends DateComponent {
   rowEls: HTMLElement[] // set of fake row elements
   cellEls: HTMLElement[] // set of whole-day elements comprising the row's background
 
-  rowCoordCache: any
-  colCoordCache: any
+  rowCoordCache: CoordCache
+  colCoordCache: CoordCache
 
   // isRigid determines whether the individual rows should ignore the contents and be a constant height.
   // Relies on the view's colCnt and rowCnt. In the future, this component should probably be self-sufficient.
@@ -82,6 +82,7 @@ export default class DayGrid extends DateComponent {
 
     for (let i = 0; i < segs.length; i++) {
       let seg = segs[i]
+      seg.component = this
 
       if (this.isRTL) {
         seg.leftCol = this.daysPerRow - 1 - seg.lastRowDayIndex
@@ -290,16 +291,24 @@ export default class DayGrid extends DateComponent {
   ------------------------------------------------------------------------------------------------------------------*/
 
 
-  queryHit(leftOffset, topOffset): Selection {
-    if (this.colCoordCache.isLeftInBounds(leftOffset) && this.rowCoordCache.isTopInBounds(topOffset)) {
-      let col = this.colCoordCache.getHorizontalIndex(leftOffset)
-      let row = this.rowCoordCache.getVerticalIndex(topOffset)
+  queryHit(leftOffset, topOffset): Selection { // why is this a Selection?
+    let { colCoordCache, rowCoordCache } = this
+
+    if (colCoordCache.isLeftInBounds(leftOffset) && rowCoordCache.isTopInBounds(topOffset)) {
+      let col = colCoordCache.getHorizontalIndex(leftOffset)
+      let row = rowCoordCache.getVerticalIndex(topOffset)
 
       if (row != null && col != null) {
         return {
           range: this.getCellRange(row, col),
           isAllDay: true,
-          // el: this.getCellEl(row, col)
+          el: this.getCellEl(row, col),
+          rect: {
+            left: colCoordCache.getLeftOffset(col),
+            right: colCoordCache.getRightOffset(col),
+            top: rowCoordCache.getTopOffset(row),
+            bottom: rowCoordCache.getBottomOffset(row)
+          }
         }
       }
     }

+ 48 - 23
src/common/GlobalContext.ts

@@ -1,62 +1,87 @@
 import DateComponent from '../component/DateComponent'
+import PointerDragListener from '../dnd/PointerDragListener'
 import DateClicking from '../interactions/DateClicking'
 import DateSelecting from '../interactions/DateSelecting'
 import EventClicking from '../interactions/EventClicking'
 import EventHovering from '../interactions/EventHovering'
 import EventDragging from '../interactions/EventDragging'
+import Calendar from '../Calendar'
 
-let componentCnt = 0
-let componentHash = {}
-let listenerHash = {}
+// TODO: how to accept external drags?
 
-export default {
+export class GlobalContext { // TODO: rename file to something better
+
+  pointerUpListener: PointerDragListener
+  componentCnt: number = 0
+  componentHash = {}
+  listenerHash = {}
+  selectedCalendar: Calendar // *date* selection
+  eventSelectedCalendar: Calendar
 
   registerComponent(component: DateComponent) {
-    componentHash[component.uid] = component
-    componentCnt++
+    this.componentHash[component.uid] = component
 
-    if (componentCnt === 1) {
+    if (!(this.componentCnt++)) {
       this.bind()
     }
 
     this.bindComponent(component)
-  },
+  }
 
   unregisterComponent(component: DateComponent) {
-    delete componentHash[component.uid]
-    componentCnt--
+    delete this.componentHash[component.uid]
 
-    if (componentCnt === 0) {
+    if (!(--this.componentCnt)) {
       this.unbind()
     }
 
     this.unbindComponent(component)
-  },
+  }
 
   bind() {
-    this.dateSelecting = new DateSelecting(componentHash)
-    this.eventDragging = new EventDragging(componentHash)
-  },
+    this.pointerUpListener = new PointerDragListener(document as any)
+    this.pointerUpListener.ignoreMove = true
+    this.pointerUpListener.on('pointerup', this.onPointerUp)
+  }
 
   unbind() {
-    this.dateSelecting.destroy()
-    this.eventDragging.destroy()
-  },
+    this.pointerUpListener.destroy()
+    this.pointerUpListener = null
+  }
 
   bindComponent(component: DateComponent) {
-    listenerHash[component.uid] = {
+    this.listenerHash[component.uid] = {
       dateClicking: new DateClicking(component),
+      dateSelecting: new DateSelecting(component, globalContext),
       eventClicking: new EventClicking(component),
-      eventHovering: new EventHovering(component)
+      eventHovering: new EventHovering(component),
+      eventDragging: new EventDragging(component, globalContext)
     }
-  },
+  }
 
   unbindComponent(component: DateComponent) {
-    let listeners = listenerHash[component.uid]
+    let listeners = this.listenerHash[component.uid]
+
     listeners.dateClicking.destroy()
+    listeners.dateSelecting.destroy()
     listeners.eventClicking.destroy()
     listeners.eventHovering.destroy()
-    delete listenerHash[component.uid]
+    listeners.eventDragging.destroy()
+
+    delete this.listenerHash[component.uid]
+  }
+
+  onPointerUp = (ev) => {
+    let { listenerHash } = this
+    let { isTouchScroll, downEl } = this.pointerUpListener
+
+    for (let id in listenerHash) {
+      listenerHash[id].dateSelecting.onDocumentPointerUp(ev, isTouchScroll, downEl)
+      listenerHash[id].eventDragging.onDocumentPointerUp(ev, isTouchScroll, downEl)
+    }
   }
 
 }
+
+let globalContext = new GlobalContext()
+export default globalContext

+ 49 - 43
src/component/DateComponent.ts

@@ -31,6 +31,7 @@ export interface DateComponentRenderState {
 // NOTE: for fg-events, eventRange.range is NOT sliced,
 // thus, we need isStart/isEnd
 export interface Seg {
+  component: DateComponent
   isStart: boolean
   isEnd: boolean
   eventRange?: EventRenderRange
@@ -112,12 +113,6 @@ export default abstract class DateComponent extends Component {
   }
 
 
-  setElement(el) {
-    el.setAttribute('data-fc-com-uid', this.uid)
-    super.setElement(el)
-  }
-
-
   addChild(child) {
     if (!this.childrenByUid[child.uid]) {
       this.childrenByUid[child.uid] = child
@@ -574,13 +569,17 @@ export default abstract class DateComponent extends Component {
 
 
   renderDragState(dragState: EventInteractionState) {
-    this.updateEventInteractionState(dragState)
+    if (dragState.origSeg) {
+      this.hideRelatedSegs(dragState.origSeg)
+    }
     this.renderDrag(dragState.eventStore, dragState.origSeg, dragState.isTouch)
   }
 
 
   unrenderDragState() {
-    this.updateEventInteractionState()
+    if (this.dragState.origSeg) {
+      this.showRelatedSegs(this.dragState.origSeg)
+    }
     this.unrenderDrag()
   }
 
@@ -603,14 +602,18 @@ export default abstract class DateComponent extends Component {
   // ---------------------------------------------------------------------------------------------------------------
 
 
-  renderEventResizeState(dragState: EventInteractionState) {
-    this.updateEventInteractionState(dragState)
-    this.renderEventResize(dragState.eventStore, dragState.origSeg, dragState.isTouch)
+  renderEventResizeState(eventResizeState: EventInteractionState) {
+    if (eventResizeState.origSeg) {
+      this.hideRelatedSegs(eventResizeState.origSeg)
+    }
+    this.renderEventResize(eventResizeState.eventStore, eventResizeState.origSeg, eventResizeState.isTouch)
   }
 
 
   unrenderEventResizeState() {
-    this.updateEventInteractionState()
+    if (this.eventResizeState.origSeg) {
+      this.showRelatedSegs(this.eventResizeState.origSeg)
+    }
     this.unrenderEventResize()
   }
 
@@ -627,47 +630,32 @@ export default abstract class DateComponent extends Component {
   }
 
 
-  // Event Interaction Utils
+  // Seg Utils
   // -----------------------------------------------------------------------------------------------------------------
 
 
-  updateEventInteractionState(dragState?: EventInteractionState) {
-    let eventDefId = (dragState && dragState.origSeg) ? dragState.origSeg.eventRange.eventDef.defId : null
-
-    if (this.interactingEventDefId && this.interactingEventDefId !== eventDefId) {
-      this.showEventByDefId(this.interactingEventDefId)
-      this.interactingEventDefId = null
-    }
-
-    if (eventDefId && !this.interactingEventDefId) {
-      this.hideEventsByDefId(eventDefId)
-      this.interactingEventDefId = eventDefId
-    }
+  hideRelatedSegs(targetSeg: Seg) {
+    this.getRelatedSegs(targetSeg).forEach(function(seg) {
+      seg.el.style.visibility = 'hidden'
+    })
   }
 
 
-  // Hides all rendered event segments linked to the given event
-  showEventByDefId(eventDefId) {
-    this.getAllEventSegs().forEach(function(seg) {
-      if (
-        seg.eventRange.eventDef.id === eventDefId &&
-        seg.el // necessary?
-      ) {
-        seg.el.style.visibility = ''
-      }
+  showRelatedSegs(targetSeg: Seg) {
+    this.getRelatedSegs(targetSeg).forEach(function(seg) {
+      seg.el.style.visibility = ''
     })
   }
 
 
-  // Shows all rendered event segments linked to the given event
-  hideEventsByDefId(eventDefId) {
-    this.getAllEventSegs().forEach(function(seg) {
-      if (
-        seg.eventRange.eventDef.id === eventDefId &&
-        seg.el // necessary?
-      ) {
-        seg.el.style.visibility = 'hidden'
-      }
+  getRelatedSegs(targetSeg: Seg) {
+    let targetEventDef = targetSeg.eventRange.eventDef
+
+    return this.getAllEventSegs().filter(function(seg: Seg) {
+      let segEventDef = seg.eventRange.eventDef
+
+      return segEventDef.defId === targetEventDef.defId || // include defId as well???
+        segEventDef.groupId && segEventDef.groupId === targetEventDef.groupId
     })
   }
 
@@ -782,6 +770,24 @@ export default abstract class DateComponent extends Component {
   }
 
 
+  /*
+  ------------------------------------------------------------------------------------------------------------------*/
+
+
+  computeHelperSize() {
+    if (this.helperRenderer) {
+      this.helperRenderer.computeSize()
+    }
+  }
+
+
+  assignHelperSize() {
+    if (this.helperRenderer) {
+      this.helperRenderer.assignSize()
+    }
+  }
+
+
   /* Converting selection/eventRanges -> segs
   ------------------------------------------------------------------------------------------------------------------*/
 

+ 10 - 0
src/component/renderers/HelperRenderer.ts

@@ -8,6 +8,7 @@ export default abstract class HelperRenderer {
   component: any
   eventRenderer: any
   helperEls: HTMLElement[]
+  segs: Seg[]
 
 
   constructor(component, eventRenderer) {
@@ -57,6 +58,15 @@ export default abstract class HelperRenderer {
     }
 
     this.helperEls = this.renderSegs(segs, sourceSeg)
+    this.segs = segs
+  }
+
+
+  computeSize() {
+  }
+
+
+  assignSize() {
   }
 
 

+ 20 - 4
src/dnd/DragMirror.ts

@@ -17,13 +17,15 @@ export default class DragMirror {
   sourceElRect: any
   needsRevert: boolean = true
   revertDuration: number = 1000
+  isReverting: boolean = false
+  revertDoneCallback: any
 
   constructor(dragListener: IntentfulDragListener) {
     this.dragListener = dragListener
     dragListener.on('pointerdown', this.onPointerDown)
     dragListener.on('dragstart', this.onDragStart)
     dragListener.on('dragmove', this.onDragMove)
-    dragListener.on('dragend', this.onDragEnd)
+    dragListener.on('pointerup', this.onPointerUp)
   }
 
   enable() {
@@ -58,7 +60,6 @@ export default class DragMirror {
   }
 
   onDragStart = (ev: PointerDragEvent) => {
-    this.sourceElRect = computeRect(this.sourceEl)
     this.handleDragEvent(ev)
   }
 
@@ -66,11 +67,11 @@ export default class DragMirror {
     this.handleDragEvent(ev)
   }
 
-  onDragEnd = (ev: PointerDragEvent) => {
+  onPointerUp = (ev: PointerDragEvent) => {
 
     if (this.mirrorEl) {
 
-      if (this.needsRevert && (this.deltaX || this.deltaY)) {
+      if (this.isEnabled && this.needsRevert && (this.deltaX || this.deltaY)) {
         this.revertAndRemove(this.mirrorEl)
       } else {
         removeElement(this.mirrorEl)
@@ -80,10 +81,13 @@ export default class DragMirror {
     }
 
     this.sourceEl = null
+    this.sourceElRect = null // so knows to recompute next time
   }
 
   // can happen after drag has finished and a new one begins
   revertAndRemove(mirrorEl) {
+    this.isReverting = true
+
     mirrorEl.style.transition =
       'top ' + this.revertDuration + 'ms,' +
       'left ' + this.revertDuration + 'ms'
@@ -96,6 +100,12 @@ export default class DragMirror {
     whenTransitionDone(mirrorEl, () => {
       mirrorEl.style.transition = ''
       removeElement(mirrorEl)
+      this.isReverting = false
+
+      if (this.revertDoneCallback) {
+        this.revertDoneCallback()
+        this.revertDoneCallback = null
+      }
     })
   }
 
@@ -107,6 +117,11 @@ export default class DragMirror {
 
   updateElPosition() {
     if (this.isEnabled) {
+
+      if (!this.sourceElRect) {
+        this.sourceElRect = computeRect(this.sourceEl)
+      }
+
       applyStyle(this.getMirrorEl(), {
         left: this.sourceElRect.left + this.deltaX,
         top: this.sourceElRect.top + this.deltaY
@@ -129,6 +144,7 @@ export default class DragMirror {
 
       // TODO: do fixed positioning?
       // TODO: how would that work with auto-scrolling?
+      // TODO: attache to .fc so that `.fc fc-not-end` will work
 
       applyStyle(mirrorEl, {
         position: 'absolute',

+ 91 - 43
src/dnd/HitDragListener.ts

@@ -1,44 +1,56 @@
 import EmitterMixin from '../common/EmitterMixin'
 import { PointerDragEvent } from './PointerDragListener'
-import { default as IntentfulDragListener, IntentfulDragOptions } from './IntentfulDragListener'
+import IntentfulDragListener from './IntentfulDragListener'
 import DateComponent, { DateComponentHash } from '../component/DateComponent'
 import { Selection } from '../reducers/selection'
+import { computeRect } from '../util/dom-geom'
+import { constrainPoint, intersectRects, getRectCenter, diffPoints } from '../util/geom'
 
 export interface Hit extends Selection {
   component: DateComponent
 }
 
 /*
-fires:
+fires (none will be fired if no initial hit):
+- pointerdown
 - dragstart
-- hitover
-- hitout
+- hitover - always fired in beginning
+- hitout - only fired when pointer moves away. not fired at drag end
+- pointerup
 - dragend
 */
 export default class HitDragListener {
 
+  component: DateComponent
   droppableComponentHash: DateComponentHash
-  emitter: EmitterMixin
   dragListener: IntentfulDragListener
+  emitter: EmitterMixin
   initialHit: Hit
   movingHit: Hit
-  finalHit: Hit // won't ever be populated if options.ignoreMove is false
+  finalHit: Hit // won't ever be populated if ignoreMove
+  coordAdjust: any
 
-  constructor(options: IntentfulDragOptions, droppableComponent: DateComponent, droppableComponentHash?: DateComponentHash) {
+  // options
+  subjectCenter: boolean = false
 
-    if (droppableComponent) {
-      this.droppableComponentHash = { [droppableComponent.uid]: droppableComponent }
-    } else {
+  constructor(component: DateComponent, droppableComponentHash?: DateComponentHash) {
+    this.component = component
+    this.droppableComponentHash = droppableComponentHash
+
+    if (droppableComponentHash) {
       this.droppableComponentHash = droppableComponentHash
+    } else {
+      this.droppableComponentHash = { [component.uid]: component }
     }
 
+    let dragListener = this.dragListener = new IntentfulDragListener(component.el)
+    dragListener.on('pointerdown', this.onPointerDown)
+    dragListener.on('dragstart', this.onDragStart)
+    dragListener.on('dragmove', this.onDragMove)
+    dragListener.on('pointerup', this.onPointerUp)
+    dragListener.on('dragend', this.onDragEnd)
+
     this.emitter = new EmitterMixin()
-    this.dragListener = new IntentfulDragListener(options)
-    this.dragListener.on('pointerdown', this.onPointerDown)
-    this.dragListener.on('dragstart', this.onDragStart)
-    this.dragListener.on('dragmove', this.onDragMove)
-    this.dragListener.on('dragend', this.onDragEnd)
-    this.dragListener.on('pointerup', this.onPointerUp)
   }
 
   destroy() {
@@ -50,56 +62,94 @@ export default class HitDragListener {
   }
 
   onPointerDown = (ev: PointerDragEvent) => {
-    this.emitter.trigger('pointerdown', ev)
-    this.prepareComponents()
-    this.initialHit = this.queryHit(ev.pageX, ev.pageY)
+    this.initialHit = null
+    this.movingHit = null
     this.finalHit = null
+
+    this.prepareComponents()
+    this.processFirstCoord(ev)
+
+    let { pointerListener } = this.dragListener
+
+    if (this.initialHit) {
+      pointerListener.ignoreMove = false
+      this.emitter.trigger('pointerdown', ev)
+    } else {
+      pointerListener.ignoreMove = true
+    }
   }
 
-  onDragStart = (ev: PointerDragEvent) => {
-    this.emitter.trigger('dragstart', ev)
+  // sets initialHit
+  // sets coordAdjust
+  processFirstCoord(ev: PointerDragEvent) {
+    let origPoint = { left: ev.pageX, top: ev.pageY }
+    let adjustedPoint = origPoint
+    let subjectEl = ev.el as HTMLElement
+    let subjectRect
+
+    if (subjectEl !== (document as any)) {
+      subjectRect = computeRect(subjectEl)
+      adjustedPoint = constrainPoint(adjustedPoint, subjectRect)
+    }
+
+    let initialHit = this.initialHit = this.queryHit(adjustedPoint.left, adjustedPoint.top)
+
+    if (initialHit) {
+      if (this.subjectCenter && subjectRect) {
+        let slicedSubjectRect = intersectRects(subjectRect, initialHit.rect)
+        if (slicedSubjectRect) {
+          adjustedPoint = getRectCenter(slicedSubjectRect)
+        }
+      }
 
-    // querying the first hovered hit is considered a 'move', so ignore if necessary
-    if (!this.dragListener.pointerListener.ignoreMove) {
-      this.handleMove(ev)
+      this.coordAdjust = diffPoints(adjustedPoint, origPoint)
     }
   }
 
+  onDragStart = (ev: PointerDragEvent) => {
+    this.emitter.trigger('dragstart', ev)
+    this.handleMove(ev)
+  }
+
   onDragMove = (ev: PointerDragEvent) => {
-    this.emitter.trigger('pointermove', ev)
+    this.emitter.trigger('dragmove', ev)
     this.handleMove(ev)
   }
 
+  onPointerUp = (ev: PointerDragEvent) => {
+    let { pointerListener } = this.dragListener
+
+    if (!pointerListener.ignoreMove) { // cancelled in onPointerDown?
+      this.emitter.trigger('pointerup', ev)
+    }
+  }
+
   onDragEnd = (ev: PointerDragEvent) => {
     this.finalHit = this.movingHit
-    this.clearMovingHit()
+    this.movingHit = null
     this.emitter.trigger('dragend', ev)
   }
 
-  onPointerUp = (ev: PointerDragEvent) => {
-    this.emitter.trigger('pointerup', ev)
-  }
-
   handleMove(ev: PointerDragEvent) {
-    let hit = this.queryHit(ev.pageX, ev.pageY)
+    let hit = this.queryHit(
+      ev.pageX + this.coordAdjust.left,
+      ev.pageY + this.coordAdjust.top
+    )
 
     if (!this.movingHit || !isHitsEqual(this.movingHit, hit)) {
-      this.clearMovingHit()
+
+      if (this.movingHit) {
+        this.emitter.trigger('hitout', this.movingHit, ev)
+        this.movingHit = null
+      }
 
       if (hit) {
         this.movingHit = hit
-        this.emitter.trigger('hitover', hit)
+        this.emitter.trigger('hitover', hit, ev)
       }
     }
   }
 
-  clearMovingHit() {
-    if (this.movingHit) {
-      this.emitter.trigger('hitout', this.movingHit)
-      this.movingHit = null
-    }
-  }
-
   prepareComponents() {
     for (let id in this.droppableComponentHash) {
       let component = this.droppableComponentHash[id]
@@ -117,8 +167,6 @@ export default class HitDragListener {
         return hit
       }
     }
-
-    return null
   }
 
 }
@@ -137,7 +185,7 @@ export function isHitsEqual(hit0: Selection, hit1: Selection) {
   }
 
   for (let propName in hit1) {
-    if (propName !== 'range') {
+    if (propName !== 'range' && propName !== 'component' && propName !== 'rect') {
       if (hit0[propName] !== hit1[propName]) {
         return false
       }

+ 82 - 75
src/dnd/IntentfulDragListener.ts

@@ -1,21 +1,7 @@
 import { default as EmitterMixin } from '../common/EmitterMixin'
 import { default as PointerDragListener, PointerDragEvent } from './PointerDragListener'
 import { preventSelection, allowSelection, preventContextMenu, allowContextMenu } from '../util/misc'
-
-export interface IntentfulDragOptions {
-  containerEl: HTMLElement
-  selector?: string
-  ignoreMove?: any // set to false if you don't need to track moves, for performance reasons
-  touchMinDistance?: number
-  mouseMinDistance?: number
-  touchDelay?: number | ((ev: PointerDragEvent) => number)
-  mouseDelay?: number | ((ev: PointerDragEvent) => number)
-
-  // if set to false, if there's a touch scroll after pointdown but before the drag begins,
-  // it won't be considered a drag and dragstart/dragmove/dragend won't be fired.
-  // if the drag initiates and this value is set to false, touch dragging will be prevented.
-  touchScrollAllowed?: boolean
-}
+import DragMirror from './DragMirror'
 
 /*
 fires:
@@ -23,35 +9,37 @@ fires:
 - dragstart
 - dragmove
 - pointermove
-- dragend
-- pointerup
+- pointerup (always happens before dragend!)
+- dragend (happens after any revert animation)
 */
 export default class IntentfulDragListener {
 
   pointerListener: PointerDragListener
   emitter: EmitterMixin
+  dragMirror: DragMirror // TODO: move out of here?
 
-  options: IntentfulDragOptions
+  // options
+  delay: number
+  minDistance: number = 0
+  touchScrollAllowed: boolean = true
 
-  isDragging: boolean = false // is it INTENTFULLY dragging?
+  isWatchingPointer: boolean = false
+  isDragging: boolean = false // is it INTENTFULLY dragging? lasts until after revert animation // TODO: exclude revert anim?
   isDelayEnded: boolean = false
   isDistanceSurpassed: boolean = false
 
-  delay: number
   delayTimeoutId: number
-
-  minDistance: number
   origX: number
   origY: number
 
-  constructor(options: IntentfulDragOptions) {
-    this.options = options
-    this.pointerListener = new PointerDragListener(options.containerEl, options.selector, options.ignoreMove)
+  constructor(containerEl: HTMLElement) {
     this.emitter = new EmitterMixin()
+    this.dragMirror = new DragMirror(this)
 
-    this.pointerListener.on('pointerdown', this.onPointerDown)
-    this.pointerListener.on('pointermove', this.onPointerMove)
-    this.pointerListener.on('pointerup', this.onPointerUp)
+    let pointerListener = this.pointerListener = new PointerDragListener(containerEl)
+    pointerListener.on('pointerdown', this.onPointerDown)
+    pointerListener.on('pointermove', this.onPointerMove)
+    pointerListener.on('pointerup', this.onPointerUp)
   }
 
   destroy() {
@@ -63,64 +51,72 @@ export default class IntentfulDragListener {
   }
 
   onPointerDown = (ev: PointerDragEvent) => {
-    this.emitter.trigger('pointerdown', ev)
-
-    preventSelection(document.body)
-    preventContextMenu(document.body)
+    if (!this.isDragging) { // mainly so new drag doesn't happen while revert animation is going
+      this.isWatchingPointer = true
+      this.isDelayEnded = false
+      this.isDistanceSurpassed = false
 
-    let minDistance = this.options[ev.isTouch ? 'touchMinDistance' : 'mouseMinDistance']
-    let delay = this.options[ev.isTouch ? 'touchDelay' : 'mouseDelay']
+      preventSelection(document.body)
+      preventContextMenu(document.body)
 
-    this.minDistance = minDistance || 0
-    this.delay = typeof delay === 'function' ? (delay as any)(ev) : delay
+      this.origX = ev.pageX
+      this.origY = ev.pageY
 
-    this.origX = ev.pageX
-    this.origY = ev.pageY
+      this.emitter.trigger('pointerdown', ev)
 
-    this.isDelayEnded = false
-    this.isDistanceSurpassed = false
+      // if moving is being ignored, don't fire any initial drag events
+      if (!this.pointerListener.ignoreMove) {
+        // actions that could fire dragstart...
 
-    this.startDelay(ev)
+        this.startDelay(ev)
 
-    if (!this.minDistance) {
-      this.handleDistanceSurpassed(ev)
+        if (!this.minDistance) {
+          this.handleDistanceSurpassed(ev)
+        }
+      }
     }
   }
 
   onPointerMove = (ev: PointerDragEvent) => {
-    this.emitter.trigger('pointermove', ev)
-
-    if (!this.isDistanceSurpassed) {
-      let dx = ev.pageX - this.origX
-      let dy = ev.pageY - this.origY
-      let minDistance = this.minDistance
-      let distanceSq // current distance from the origin, squared
-
-      distanceSq = dx * dx + dy * dy
-      if (distanceSq >= minDistance * minDistance) { // use pythagorean theorem
-        this.handleDistanceSurpassed(ev)
+    if (this.isWatchingPointer) { // if false, still waiting for previous drag's revert
+      this.emitter.trigger('pointermove', ev)
+
+      if (!this.isDistanceSurpassed) {
+        let dx = ev.pageX - this.origX
+        let dy = ev.pageY - this.origY
+        let minDistance = this.minDistance
+        let distanceSq // current distance from the origin, squared
+
+        distanceSq = dx * dx + dy * dy
+        if (distanceSq >= minDistance * minDistance) { // use pythagorean theorem
+          this.handleDistanceSurpassed(ev)
+        }
       }
-    }
 
-    if (this.isDragging) {
-      this.emitter.trigger('dragmove', ev)
+      if (this.isDragging) {
+        this.emitter.trigger('dragmove', ev)
+      }
     }
   }
 
   onPointerUp = (ev: PointerDragEvent) => {
-    if (this.isDragging) {
-      this.stopDrag(ev)
-    }
+    if (this.isWatchingPointer) { // if false, still waiting for previous drag's revert
+      this.isWatchingPointer = false
 
-    allowSelection(document.body)
-    allowContextMenu(document.body)
+      this.emitter.trigger('pointerup', ev) // can potentially set needsRevert
 
-    if (this.delayTimeoutId) {
-      clearTimeout(this.delayTimeoutId)
-      this.delayTimeoutId = null
-    }
+      if (this.isDragging) {
+        this.tryStopDrag(ev)
+      }
 
-    this.emitter.trigger('pointerup', ev)
+      allowSelection(document.body)
+      allowContextMenu(document.body)
+
+      if (this.delayTimeoutId) {
+        clearTimeout(this.delayTimeoutId)
+        this.delayTimeoutId = null
+      }
+    }
   }
 
   startDelay(ev: PointerDragEvent) {
@@ -136,29 +132,40 @@ export default class IntentfulDragListener {
 
   handleDelayEnd(ev: PointerDragEvent) {
     this.isDelayEnded = true
-    this.startDrag(ev)
+    this.tryStartDrag(ev)
   }
 
   handleDistanceSurpassed(ev: PointerDragEvent) {
     this.isDistanceSurpassed = true
-    this.startDrag(ev)
+    this.tryStartDrag(ev)
   }
 
-  startDrag(ev: PointerDragEvent) { // will only start if appropriate
+  tryStartDrag(ev: PointerDragEvent) {
     if (this.isDelayEnded && this.isDistanceSurpassed) {
-      let touchScrollAllowed = this.options.touchScrollAllowed
-
-      if (!this.pointerListener.isTouchScroll || touchScrollAllowed) {
-        this.emitter.trigger('dragstart', ev)
+      if (!this.pointerListener.isTouchScroll || this.touchScrollAllowed) {
         this.isDragging = true
+        this.emitter.trigger('dragstart', ev)
 
-        if (touchScrollAllowed === false) {
+        if (this.touchScrollAllowed === false) {
           this.pointerListener.cancelTouchScroll()
         }
       }
     }
   }
 
+  tryStopDrag(ev) {
+    let stopDrag = this.stopDrag.bind(this, ev) // bound with args
+
+    if (this.dragMirror.isReverting) {
+      this.dragMirror.revertDoneCallback = stopDrag // will clear itself
+    } else {
+      // HACK - we want to make sure dragend fires after all pointerup events.
+      // Without doing this hack, pointer-up event propogation might reach an ancestor
+      // node after dragend
+      setTimeout(stopDrag, 0)
+    }
+  }
+
   stopDrag(ev) {
     this.emitter.trigger('dragend', ev)
     this.isDragging = false

+ 40 - 36
src/dnd/PointerDragListener.ts

@@ -8,30 +8,36 @@ import { isPrimaryMouseButton } from '../util/dom-event'
 export interface PointerDragEvent {
   origEvent: UIEvent
   isTouch: boolean
-  el: HTMLElement
+  el: HTMLElement // rename to target?
   pageX: number
   pageY: number
 }
 
 export type PointerEventHandler = (ev: PointerDragEvent) => void
 
+/*
+events:
+- pointerdown
+- pointermove
+- pointerup
+*/
 export default class PointerDragListener {
 
   containerEl: HTMLElement
-  selector: string
-  ignoreMove: any
   subjectEl: HTMLElement
   downEl: HTMLElement
   emitter: EmitterMixin
 
+  // options
+  selector: string
+  ignoreMove: boolean = false // bad name?
+
   isDragging: boolean = false
   isDraggingTouch: boolean = false
   isTouchScroll: boolean = false
 
-  constructor(containerEl: HTMLElement, selector: string, ignoreMove: any) {
+  constructor(containerEl: HTMLElement) {
     this.containerEl = containerEl
-    this.selector = selector
-    this.ignoreMove = ignoreMove
     this.emitter = new EmitterMixin()
     containerEl.addEventListener('mousedown', this.onMouseDown)
     containerEl.addEventListener('touchstart', this.onTouchStart)
@@ -48,28 +54,21 @@ export default class PointerDragListener {
     this.emitter.on(name, handler)
   }
 
-  simulateStart(pev: PointerDragEvent) {
-    if (pev.isTouch) {
-      this.onTouchStart(pev.origEvent as TouchEvent)
-    } else {
-      this.onMouseDown(pev.origEvent as MouseEvent)
-    }
-  }
-
   maybeStart(ev: UIEvent) {
     if ((this.subjectEl = this.queryValidSubjectEl(ev))) {
-      this.isDragging = true // do this first so cancelTouchScroll will work
       this.downEl = ev.target as HTMLElement
+      this.isDragging = true // do this first so cancelTouchScroll will work
+      this.isTouchScroll = false
       return true
     }
   }
 
-  cleanupDrag() { // do this last, so that pointerup has access to props
+  cleanup() { // do this last, so that pointerup has access to props
     isWindowTouchMoveCancelled = false
     this.isDragging = false
-    this.isTouchScroll = false
     this.subjectEl = null
     this.downEl = null
+    // keep isTouchScroll around for later access
   }
 
   onTouchScroll = () => {
@@ -107,13 +106,16 @@ export default class PointerDragListener {
   onMouseUp = (ev: MouseEvent) => {
     document.removeEventListener('mousemove', this.onMouseMove)
     document.removeEventListener('mouseup', this.onMouseUp)
+
     this.emitter.trigger('pointerup', this.createMouseEvent(ev))
-    this.cleanupDrag()
+
+    this.cleanup()
   }
 
   onTouchStart = (ev: TouchEvent) => {
     if (this.maybeStart(ev)) {
       this.isDraggingTouch = true
+
       this.emitter.trigger('pointerdown', this.createTouchEvent(ev))
 
       // unlike mouse, need to attach to target, not document
@@ -143,18 +145,19 @@ export default class PointerDragListener {
   }
 
   onTouchEnd = (ev: TouchEvent) => {
-    if (this.isDragging) { // guard against touchend followed by touchcancel
+    if (this.isDragging) { // done to guard against touchend followed by touchcancel
       let target = ev.target
+
       target.removeEventListener('touchmove', this.onTouchMove)
       target.removeEventListener('touchend', this.onTouchEnd)
       target.removeEventListener('touchcancel', this.onTouchEnd)
-
       window.removeEventListener('scroll', this.onTouchScroll)
 
       this.emitter.trigger('pointerup', this.createTouchEvent(ev))
-      this.cleanupDrag()
+
+      this.cleanup()
       this.isDraggingTouch = false
-      this.startIgnoringMouse()
+      startIgnoringMouse()
     }
   }
 
@@ -162,14 +165,6 @@ export default class PointerDragListener {
     return ignoreMouseDepth || this.isDraggingTouch
   }
 
-  startIgnoringMouse() {
-    ignoreMouseDepth++
-
-    setTimeout(() => {
-      ignoreMouseDepth--
-    }, (exportHooks as any).touchMouseIgnoreWait)
-  }
-
   cancelTouchScroll() {
     if (this.isDragging) {
       isWindowTouchMoveCancelled = true
@@ -187,30 +182,39 @@ export default class PointerDragListener {
   }
 
   createTouchEvent(ev): PointerDragEvent {
-    let obj = {
+    let pev = {
       origEvent: ev,
       isTouch: true,
       el: this.subjectEl
     } as PointerDragEvent
+
     let touches = ev.touches
 
     // if touch coords available, prefer,
     // because FF would give bad ev.pageX ev.pageY
     if (touches && touches.length) {
-      obj.pageX = touches[0].pageX
-      obj.pageY = touches[0].pageY
+      pev.pageX = touches[0].pageX
+      pev.pageY = touches[0].pageY
     } else {
-      obj.pageX = ev.pageX
-      obj.pageY = ev.pageY
+      pev.pageX = ev.pageX
+      pev.pageY = ev.pageY
     }
 
-    return obj
+    return pev
   }
 
 }
 
 let ignoreMouseDepth = 0
 
+function startIgnoringMouse() { // can be made non-class function
+  ignoreMouseDepth++
+
+  setTimeout(() => {
+    ignoreMouseDepth--
+  }, (exportHooks as any).touchMouseIgnoreWait)
+}
+
 // we want to attach touchmove as early as possible for safari
 
 let listenerCnt = 0

+ 13 - 8
src/interactions/DateClicking.ts

@@ -9,21 +9,26 @@ export default class DateClicking {
 
   constructor(component: DateComponent) {
     this.component = component
-    this.hitListener = new HitDragListener({
-      containerEl: component.el
-      // don't do ignoreMove:false because finalHit needs it
-    }, component)
-
+    this.hitListener = new HitDragListener(component)
+    this.hitListener.on('pointerdown', this.onPointerDown)
     this.hitListener.on('dragend', this.onDragEnd)
   }
 
+  onPointerDown = (ev: PointerDragEvent) => {
+    let { component } = this
+    let { pointerListener } = this.hitListener.dragListener
+
+    // do this in pointerdown (not dragend) because DOM might be mutated by the time dragend is fired
+    pointerListener.ignoreMove = !component.isValidDateInteraction(pointerListener.downEl)
+  }
+
   onDragEnd = (ev: PointerDragEvent) => {
     let { component } = this
-    let pointerListener = this.hitListener.dragListener.pointerListener
+    let { pointerListener } = this.hitListener.dragListener
 
     if (
-      !pointerListener.isTouchScroll &&
-      component.isValidDateInteraction(pointerListener.downEl)
+      !pointerListener.ignoreMove && // not ignored in onPointerDown
+      !pointerListener.isTouchScroll
     ) {
       let { initialHit, finalHit } = this.hitListener
 

+ 68 - 102
src/interactions/DateSelecting.ts

@@ -1,145 +1,111 @@
 import { compareNumbers } from '../util/misc'
 import { elementClosest } from '../util/dom-manip'
-import DateComponent, { DateComponentHash } from '../component/DateComponent'
+import DateComponent from '../component/DateComponent'
 import HitDragListener, { Hit } from '../dnd/HitDragListener'
 import { Selection } from '../reducers/selection'
 import UnzonedRange from '../models/UnzonedRange'
-import PointerDragListener, { PointerDragEvent } from '../dnd/PointerDragListener'
+import { PointerDragEvent } from '../dnd/PointerDragListener'
+import { GlobalContext } from '../common/GlobalContext'
 
 export default class DateSelecting {
 
-  componentHash: DateComponentHash
-  pointerListener: PointerDragListener
+  component: DateComponent
+  globalContext: GlobalContext
   hitListener: HitDragListener
-  dragComponent: DateComponent
   dragSelection: Selection
-  activeComponent: DateComponent
-  activeSelection: Selection
-
-  constructor(componentHash: DateComponentHash) {
-    this.componentHash = componentHash
-    this.pointerListener = new PointerDragListener(
-      document as any, // TODO: better
-      null, // selector
-      true // ignoreMove
-    )
-    this.pointerListener.on('pointerdown', this.onPointerDown)
-    this.pointerListener.on('pointerup', this.onPointerUp)
+
+  constructor(component: DateComponent, globalContext: GlobalContext) {
+    this.component = component
+    this.globalContext = globalContext
+
+    let hitListener = this.hitListener = new HitDragListener(component)
+    hitListener.dragListener.touchScrollAllowed = false
+    hitListener.on('pointerdown', this.onPointerDown)
+    hitListener.on('dragstart', this.onDragStart)
+    hitListener.on('hitover', this.onHitOver)
+    hitListener.on('hitout', this.onHitOut)
   }
 
   destroy() {
-    this.pointerListener.destroy()
+    this.hitListener.destroy()
   }
 
   onPointerDown = (ev: PointerDragEvent) => {
-    let component = this.queryComponent(ev)
-
-    if (
-      component &&
-      component.opt('selectable') &&
+    let { component } = this
+    let { dragListener } = this.hitListener
+    let isValid = component.opt('selectable') &&
       component.isValidDateInteraction(ev.origEvent.target as HTMLElement)
-    ) {
-
-      this.hitListener = new HitDragListener({
-        containerEl: component.el,
-        touchDelay: getComponentDelay(component),
-        touchScrollAllowed: false
-      }, component)
 
-      this.dragComponent = component
-      this.dragSelection = null
+    // don't bother to watch expensive moves if component won't do selection
+    dragListener.pointerListener.ignoreMove = !isValid
 
-      this.hitListener.on('dragstart', this.onDragStart)
-      this.hitListener.on('hitover', this.onHitOver)
-      this.hitListener.on('hitout', this.onHitOut)
-      this.hitListener.dragListener.pointerListener.simulateStart(ev)
-    }
-  }
-
-  queryComponent(ev: PointerDragEvent): DateComponent {
-    let componentEl = elementClosest(
-      ev.origEvent.target as any, // TODO: better
-      '[data-fc-com-uid]'
-    )
-    if (componentEl) {
-      return this.componentHash[componentEl.getAttribute('data-fc-com-uid')]
-    }
+    dragListener.delay = (isValid && ev.isTouch) ?
+      getComponentDelay(component) :
+      null
   }
 
   onDragStart = (ev: PointerDragEvent) => {
-    if (this.hitListener.initialHit) {
-      this.clearActiveSelection(ev)
+    let { globalContext } = this
+
+    if (globalContext.selectedCalendar) {
+      globalContext.selectedCalendar.unselect(ev.origEvent)
+      globalContext.selectedCalendar = null
     }
   }
 
-  onHitOver = (overHit: Hit) => {
-    let { initialHit } = this.hitListener
-    let initialComponent = initialHit.component
-    let calendar = initialComponent.getCalendar()
-    let dragSelection = computeSelection(initialHit, overHit)
+  onHitOver = (overHit: Hit) => { // TODO: do a onHitChange instead?
+    let { globalContext } = this
+    let calendar = this.component.getCalendar()
+    let dragSelection = computeSelection(this.hitListener.initialHit, overHit)
 
     if (dragSelection) {
+      globalContext.selectedCalendar = calendar
       this.dragSelection = dragSelection
-      calendar.setSelectionState(dragSelection)
+
+      calendar.dispatch({
+        type: 'SELECT',
+        selection: dragSelection
+      })
     }
   }
 
-  onHitOut = (hit: Selection) => {
-    let { initialHit, finalHit } = this.hitListener
-    let initialComponent = initialHit.component
-    let calendar = initialComponent.getCalendar()
+  onHitOut = (hit: Selection, ev) => {
+    let { globalContext } = this
+    let calendar = this.component.getCalendar()
 
-    if (!finalHit) { // still dragging? a hitout means it went out of bounds
-      this.dragSelection = null
-      calendar.clearSelectionState()
-    }
+    globalContext.selectedCalendar = null
+    this.dragSelection = null
+
+    calendar.dispatch({
+      type: 'UNSELECT'
+    })
   }
 
-  onPointerUp = (ev: PointerDragEvent) => {
-    if (this.dragSelection) {
-      this.setActiveSelection(this.dragComponent, this.dragSelection, ev)
-    } else if (!this.pointerListener.isTouchScroll) {
-      // if there was a pointerup that did not result in a selection and was
-      // not merely a touchmove-scroll, then possibly unselect the current selection
-      this.maybeUnfocus(ev)
-    }
+  onDocumentPointerUp = (ev: PointerDragEvent, isTouchScroll: boolean, downEl: HTMLElement) => {
+    let { component } = this
 
-    if (this.hitListener) {
-      this.hitListener.destroy()
-      this.hitListener = null
-    }
+    if (this.dragSelection) {
 
-    this.dragComponent = null
-    this.dragSelection = null
-  }
+      // the selection is already rendered, so just need to fire
+      component.getCalendar().triggerSelect(
+        this.dragSelection,
+        component.view,
+        ev.origEvent
+      )
 
-  maybeUnfocus(ev: PointerDragEvent) {
-    if (this.activeSelection) {
-      let view = this.activeComponent.view
-      let unselectAuto = view.opt('unselectAuto')
-      let unselectCancel = view.opt('unselectCancel')
+      this.dragSelection = null
 
-      if (unselectAuto && (!unselectCancel || !elementClosest(this.pointerListener.downEl, unselectCancel))) {
-        this.clearActiveSelection(ev)
-      }
-    }
-  }
+    } else if (!isTouchScroll) {
+      // if there was a pointerup that did not result in a selection and was
+      // not merely a touchmove-scroll, then possibly unselect the current selection.
+      // won't do anything if already unselected (OR, leverage selectedCalendar?)
 
-  setActiveSelection(component: DateComponent, selection: Selection, ev: PointerDragEvent) {
-    this.clearActiveSelection(ev)
-    this.activeComponent = component
-    this.activeSelection = selection
-    let view = component.view
-    view.calendar.triggerSelect(selection, view, ev.origEvent)
-  }
+      let unselectAuto = component.opt('unselectAuto')
+      let unselectCancel = component.opt('unselectCancel')
 
-  clearActiveSelection(ev: PointerDragEvent) {
-    if (this.activeSelection) {
-      let component = this.activeComponent
-      let calendar = component.getCalendar()
-      calendar.clearSelectionState()
-      calendar.triggerUnselect(component.view, ev.origEvent)
-      this.activeSelection = null
+      if (unselectAuto && (!unselectAuto || !elementClosest(downEl, unselectCancel))) {
+        component.getCalendar().unselect()
+      }
     }
   }
 

+ 1 - 0
src/list/ListView.ts

@@ -110,6 +110,7 @@ export default class ListView extends View {
 
       if (segRange) {
         seg = {
+          component: this,
           start: segRange.start,
           end: segRange.end,
           isStart: segRange.start.valueOf() === range.start.valueOf(),

+ 0 - 1
src/reducers/event-mutation.ts

@@ -4,7 +4,6 @@ import { diffDayAndTime } from '../datelib/marker'
 import { Duration, createDuration } from '../datelib/duration'
 import { EventStore, EventDef, EventInstance } from './event-store'
 import { assignTo } from '../util/object'
-import { filterHash } from '../util/object'
 import Calendar from '../Calendar'
 
 export interface EventMutation {

+ 26 - 0
src/reducers/main.ts

@@ -115,8 +115,34 @@ export function reduce(state: CalendarState, action: any, calendar: Calendar): C
         return assignTo({}, state, {
           selection: null
         })
+      } else {
+        break
       }
 
+    case 'SET_DRAG':
+      return assignTo({}, state, {
+        dragState: {
+          eventStore: action.displacement, // pass these in as one drag state?
+          isTouch: action.isTouch,
+          origSeg: action.origSeg
+        }
+      })
+
+    case 'CLEAR_DRAG':
+      return assignTo({}, state, {
+        dragState: null
+      })
+
+    case 'SELECT_EVENT':
+      return assignTo({}, state, {
+        selectedEventInstanceId: action.eventInstanceId
+      })
+
+    case 'CLEAR_SELECTED_EVENT':
+      return assignTo({}, state, {
+        selectedEventInstanceId: null
+      })
+
   }
 
   return newState