Просмотр исходного кода

new round of dayclick/select functionality

Adam Shaw 7 лет назад
Родитель
Сommit
b91bd37e9a

+ 53 - 29
src/Calendar.ts

@@ -3,7 +3,6 @@ import { computeHeightAndMargins } from './util/dom-geom'
 import { listenBySelector } from './util/dom-event'
 import { capitaliseFirstLetter, debounce } from './util/misc'
 import { globalDefaults, rtlDefaults } from './options'
-import GlobalContext from './common/GlobalContext'
 import { default as EmitterMixin, EmitterInterface } from './common/EmitterMixin'
 import Toolbar from './Toolbar'
 import OptionsManager from './OptionsManager'
@@ -19,7 +18,7 @@ import { DateMarker, startOfDay } from './datelib/marker'
 import { createFormatter } from './datelib/formatting'
 import { Duration, createDuration } from './datelib/duration'
 import { CalendarState, reduce } from './reducers/main'
-import { parseSelection, SelectionInput } from './reducers/selection'
+import { parseSelection, SelectionInput, Selection } from './reducers/selection'
 import reselector from './util/reselector'
 import { assignTo } from './util/object'
 import { RenderForceFlags } from './component/Component'
@@ -274,8 +273,6 @@ export default class Calendar {
 
 
   bindGlobalHandlers() {
-    GlobalContext.registerCalendar(this)
-
     if (this.opt('handleWindowResize')) {
       window.addEventListener('resize',
         this.windowResizeProxy = debounce( // prevents rapid calls
@@ -287,8 +284,6 @@ export default class Calendar {
   }
 
   unbindGlobalHandlers() {
-    GlobalContext.unregisterCalendar(this)
-
     if (this.windowResizeProxy) {
       window.removeEventListener('resize', this.windowResizeProxy)
       this.windowResizeProxy = null
@@ -361,7 +356,9 @@ export default class Calendar {
         this.publiclyTrigger('loading', [ false, this.view ])
       }
 
-      this.requestRerender()
+      if (oldState !== newState) {
+        this.requestRerender()
+      }
     }
   }
 
@@ -901,14 +898,12 @@ export default class Calendar {
   }
 
 
-  // Selection
+  // Selection / DayClick
   // -----------------------------------------------------------------------------------------------------------------
 
 
   // this public method receives start/end dates in any format, with any timezone
-  //
-  // args were changed
-  //
+  // NOTE: args were changed from v3
   select(dateOrObj: DateInput | object, endDate?: DateInput) {
     let selectionInput: SelectionInput
 
@@ -922,39 +917,68 @@ export default class Calendar {
     }
 
     let selection = parseSelection(selectionInput, this.dateEnv)
+    if (selection) {
+      this.setSelectionState(selection)
+      this.triggerSelect(selection, this.view)
+    } // otherwise, throw error?
+  }
+
 
-    // TODO: use dispatch
-    console.log(selection)
+  // public method
+  unselect() {
+    this.clearSelectionState()
+    this.triggerUnselect(this.view)
   }
 
 
-  unselect() { // safe to be called before renderView
-    // TODO: use dispatch
+  setSelectionState(selection) {
+    this.dispatch({
+      type: 'SELECT',
+      selection: selection
+    })
   }
 
 
-  // External Dragging
-  // -----------------------------------------------------------------------------------------------------------------
+  clearSelectionState() {
+    this.dispatch({
+      type: 'UNSELECT'
+    })
+  }
 
 
-  handlExternalDragStart(ev, el, skipBinding) {
-    if (this.renderedView) {
-      this.renderedView.handlExternalDragStart(ev, el, skipBinding)
-    }
+  // Triggers handlers to 'select'
+  triggerSelect(selection: Selection, view: View, ev?: UIEvent) {
+    this.publiclyTrigger('select', [
+      {
+        start: this.dateEnv.toDate(selection.range.start),
+        end: this.dateEnv.toDate(selection.range.end),
+        isAllDay: selection.isAllDay,
+        jsEvent: ev,
+        view
+      }
+    ])
   }
 
 
-  handleExternalDragMove(ev) {
-    if (this.renderedView) {
-      this.renderedView.handleExternalDragMove(ev)
-    }
+  triggerUnselect(view, ev?: UIEvent) {
+    this.publiclyTrigger('unselect', [
+      {
+        jsEvent: ev,
+        view
+      }
+    ])
   }
 
 
-  handleExternalDragStop(ev) {
-    if (this.renderedView) {
-      this.renderedView.handleExternalDragStop(ev)
-    }
+  triggerDayClick(selection: Selection, view: View, ev: UIEvent) {
+    this.publiclyTrigger('dayClick', [
+      {
+        date: this.dateEnv.toDate(selection.range.start),
+        isAllDay: selection.isAllDay,
+        jsEvent: ev,
+        view
+      }
+    ])
   }
 
 

+ 0 - 60
src/View.ts

@@ -8,7 +8,6 @@ import { DateMarker, addDays, addMs, diffWholeDays } from './datelib/marker'
 import { createDuration } from './datelib/duration'
 import { createFormatter } from './datelib/formatting'
 import { EventInstance } from './reducers/event-store'
-import { Selection } from './reducers/selection'
 import { default as EmitterMixin, EmitterInterface } from './common/EmitterMixin'
 
 
@@ -34,9 +33,6 @@ export default abstract class View extends InteractiveDateComponent {
 
   queuedScroll: object
 
-  isSelected: boolean = false // boolean whether a range of time is user-selected or not
-  selectedEventInstance: EventInstance
-
   eventOrderSpecs: any // criteria for ordering events when they have same date/time
 
   // for date utils, computed from options
@@ -197,7 +193,6 @@ export default abstract class View extends InteractiveDateComponent {
 
   unrenderDates() {
     this.triggerWillRemoveDates()
-    this.unselect()
     this.stopNowIndicator()
     super.unrenderDates()
   }
@@ -375,61 +370,6 @@ export default abstract class View extends InteractiveDateComponent {
   }
 
 
-  /* Selection (time range)
-  ------------------------------------------------------------------------------------------------------------------*/
-
-
-  // Selects a date span on the view. `start` and `end` are both Moments.
-  // `ev` is the native mouse event that begin the interaction.
-  select(selection: Selection, ev?) {
-    this.unselect(ev)
-    this.renderSelection(selection)
-    this.reportSelection(selection, ev)
-  }
-
-
-  // Called when a new selection is made. Updates internal state and triggers handlers.
-  reportSelection(selection: Selection, ev?) {
-    this.isSelected = true
-    this.triggerSelect(selection, ev)
-  }
-
-
-  // Triggers handlers to 'select'
-  triggerSelect(selection: Selection, ev?) {
-    let dateEnv = this.getDateEnv()
-
-    this.publiclyTrigger('select', [
-      {
-        start: dateEnv.toDate(selection.range.start),
-        end: dateEnv.toDate(selection.range.end),
-        isAllDay: selection.isAllDay,
-        jsEvent: ev,
-        view: this
-      }
-    ])
-  }
-
-
-  // Undoes a selection. updates in the internal state and triggers handlers.
-  // `ev` is the native mouse event that began the interaction.
-  unselect(ev?) {
-    if (this.isSelected) {
-      this.isSelected = false
-      if (this['destroySelection']) {
-        this['destroySelection']() // TODO: deprecate
-      }
-      this.unrenderSelection()
-      this.publiclyTrigger('unselect', [
-        {
-          jsEvent: ev,
-          view: this
-        }
-      ])
-    }
-  }
-
-
   /* Date Utils
   ------------------------------------------------------------------------------------------------------------------*/
 

+ 32 - 32
src/common/GlobalContext.ts

@@ -1,55 +1,55 @@
-import { removeExact } from '../util/array'
-import Calendar from '../Calendar'
 import InteractiveDateComponent from '../component/InteractiveDateComponent'
+import DateClicking from '../interactions/DateClicking'
+import DateSelecting from '../interactions/DateSelecting'
 
-let activeCalendars: Calendar[] = []
-let activeComponents: InteractiveDateComponent[] = []
+let componentCnt = 0
+let componentHash = {}
+let listenerHash = {}
 
 export default {
 
-  registerCalendar(calendar: Calendar) {
-    activeCalendars.push(calendar)
+  // TODO: event hovering
 
-    if (activeCalendars.length === 1) {
+  registerComponent(component: InteractiveDateComponent) {
+    componentHash[component.uid] = component
+    componentCnt++
+
+    if (componentCnt === 1) {
       this.bind()
     }
+
+    this.bindComponent(component)
   },
 
-  unregisterCalendar(calendar: Calendar) {
-    if (
-      removeExact(activeCalendars, calendar) && // any removed?
-      !activeCalendars.length // no more left
-    ) {
+  unregisterComponent(component: InteractiveDateComponent) {
+    delete componentHash[component.uid]
+    componentCnt--
+
+    if (componentCnt === 0) {
       this.unbind()
     }
+
+    this.unbindComponent(component)
   },
 
-  registerComponent(component: InteractiveDateComponent) {
-    activeComponents.push(component)
+  bind() {
+    this.dateSelector = new DateSelecting(componentHash)
   },
 
-  unregisterComponent(component: InteractiveDateComponent) {
-    removeExact(activeComponents, component)
+  unbind() {
+    this.dateSelector.destroy()
   },
 
-  bind() {
-    document.addEventListener('click', this.documentClick = function(ev) {
-      for (let component of activeComponents) {
-        component.buildCoordCaches()
-        let hit = component.queryHit(ev.pageX, ev.pageY)
-        if (hit) {
-          console.log(
-            hit.range.start.toUTCString(),
-            hit.range.end.toUTCString(),
-            hit.isAllDay
-          )
-        }
-      }
-    })
+  bindComponent(component: InteractiveDateComponent) {
+    listenerHash[component.uid] = {
+      dateClicker: new DateClicking(component)
+    }
   },
 
-  unbind() {
-    document.removeEventListener('click', this.documentClick)
+  unbindComponent(component: InteractiveDateComponent) {
+    let listeners = listenerHash[component.uid]
+    listeners.dateClicker.destroy()
+    delete listenerHash[component.uid]
   }
 
 }

+ 6 - 0
src/component/DateComponent.ts

@@ -107,6 +107,12 @@ 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

+ 3 - 3
src/component/InteractiveDateComponent.ts

@@ -2,10 +2,10 @@ import DateComponent from './DateComponent'
 import { Selection } from '../reducers/selection'
 import GlobalContext from '../common/GlobalContext'
 
+export type InteractiveDateComponentHash = {
+  [uid: string]: InteractiveDateComponent
+}
 
-/*
-NOTE: still needed for event element clicking and drag initiation
-*/
 export default abstract class InteractiveDateComponent extends DateComponent {
 
   // if defined, holds the unit identified (ex: "year" or "month") that determines the level of granularity

+ 146 - 0
src/dnd/HitDragListener.ts

@@ -0,0 +1,146 @@
+import EmitterMixin from '../common/EmitterMixin'
+import { PointerDragEvent } from './PointerDragListener'
+import { default as IntentfulDragListener, IntentfulDragOptions } from './IntentfulDragListener'
+import InteractiveDateComponent from '../component/InteractiveDateComponent'
+import { Selection } from '../reducers/selection'
+
+export interface Hit extends Selection {
+  component: InteractiveDateComponent
+}
+
+/*
+fires:
+- dragstart
+- hitover
+- hitout
+- dragend
+*/
+export default class HitDragListener {
+
+  droppableComponents: InteractiveDateComponent[]
+  emitter: EmitterMixin
+  dragListener: IntentfulDragListener
+  initialHit: Hit
+  movingHit: Hit
+  finalHit: Hit // won't ever be populated if options.ignoreMove is false
+
+  constructor(options: IntentfulDragOptions, droppableComponents: InteractiveDateComponent[]) {
+    this.droppableComponents = droppableComponents
+    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() {
+    this.dragListener.destroy()
+  }
+
+  on(name, handler) {
+    this.emitter.on(name, handler)
+  }
+
+  onPointerDown = (ev: PointerDragEvent) => {
+    this.emitter.trigger('pointerdown', ev)
+    this.prepareComponents()
+    this.initialHit = this.queryHit(ev.pageX, ev.pageY)
+    this.finalHit = null
+  }
+
+  onDragStart = (ev: PointerDragEvent) => {
+    this.emitter.trigger('dragstart', ev)
+
+    // querying the first hovered hit is considered a 'move', so ignore if necessary
+    if (!this.dragListener.pointerListener.ignoreMove) {
+      this.handleMove(ev)
+    }
+  }
+
+  onDragMove = (ev: PointerDragEvent) => {
+    this.emitter.trigger('pointermove', ev)
+    this.handleMove(ev)
+  }
+
+  onDragEnd = (ev: PointerDragEvent) => {
+    this.finalHit = this.movingHit
+    this.clearMovingHit()
+    this.emitter.trigger('dragend', ev)
+  }
+
+  onPointerUp = (ev: PointerDragEvent) => {
+    this.emitter.trigger('pointerup', ev)
+  }
+
+  handleMove(ev: PointerDragEvent) {
+    let hit = this.queryHit(ev.pageX, ev.pageY)
+
+    if (!this.movingHit || !isHitsEqual(this.movingHit, hit)) {
+      this.clearMovingHit()
+
+      if (hit) {
+        this.movingHit = hit
+        this.emitter.trigger('hitover', hit)
+      }
+    }
+  }
+
+  clearMovingHit() {
+    if (this.movingHit) {
+      this.emitter.trigger('hitout', this.movingHit)
+      this.movingHit = null
+    }
+  }
+
+  prepareComponents() {
+    for (let component of this.droppableComponents) {
+      component.buildCoordCaches()
+    }
+  }
+
+  queryHit(x, y): Hit {
+    for (let component of this.droppableComponents) {
+      let hit = component.queryHit(x, y) as Hit
+
+      if (hit) {
+        hit.component = component
+        return hit
+      }
+    }
+
+    return null
+  }
+
+}
+
+export function isHitsEqual(hit0: Selection, hit1: Selection) {
+  if (!hit0 && !hit1) {
+    return true
+  }
+
+  if (Boolean(hit0) !== Boolean(hit1)) {
+    return false
+  }
+
+  if (!hit0.range.equals(hit1.range)) {
+    return false
+  }
+
+  for (let propName in hit1) {
+    if (propName !== 'range') {
+      if (hit0[propName] !== hit1[propName]) {
+        return false
+      }
+    }
+  }
+
+  for (let propName in hit0) {
+    if (!(propName in hit1)) {
+      return false
+    }
+  }
+
+  return true
+}

+ 47 - 51
src/dnd/IntentfulDragListener.ts

@@ -1,18 +1,34 @@
 import { default as EmitterMixin } from '../common/EmitterMixin'
-import { default as PointerDragListener, PointerEvent } from './PointerDragListener'
+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 | [() => number]
-  mouseDelay?: number | [() => 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
 }
 
+/*
+fires:
+- pointerdown
+- dragstart
+- dragmove
+- pointermove
+- dragend
+- pointerup
+*/
 export default class IntentfulDragListener {
 
-  pointer: PointerDragListener
+  pointerListener: PointerDragListener
   emitter: EmitterMixin
 
   options: IntentfulDragOptions
@@ -20,7 +36,6 @@ export default class IntentfulDragListener {
   isDragging: boolean = false // is it INTENTFULLY dragging?
   isDelayEnded: boolean = false
   isDistanceSurpassed: boolean = false
-  isTouchScroll: boolean = false
 
   delay: number
   delayTimeoutId: number
@@ -29,61 +44,50 @@ export default class IntentfulDragListener {
   origX: number
   origY: number
 
-  constructor(options) {
+  constructor(options: IntentfulDragOptions) {
     this.options = options
-    this.pointer = new PointerDragListener(options.containerEl, options.selector)
+    this.pointerListener = new PointerDragListener(options.containerEl, options.selector, options.ignoreMove)
     this.emitter = new EmitterMixin()
 
-    this.pointer.on('down', this.handleDown)
-    this.pointer.on('move', this.handleMove)
-    this.pointer.on('up', this.handleUp)
+    this.pointerListener.on('pointerdown', this.onPointerDown)
+    this.pointerListener.on('pointermove', this.onPointerMove)
+    this.pointerListener.on('pointerup', this.onPointerUp)
   }
 
   destroy() {
-    this.pointer.destroy()
+    this.pointerListener.destroy()
   }
 
   on(name, handler) {
     this.emitter.on(name, handler)
   }
 
-  handleDown = (ev: PointerEvent) => {
+  onPointerDown = (ev: PointerDragEvent) => {
+    this.emitter.trigger('pointerdown', ev)
+
     preventSelection(document.body)
     preventContextMenu(document.body)
 
     let minDistance = this.options[ev.isTouch ? 'touchMinDistance' : 'mouseMinDistance']
     let delay = this.options[ev.isTouch ? 'touchDelay' : 'mouseDelay']
 
-    this.minDistance = minDistance
-    this.delay = typeof delay === 'function' ? (delay as any)() : delay
+    this.minDistance = minDistance || 0
+    this.delay = typeof delay === 'function' ? (delay as any)(ev) : delay
 
     this.origX = ev.pageX
     this.origY = ev.pageY
 
     this.isDelayEnded = false
     this.isDistanceSurpassed = false
-    this.isTouchScroll = false
 
-    this.emitter.trigger('pointerdown', ev)
     this.startDelay(ev)
 
     if (!this.minDistance) {
       this.handleDistanceSurpassed(ev)
     }
-
-    if (ev.isTouch) {
-      // attach a handler to get called when ANY scroll action happens on the page.
-      // this was impossible to do with normal on/off because 'scroll' doesn't bubble.
-      // http://stackoverflow.com/a/32954565/96342
-      window.addEventListener(
-        'scroll',
-        this.handleTouchScroll, // always bound to `this`
-        true // useCapture
-      )
-    }
   }
 
-  handleMove = (ev: PointerEvent) => {
+  onPointerMove = (ev: PointerDragEvent) => {
     this.emitter.trigger('pointermove', ev)
 
     if (!this.isDistanceSurpassed) {
@@ -103,7 +107,7 @@ export default class IntentfulDragListener {
     }
   }
 
-  handleUp = (ev: PointerEvent) => {
+  onPointerUp = (ev: PointerDragEvent) => {
     if (this.isDragging) {
       this.stopDrag(ev)
     }
@@ -116,18 +120,10 @@ export default class IntentfulDragListener {
       this.delayTimeoutId = null
     }
 
-    if (ev.isTouch) {
-      window.removeEventListener('scroll', this.handleTouchScroll)
-    }
-
     this.emitter.trigger('pointerup', ev)
   }
 
-  handleTouchScroll = () => {
-    this.isTouchScroll = true
-  }
-
-  startDelay(ev: PointerEvent) {
+  startDelay(ev: PointerDragEvent) {
     if (typeof this.delay === 'number') {
       this.delayTimeoutId = setTimeout(() => {
         this.delayTimeoutId = null
@@ -138,27 +134,27 @@ export default class IntentfulDragListener {
     }
   }
 
-  handleDelayEnd(ev: PointerEvent) {
+  handleDelayEnd(ev: PointerDragEvent) {
     this.isDelayEnded = true
     this.startDrag(ev)
   }
 
-  handleDistanceSurpassed(ev: PointerEvent) {
+  handleDistanceSurpassed(ev: PointerDragEvent) {
     this.isDistanceSurpassed = true
     this.startDrag(ev)
   }
 
-  startDrag(ev: PointerEvent) { // will only start if appropriate
-    if (
-      this.isDelayEnded &&
-      this.isDistanceSurpassed &&
-      (!this.isTouchScroll || this.options.touchScrollAllowed !== false)
-    ) {
-      this.emitter.trigger('dragstart', ev)
-      this.isDragging = true
-
-      if (this.options.touchScrollAllowed === false) {
-        this.pointer.cancelTouchScroll()
+  startDrag(ev: PointerDragEvent) { // will only start if appropriate
+    if (this.isDelayEnded && this.isDistanceSurpassed) {
+      let touchScrollAllowed = this.options.touchScrollAllowed
+
+      if (!this.pointerListener.isTouchScroll || touchScrollAllowed) {
+        this.emitter.trigger('dragstart', ev)
+        this.isDragging = true
+
+        if (touchScrollAllowed === false) {
+          this.pointerListener.cancelTouchScroll()
+        }
       }
     }
   }

+ 96 - 43
src/dnd/PointerDragListener.ts

@@ -5,7 +5,7 @@ import { isPrimaryMouseButton } from '../util/dom-event'
 
 (exportHooks as any).touchMouseIgnoreWait = 500
 
-export interface PointerEvent {
+export interface PointerDragEvent {
   origEvent: UIEvent
   isTouch: boolean
   el: HTMLElement
@@ -13,22 +13,24 @@ export interface PointerEvent {
   pageY: number
 }
 
-export type PointerEventHandler = (ev: PointerEvent) => void
+export type PointerEventHandler = (ev: PointerDragEvent) => void
 
 export default class PointerDragListener {
 
   containerEl: HTMLElement
   selector: string
+  ignoreMove: any
   subjectEl: HTMLElement
   emitter: EmitterMixin
 
   isDragging: boolean = false
   isDraggingTouch: boolean = false
-  ignoreMouseDepth: number = 0
+  isTouchScroll: boolean = false
 
-  constructor(containerEl: HTMLElement, selector?: string) {
+  constructor(containerEl: HTMLElement, selector: string, ignoreMove: any) {
     this.containerEl = containerEl
     this.selector = selector
+    this.ignoreMove = ignoreMove
     this.emitter = new EmitterMixin()
     containerEl.addEventListener('mousedown', this.onMouseDown)
     containerEl.addEventListener('touchstart', this.onTouchStart)
@@ -36,6 +38,8 @@ export default class PointerDragListener {
   }
 
   destroy() {
+    this.containerEl.removeEventListener('mousedown', this.onMouseDown)
+    this.containerEl.removeEventListener('touchstart', this.onTouchStart)
     listenerDestroyed()
   }
 
@@ -43,76 +47,123 @@ 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) {
+    if ((this.subjectEl = this.queryValidSubjectEl(ev))) {
+      this.isDragging = true // do this first so cancelTouchScroll will work
+      return true
+    }
+  }
+
+  cleanupDrag() { // do this last, so that pointerup has access to props
+    isWindowTouchMoveCancelled = false
+    this.isDragging = false
+    this.isTouchScroll = false
+    this.subjectEl = null
+  }
+
+  onTouchScroll = () => {
+    this.isTouchScroll = true
+  }
+
+  queryValidSubjectEl(ev: UIEvent): HTMLElement {
+    if (this.selector) {
+      return elementClosest(ev.target as HTMLElement, this.selector)
+    } else {
+      return this.containerEl
+    }
+  }
+
   onMouseDown = (ev: MouseEvent) => {
     if (
       !this.shouldIgnoreMouse() &&
       isPrimaryMouseButton(ev) &&
-      (this.subjectEl = this.queryValidSubjectEl(ev))
+      this.maybeStart(ev)
     ) {
-      this.isDragging = true // do this first so cancelTouchScroll will work
-      this.emitter.trigger('down', this.createMouseEvent(ev))
-      document.addEventListener('mousemove', this.onMouseMove)
+      this.emitter.trigger('pointerdown', this.createMouseEvent(ev))
+
+      if (!this.ignoreMove) {
+        document.addEventListener('mousemove', this.onMouseMove)
+      }
+
       document.addEventListener('mouseup', this.onMouseUp)
     }
   }
 
   onMouseMove = (ev: MouseEvent) => {
-    this.emitter.trigger('move', this.createMouseEvent(ev))
+    this.emitter.trigger('pointermove', this.createMouseEvent(ev))
   }
 
   onMouseUp = (ev: MouseEvent) => {
-    isWindowTouchMoveCancelled = false
-    this.isDragging = false
     document.removeEventListener('mousemove', this.onMouseMove)
     document.removeEventListener('mouseup', this.onMouseUp)
-    this.emitter.trigger('up', this.createMouseEvent(ev))
-    this.subjectEl = null // clear afterwards, so handlers have access
+    this.emitter.trigger('pointerup', this.createMouseEvent(ev))
+    this.cleanupDrag()
   }
 
   onTouchStart = (ev: TouchEvent) => {
-    if ((this.subjectEl = this.queryValidSubjectEl(ev))) {
-      this.isDragging = true // do this first so cancelTouchScroll will work
+    if (this.maybeStart(ev)) {
       this.isDraggingTouch = true
-      this.emitter.trigger('down', this.createTouchEvent(ev))
-      document.addEventListener('touchmove', this.onTouchMove)
-      document.addEventListener('touchend', this.onTouchEnd)
-      document.addEventListener('touchcancel', this.onTouchEnd) // treat it as a touch end
+      this.emitter.trigger('pointerdown', this.createTouchEvent(ev))
+
+      // unlike mouse, need to attach to target, not document
+      // https://stackoverflow.com/a/45760014
+      let target = ev.target
+
+      if (!this.ignoreMove) {
+        target.addEventListener('touchmove', this.onTouchMove)
+      }
+
+      target.addEventListener('touchend', this.onTouchEnd)
+      target.addEventListener('touchcancel', this.onTouchEnd) // treat it as a touch end
+
+      // attach a handler to get called when ANY scroll action happens on the page.
+      // this was impossible to do with normal on/off because 'scroll' doesn't bubble.
+      // http://stackoverflow.com/a/32954565/96342
+      window.addEventListener(
+        'scroll',
+        this.onTouchScroll, // always bound to `this`
+        true // useCapture
+      )
     }
   }
 
   onTouchMove = (ev: TouchEvent) => {
-    this.emitter.trigger('move', this.createTouchEvent(ev))
+    this.emitter.trigger('pointermove', this.createTouchEvent(ev))
   }
 
   onTouchEnd = (ev: TouchEvent) => {
-    isWindowTouchMoveCancelled = false
-    this.isDragging = false
-    this.isDraggingTouch = false
-    document.removeEventListener('touchmove', this.onTouchMove)
-    document.removeEventListener('touchend', this.onTouchEnd)
-    document.removeEventListener('touchcancel', this.onTouchEnd)
-    this.emitter.trigger('up', this.createTouchEvent(ev))
-    this.subjectEl = null // clear afterwards, so handlers have access
-    this.startIgnoringMouse()
-  }
-
-  queryValidSubjectEl(ev: UIEvent): HTMLElement {
-    if (this.selector) {
-      return elementClosest(ev.target as HTMLElement, this.selector)
-    } else {
-      return this.containerEl
+    if (this.isDragging) { // 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.isDraggingTouch = false
+      this.startIgnoringMouse()
     }
   }
 
   shouldIgnoreMouse() {
-    return this.ignoreMouseDepth || this.isDraggingTouch
+    return ignoreMouseDepth || this.isDraggingTouch
   }
 
   startIgnoringMouse() {
-    this.ignoreMouseDepth++
+    ignoreMouseDepth++
 
     setTimeout(() => {
-      this.ignoreMouseDepth--
+      ignoreMouseDepth--
     }, (exportHooks as any).touchMouseIgnoreWait)
   }
 
@@ -122,7 +173,7 @@ export default class PointerDragListener {
     }
   }
 
-  createMouseEvent(ev): PointerEvent {
+  createMouseEvent(ev): PointerDragEvent {
     return {
       origEvent: ev,
       isTouch: false,
@@ -132,13 +183,13 @@ export default class PointerDragListener {
     }
   }
 
-  createTouchEvent(ev): PointerEvent {
-    let touches = ev.touches
+  createTouchEvent(ev): PointerDragEvent {
     let obj = {
       origEvent: ev,
       isTouch: true,
       el: this.subjectEl
-    } as PointerEvent
+    } as PointerDragEvent
+    let touches = ev.touches
 
     // if touch coords available, prefer,
     // because FF would give bad ev.pageX ev.pageY
@@ -155,6 +206,8 @@ export default class PointerDragListener {
 
 }
 
+let ignoreMouseDepth = 0
+
 // we want to attach touchmove as early as possible for safari
 
 let listenerCnt = 0

+ 35 - 0
src/interactions/DateClicking.ts

@@ -0,0 +1,35 @@
+import InteractiveDateComponent from '../component/InteractiveDateComponent'
+import HitDragListener, { isHitsEqual } from '../dnd/HitDragListener'
+import { PointerDragEvent } from '../dnd/PointerDragListener'
+
+export default class DateClicking {
+
+  component: InteractiveDateComponent
+  hitListener: HitDragListener
+
+  constructor(component: InteractiveDateComponent) {
+    this.component = component
+    this.hitListener = new HitDragListener({
+      containerEl: component.el
+      // don't do ignoreMove:false because finalHit needs it
+    }, [ component ])
+
+    this.hitListener.on('dragend', this.onDragEnd)
+  }
+
+  onDragEnd = (ev: PointerDragEvent) => {
+    if (!this.hitListener.dragListener.pointerListener.isTouchScroll) {
+      let { initialHit, finalHit } = this.hitListener
+
+      if (initialHit && finalHit && isHitsEqual(initialHit, finalHit)) {
+        let component = initialHit.component
+        component.getCalendar().triggerDayClick(initialHit, component.view, ev.origEvent)
+      }
+    }
+  }
+
+  destroy() {
+    this.hitListener.destroy()
+  }
+
+}

+ 168 - 0
src/interactions/DateSelecting.ts

@@ -0,0 +1,168 @@
+import { compareNumbers } from '../util/misc'
+import { elementClosest } from '../util/dom-manip'
+import InteractiveDateComponent, { InteractiveDateComponentHash } from '../component/InteractiveDateComponent'
+import HitDragListener, { Hit } from '../dnd/HitDragListener'
+import { Selection } from '../reducers/selection'
+import UnzonedRange from '../models/UnzonedRange'
+import PointerDragListener, { PointerDragEvent } from '../dnd/PointerDragListener'
+
+export default class DateSelecting {
+
+  componentHash: InteractiveDateComponentHash
+  pointerListener: PointerDragListener
+  pointerDownEl: HTMLElement
+  hitListener: HitDragListener
+  dragComponent: InteractiveDateComponent
+  dragSelection: Selection
+  activeComponent: InteractiveDateComponent
+  activeSelection: Selection
+
+  constructor(componentHash: InteractiveDateComponentHash) {
+    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)
+  }
+
+  onPointerDown = (ev: PointerDragEvent) => {
+    let component = this.queryComponent(ev)
+
+    if (component && component.opt('selectable')) {
+
+      this.hitListener = new HitDragListener({
+        containerEl: component.el,
+        touchDelay: getComponentDelay(component),
+        touchScrollAllowed: false
+      }, [ component ])
+
+      this.dragComponent = component
+      this.dragSelection = null
+
+      this.hitListener.on('dragstart', this.onDragStart)
+      this.hitListener.on('hitover', this.onHitOver)
+      this.hitListener.on('hitout', this.onHitOut)
+      this.hitListener.dragListener.pointerListener.simulateStart(ev)
+    }
+
+    this.pointerDownEl = ev.origEvent.target as any // TODO: better
+  }
+
+  queryComponent(ev: PointerDragEvent): InteractiveDateComponent {
+    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')]
+    }
+  }
+
+  onDragStart = (ev: PointerDragEvent) => {
+    if (this.hitListener.initialHit) {
+      this.clearActiveSelection(ev)
+    }
+  }
+
+  onHitOver = (overHit: Hit) => {
+    let { initialHit } = this.hitListener
+    let initialComponent = initialHit.component
+    let calendar = initialComponent.getCalendar()
+    let dragSelection = computeSelection(initialHit, overHit)
+
+    if (dragSelection) {
+      this.dragSelection = dragSelection
+      calendar.setSelectionState(dragSelection)
+    }
+  }
+
+  onHitOut = (hit: Selection) => {
+    let { initialHit, finalHit } = this.hitListener
+    let initialComponent = initialHit.component
+    let calendar = initialComponent.getCalendar()
+
+    if (!finalHit) { // still dragging? a hitout means it went out of bounds
+      this.dragSelection = null
+      calendar.clearSelectionState()
+    }
+  }
+
+  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)
+    }
+
+    if (this.hitListener) {
+      this.hitListener.destroy()
+      this.hitListener = null
+    }
+
+    this.dragComponent = null
+    this.dragSelection = null
+    this.pointerDownEl = null
+  }
+
+  maybeUnfocus(ev: PointerDragEvent) {
+    if (this.activeSelection) {
+      let view = this.activeComponent.view
+      let unselectAuto = view.opt('unselectAuto')
+      let unselectCancel = view.opt('unselectCancel')
+
+      if (unselectAuto && (!unselectCancel || !elementClosest(this.pointerDownEl, unselectCancel))) {
+        this.clearActiveSelection(ev)
+      }
+    }
+  }
+
+  setActiveSelection(component: InteractiveDateComponent, selection: Selection, ev: PointerDragEvent) {
+    this.clearActiveSelection(ev)
+    this.activeComponent = component
+    this.activeSelection = selection
+    let view = component.view
+    view.calendar.triggerSelect(selection, view, ev.origEvent)
+  }
+
+  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
+    }
+  }
+
+}
+
+function getComponentDelay(component): number {
+  let delay = component.opt('selectLongPressDelay')
+  if (delay == null) {
+    delay = component.opt('longPressDelay')
+  }
+  return delay
+}
+
+function computeSelection(hit0: Hit, hit1: Hit): Selection {
+  let ms = [
+    hit0.range.start,
+    hit0.range.end,
+    hit1.range.start,
+    hit1.range.end
+  ]
+
+  ms.sort(compareNumbers)
+
+  return {
+    range: new UnzonedRange(ms[0], ms[3]),
+    isAllDay: hit0.isAllDay
+  }
+}
+
+// TODO: isSelectionFootprintAllowed

+ 13 - 0
src/reducers/main.ts

@@ -3,6 +3,7 @@ import { DateComponentRenderState } from '../component/DateComponent'
 import { EventSourceHash, reduceEventSourceHash } from './event-sources'
 import { reduceEventStore } from './event-store'
 import { DateMarker } from '../datelib/marker'
+import { assignTo } from '../util/object'
 
 export interface CalendarState extends DateComponentRenderState {
   loadingLevel: number
@@ -104,6 +105,18 @@ export function reduce(state: CalendarState, action: any, calendar: Calendar): C
       })
       break
 
+    case 'SELECT':
+      return assignTo({}, state, {
+        selection: action.selection
+      })
+
+    case 'UNSELECT':
+      if (state.selection) { // if already no selection, don't bother
+        return assignTo({}, state, {
+          selection: null
+        })
+      }
+
   }
 
   return newState