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

Merge branch 'master' into weeknr-in-daycell

Conflicts:
	src/common/common.css
Peter Nowee 9 лет назад
Родитель
Сommit
f3fb8c7a5d

+ 13 - 1
CHANGELOG.md

@@ -1,8 +1,20 @@
 
+v2.7.0 (2016-04-23)
+-------------------
+
+touch device support (#994):
+	- smoother scrolling
+	- interactions initiated via "long press":
+		- event drag-n-drop
+		- event resize
+		- time-range selecting
+	- `longPressDelay`
+
+
 v2.6.1 (2016-02-17)
 -------------------
 
-- make nowIndicator positioning refresh on window resize
+- make `nowIndicator` positioning refresh on window resize
 
 
 v2.6.0 (2016-01-07)

+ 4 - 1
lumbar.json

@@ -18,10 +18,12 @@
         "src/moment-ext.js",
         "src/date-formatting.js",
         "src/common/Class.js",
-        "src/common/Emitter.js",
+        "src/common/EmitterMixin.js",
+        "src/common/ListenerMixin.js",
         "src/common/Popover.js",
         "src/common/CoordCache.js",
         "src/common/DragListener.js",
+        "src/common/DragListener.autoscroll.js",
         "src/common/HitDragListener.js",
         "src/common/MouseFollower.js",
         "src/common/Grid.js",
@@ -33,6 +35,7 @@
         "src/common/TimeGrid.js",
         "src/common/TimeGrid.events.js",
         "src/common/View.js",
+        "src/common/Scroller.js",
         "src/Calendar.js",
         "src/defaults.js",
         "src/lang.js",

+ 11 - 2
src/Calendar.js

@@ -9,6 +9,7 @@ var Calendar = FC.Calendar = Class.extend({
 	view: null, // current View object
 	header: null,
 	loadingLevel: 0, // number of simultaneous loading tasks
+	isTouch: false,
 
 
 	// a lot of this class' OOP logic is scoped within this constructor function,
@@ -55,6 +56,10 @@ var Calendar = FC.Calendar = Class.extend({
 		]);
 		populateInstanceComputableOptions(this.options);
 
+		this.isTouch = this.options.isTouch != null ?
+			this.options.isTouch :
+			FC.isTouch;
+
 		this.viewSpecCache = {}; // somewhat unrelated
 	},
 
@@ -253,7 +258,7 @@ var Calendar = FC.Calendar = Class.extend({
 });
 
 
-Calendar.mixin(Emitter);
+Calendar.mixin(EmitterMixin);
 
 
 function Calendar_constructor(element, overrides) {
@@ -553,6 +558,10 @@ function Calendar_constructor(element, overrides) {
 		tm = options.theme ? 'ui' : 'fc';
 		element.addClass('fc');
 
+		element.addClass(
+			t.isTouch ? 'fc-touch' : 'fc-cursor'
+		);
+
 		if (options.isRTL) {
 			element.addClass('fc-rtl');
 		}
@@ -595,7 +604,7 @@ function Calendar_constructor(element, overrides) {
 
 		header.removeElement();
 		content.remove();
-		element.removeClass('fc fc-ltr fc-rtl fc-unthemed ui-widget');
+		element.removeClass('fc fc-touch fc-cursor fc-ltr fc-rtl fc-unthemed ui-widget');
 
 		if (windowResizeProxy) {
 			$(window).unbind('resize', windowResizeProxy);

+ 53 - 23
src/agenda/AgendaView.js

@@ -6,6 +6,8 @@
 
 var AgendaView = FC.AgendaView = View.extend({
 
+	scroller: null,
+
 	timeGridClass: TimeGrid, // class used to instantiate the timeGrid. subclasses can override
 	timeGrid: null, // the main time-grid subcomponent of this view
 
@@ -15,11 +17,10 @@ var AgendaView = FC.AgendaView = View.extend({
 	axisWidth: null, // the width of the time axis running down the side
 
 	headContainerEl: null, // div that hold's the timeGrid's rendered date header
-	noScrollRowEls: null, // set of fake row elements that must compensate when scrollerEl has scrollbars
+	noScrollRowEls: null, // set of fake row elements that must compensate when scroller has scrollbars
 
 	// when the time-grid isn't tall enough to occupy the given height, we render an <hr> underneath
 	bottomRuleEl: null,
-	bottomRuleHeight: null,
 
 
 	initialize: function() {
@@ -28,6 +29,11 @@ var AgendaView = FC.AgendaView = View.extend({
 		if (this.opt('allDaySlot')) { // should we display the "all-day" area?
 			this.dayGrid = this.instantiateDayGrid(); // the all-day subcomponent of this view
 		}
+
+		this.scroller = new Scroller({
+			overflowX: 'hidden',
+			overflowY: 'auto'
+		});
 	},
 
 
@@ -68,10 +74,12 @@ var AgendaView = FC.AgendaView = View.extend({
 		this.el.addClass('fc-agenda-view').html(this.renderSkeletonHtml());
 		this.renderHead();
 
-		// the element that wraps the time-grid that will probably scroll
-		this.scrollerEl = this.el.find('.fc-time-grid-container');
+		this.scroller.render();
+		var timeGridWrapEl = this.scroller.el.addClass('fc-time-grid-container');
+		var timeGridEl = $('<div class="fc-time-grid" />').appendTo(timeGridWrapEl);
+		this.el.find('.fc-body > tr > td').append(timeGridWrapEl);
 
-		this.timeGrid.setElement(this.el.find('.fc-time-grid'));
+		this.timeGrid.setElement(timeGridEl);
 		this.timeGrid.renderDates();
 
 		// the <hr> that sometimes displays under the time-grid
@@ -108,6 +116,8 @@ var AgendaView = FC.AgendaView = View.extend({
 			this.dayGrid.unrenderDates();
 			this.dayGrid.removeElement();
 		}
+
+		this.scroller.destroy();
 	},
 
 
@@ -129,9 +139,6 @@ var AgendaView = FC.AgendaView = View.extend({
 								'<hr class="fc-divider ' + this.widgetHeaderClass + '"/>' :
 								''
 								) +
-							'<div class="fc-time-grid-container">' +
-								'<div class="fc-time-grid"/>' +
-							'</div>' +
 						'</td>' +
 					'</tr>' +
 				'</tbody>' +
@@ -211,16 +218,11 @@ var AgendaView = FC.AgendaView = View.extend({
 	setHeight: function(totalHeight, isAuto) {
 		var eventLimit;
 		var scrollerHeight;
-
-		if (this.bottomRuleHeight === null) {
-			// calculate the height of the rule the very first time
-			this.bottomRuleHeight = this.bottomRuleEl.outerHeight();
-		}
-		this.bottomRuleEl.hide(); // .show() will be called later if this <hr> is necessary
+		var scrollbarWidths;
 
 		// reset all dimensions back to the original state
-		this.scrollerEl.css('overflow', '');
-		unsetScroller(this.scrollerEl);
+		this.bottomRuleEl.hide(); // .show() will be called later if this <hr> is necessary
+		this.scroller.clear(); // sets height to 'auto' and clears overflow
 		uncompensateScroll(this.noScrollRowEls);
 
 		// limit number of events in the all-day area
@@ -236,28 +238,46 @@ var AgendaView = FC.AgendaView = View.extend({
 			}
 		}
 
-		if (!isAuto) { // should we force dimensions of the scroll container, or let the contents be natural height?
+		if (!isAuto) { // should we force dimensions of the scroll container?
 
 			scrollerHeight = this.computeScrollerHeight(totalHeight);
-			if (setPotentialScroller(this.scrollerEl, scrollerHeight)) { // using scrollbars?
+			this.scroller.setHeight(scrollerHeight);
+			scrollbarWidths = this.scroller.getScrollbarWidths();
+
+			if (scrollbarWidths.left || scrollbarWidths.right) { // using scrollbars?
 
 				// make the all-day and header rows lines up
-				compensateScroll(this.noScrollRowEls, getScrollbarWidths(this.scrollerEl));
+				compensateScroll(this.noScrollRowEls, scrollbarWidths);
 
 				// the scrollbar compensation might have changed text flow, which might affect height, so recalculate
 				// and reapply the desired height to the scroller.
 				scrollerHeight = this.computeScrollerHeight(totalHeight);
-				this.scrollerEl.height(scrollerHeight);
+				this.scroller.setHeight(scrollerHeight);
 			}
-			else { // no scrollbars
-				// still, force a height and display the bottom rule (marks the end of day)
-				this.scrollerEl.height(scrollerHeight).css('overflow', 'hidden'); // in case <hr> goes outside
+
+			// guarantees the same scrollbar widths
+			this.scroller.lockOverflow(scrollbarWidths);
+
+			// if there's any space below the slats, show the horizontal rule.
+			// this won't cause any new overflow, because lockOverflow already called.
+			if (this.timeGrid.getTotalSlatHeight() < scrollerHeight) {
 				this.bottomRuleEl.show();
 			}
 		}
 	},
 
 
+	// given a desired total height of the view, returns what the height of the scroller should be
+	computeScrollerHeight: function(totalHeight) {
+		return totalHeight -
+			subtractInnerElHeight(this.el, this.scroller.el); // everything that's NOT the scroller
+	},
+
+
+	/* Scroll
+	------------------------------------------------------------------------------------------------------------------*/
+
+
 	// Computes the initial pre-configured scroll state prior to allowing the user to change it
 	computeInitialScroll: function() {
 		var scrollTime = moment.duration(this.opt('scrollTime'));
@@ -274,6 +294,16 @@ var AgendaView = FC.AgendaView = View.extend({
 	},
 
 
+	queryScroll: function() {
+		return this.scroller.getScrollTop();
+	},
+
+
+	setScroll: function(top) {
+		this.scroller.setScrollTop(top);
+	},
+
+
 	/* Hit Areas
 	------------------------------------------------------------------------------------------------------------------*/
 	// forward all hit-related method calls to the grids (dayGrid might not be defined)

+ 35 - 3
src/agenda/agenda.css

@@ -199,6 +199,20 @@ be a descendant of the grid when it is being dragged.
 	overflow: hidden; /* don't let the bg flow over rounded corners */
 }
 
+.fc-time-grid-event.fc-selected {
+	/* need to allow touch resizers to extend outside event's bounding box */
+	/* common fc-selected styles hide the fc-bg, so don't need this anyway */
+	overflow: visible;
+}
+
+.fc-time-grid-event.fc-selected .fc-bg {
+	display: none; /* hide semi-white background, to appear darker */
+}
+
+.fc-time-grid-event .fc-content {
+	overflow: hidden; /* for when .fc-selected */
+}
+
 .fc-time-grid-event .fc-time,
 .fc-time-grid-event .fc-title {
 	padding: 0 1px;
@@ -240,9 +254,9 @@ be a descendant of the grid when it is being dragged.
 	padding: 0; /* undo padding from above */
 }
 
-/* resizer */
+/* resizer (cursor device) */
 
-.fc-time-grid-event .fc-resizer {
+.fc-cursor .fc-time-grid-event .fc-resizer {
 	left: 0;
 	right: 0;
 	bottom: 0;
@@ -255,10 +269,28 @@ be a descendant of the grid when it is being dragged.
 	cursor: s-resize;
 }
 
-.fc-time-grid-event .fc-resizer:after {
+.fc-cursor .fc-time-grid-event .fc-resizer:after {
 	content: "=";
 }
 
+/* resizer (touch device) */
+
+.fc-touch .fc-time-grid-event .fc-resizer {
+	/* 10x10 dot */
+	border-radius: 5px;
+	border-width: 1px;
+	width: 8px;
+	height: 8px;
+	border-style: solid;
+	border-color: inherit;
+	background: #fff;
+	/* horizontally center */
+	left: 50%;
+	margin-left: -5px;
+	/* center on the bottom edge */
+	bottom: -5px;
+}
+
 
 /* Now Indicator
 --------------------------------------------------------------------------------------------------*/

+ 53 - 13
src/basic/BasicView.js

@@ -6,6 +6,8 @@
 
 var BasicView = FC.BasicView = View.extend({
 
+	scroller: null,
+
 	dayGridClass: DayGrid, // class the dayGrid will be instantiated from (overridable by subclasses)
 	dayGrid: null, // the main subcomponent that does most of the heavy lifting
 
@@ -22,6 +24,11 @@ var BasicView = FC.BasicView = View.extend({
 
 	initialize: function() {
 		this.dayGrid = this.instantiateDayGrid();
+
+		this.scroller = new Scroller({
+			overflowX: 'hidden',
+			overflowY: 'auto'
+		});
 	},
 
 
@@ -83,9 +90,12 @@ var BasicView = FC.BasicView = View.extend({
 		this.el.addClass('fc-basic-view').html(this.renderSkeletonHtml());
 		this.renderHead();
 
-		this.scrollerEl = this.el.find('.fc-day-grid-container');
+		this.scroller.render();
+		var dayGridContainerEl = this.scroller.el.addClass('fc-day-grid-container');
+		var dayGridEl = $('<div class="fc-day-grid" />').appendTo(dayGridContainerEl);
+		this.el.find('.fc-body > tr > td').append(dayGridContainerEl);
 
-		this.dayGrid.setElement(this.el.find('.fc-day-grid'));
+		this.dayGrid.setElement(dayGridEl);
 		this.dayGrid.renderDates(this.hasRigidRows());
 	},
 
@@ -104,6 +114,7 @@ var BasicView = FC.BasicView = View.extend({
 	unrenderDates: function() {
 		this.dayGrid.unrenderDates();
 		this.dayGrid.removeElement();
+		this.scroller.destroy();
 	},
 
 
@@ -124,11 +135,7 @@ var BasicView = FC.BasicView = View.extend({
 				'</thead>' +
 				'<tbody class="fc-body">' +
 					'<tr>' +
-						'<td class="' + this.widgetContentClass + '">' +
-							'<div class="fc-day-grid-container">' +
-								'<div class="fc-day-grid"/>' +
-							'</div>' +
-						'</td>' +
+						'<td class="' + this.widgetContentClass + '"></td>' +
 					'</tr>' +
 				'</tbody>' +
 			'</table>';
@@ -171,9 +178,10 @@ var BasicView = FC.BasicView = View.extend({
 	setHeight: function(totalHeight, isAuto) {
 		var eventLimit = this.opt('eventLimit');
 		var scrollerHeight;
+		var scrollbarWidths;
 
 		// reset all heights to be natural
-		unsetScroller(this.scrollerEl);
+		this.scroller.clear();
 		uncompensateScroll(this.headRowEl);
 
 		this.dayGrid.removeSegPopover(); // kill the "more" popover if displayed
@@ -183,6 +191,8 @@ var BasicView = FC.BasicView = View.extend({
 			this.dayGrid.limitRows(eventLimit); // limit the levels first so the height can redistribute after
 		}
 
+		// distribute the height to the rows
+		// (totalHeight is a "recommended" value if isAuto)
 		scrollerHeight = this.computeScrollerHeight(totalHeight);
 		this.setGridHeight(scrollerHeight, isAuto);
 
@@ -191,17 +201,33 @@ var BasicView = FC.BasicView = View.extend({
 			this.dayGrid.limitRows(eventLimit); // limit the levels after the grid's row heights have been set
 		}
 
-		if (!isAuto && setPotentialScroller(this.scrollerEl, scrollerHeight)) { // using scrollbars?
+		if (!isAuto) { // should we force dimensions of the scroll container?
+
+			this.scroller.setHeight(scrollerHeight);
+			scrollbarWidths = this.scroller.getScrollbarWidths();
 
-			compensateScroll(this.headRowEl, getScrollbarWidths(this.scrollerEl));
+			if (scrollbarWidths.left || scrollbarWidths.right) { // using scrollbars?
 
-			// doing the scrollbar compensation might have created text overflow which created more height. redo
-			scrollerHeight = this.computeScrollerHeight(totalHeight);
-			this.scrollerEl.height(scrollerHeight);
+				compensateScroll(this.headRowEl, scrollbarWidths);
+
+				// doing the scrollbar compensation might have created text overflow which created more height. redo
+				scrollerHeight = this.computeScrollerHeight(totalHeight);
+				this.scroller.setHeight(scrollerHeight);
+			}
+
+			// guarantees the same scrollbar widths
+			this.scroller.lockOverflow(scrollbarWidths);
 		}
 	},
 
 
+	// given a desired total height of the view, returns what the height of the scroller should be
+	computeScrollerHeight: function(totalHeight) {
+		return totalHeight -
+			subtractInnerElHeight(this.el, this.scroller.el); // everything that's NOT the scroller
+	},
+
+
 	// Sets the height of just the DayGrid component in this view
 	setGridHeight: function(height, isAuto) {
 		if (isAuto) {
@@ -213,6 +239,20 @@ var BasicView = FC.BasicView = View.extend({
 	},
 
 
+	/* Scroll
+	------------------------------------------------------------------------------------------------------------------*/
+
+
+	queryScroll: function() {
+		return this.scroller.getScrollTop();
+	},
+
+
+	setScroll: function(top) {
+		this.scroller.setScrollTop(top);
+	},
+
+
 	/* Hit Areas
 	------------------------------------------------------------------------------------------------------------------*/
 	// forward all hit-related method calls to dayGrid

+ 1 - 1
src/common/Class.js

@@ -58,5 +58,5 @@ function extendClass(superClass, members) {
 
 
 function mixIntoClass(theClass, members) {
-	copyOwnProps(members.prototype || members, theClass.prototype); // TODO: copyNativeMethods?
+	copyOwnProps(members, theClass.prototype); // TODO: copyNativeMethods?
 }

+ 5 - 6
src/common/DayGrid.js

@@ -291,10 +291,7 @@ var DayGrid = FC.DayGrid = Grid.extend(DayTableMixin, {
 		// if a segment from the same calendar but another component is being dragged, render a helper event
 		if (seg && !seg.el.closest(this.el).length) {
 
-			this.renderEventLocationHelper(eventLocation, seg);
-			this.applyDragOpacity(this.helperEls);
-
-			return true; // a helper has been rendered
+			return this.renderEventLocationHelper(eventLocation, seg); // returns mock event elements
 		}
 	},
 
@@ -313,7 +310,7 @@ var DayGrid = FC.DayGrid = Grid.extend(DayTableMixin, {
 	// Renders a visual indication of an event being resized
 	renderEventResize: function(eventLocation, seg) {
 		this.renderHighlight(this.eventToSpan(eventLocation));
-		this.renderEventLocationHelper(eventLocation, seg);
+		return this.renderEventLocationHelper(eventLocation, seg); // returns mock event elements
 	},
 
 
@@ -359,7 +356,9 @@ var DayGrid = FC.DayGrid = Grid.extend(DayTableMixin, {
 			helperNodes.push(skeletonEl[0]);
 		});
 
-		this.helperEls = $(helperNodes); // array -> jQuery set
+		return ( // must return the elements rendered
+			this.helperEls = $(helperNodes) // array -> jQuery set
+		);
 	},
 
 

+ 185 - 0
src/common/DragListener.autoscroll.js

@@ -0,0 +1,185 @@
+/*
+this.scrollEl is set in DragListener
+*/
+DragListener.mixin({
+
+	isAutoScroll: false,
+
+	scrollBounds: null, // { top, bottom, left, right }
+	scrollTopVel: null, // pixels per second
+	scrollLeftVel: null, // pixels per second
+	scrollIntervalId: null, // ID of setTimeout for scrolling animation loop
+
+	// defaults
+	scrollSensitivity: 30, // pixels from edge for scrolling to start
+	scrollSpeed: 200, // pixels per second, at maximum speed
+	scrollIntervalMs: 50, // millisecond wait between scroll increment
+
+
+	initAutoScroll: function() {
+		var scrollEl = this.scrollEl;
+
+		this.isAutoScroll =
+			this.options.scroll &&
+			scrollEl &&
+			!scrollEl.is(window) &&
+			!scrollEl.is(document);
+
+		if (this.isAutoScroll) {
+			// debounce makes sure rapid calls don't happen
+			this.listenTo(scrollEl, 'scroll', debounce(this.handleDebouncedScroll, 100));
+		}
+	},
+
+
+	destroyAutoScroll: function() {
+		this.endAutoScroll(); // kill any animation loop
+
+		// remove the scroll handler if there is a scrollEl
+		if (this.isAutoScroll) {
+			this.stopListeningTo(this.scrollEl, 'scroll'); // will probably get removed by unbindHandlers too :(
+		}
+	},
+
+
+	// Computes and stores the bounding rectangle of scrollEl
+	computeScrollBounds: function() {
+		if (this.isAutoScroll) {
+			this.scrollBounds = getOuterRect(this.scrollEl);
+			// TODO: use getClientRect in future. but prevents auto scrolling when on top of scrollbars
+		}
+	},
+
+
+	// Called when the dragging is in progress and scrolling should be updated
+	updateAutoScroll: function(ev) {
+		var sensitivity = this.scrollSensitivity;
+		var bounds = this.scrollBounds;
+		var topCloseness, bottomCloseness;
+		var leftCloseness, rightCloseness;
+		var topVel = 0;
+		var leftVel = 0;
+
+		if (bounds) { // only scroll if scrollEl exists
+
+			// compute closeness to edges. valid range is from 0.0 - 1.0
+			topCloseness = (sensitivity - (getEvY(ev) - bounds.top)) / sensitivity;
+			bottomCloseness = (sensitivity - (bounds.bottom - getEvY(ev))) / sensitivity;
+			leftCloseness = (sensitivity - (getEvX(ev) - bounds.left)) / sensitivity;
+			rightCloseness = (sensitivity - (bounds.right - getEvX(ev))) / sensitivity;
+
+			// translate vertical closeness into velocity.
+			// mouse must be completely in bounds for velocity to happen.
+			if (topCloseness >= 0 && topCloseness <= 1) {
+				topVel = topCloseness * this.scrollSpeed * -1; // negative. for scrolling up
+			}
+			else if (bottomCloseness >= 0 && bottomCloseness <= 1) {
+				topVel = bottomCloseness * this.scrollSpeed;
+			}
+
+			// translate horizontal closeness into velocity
+			if (leftCloseness >= 0 && leftCloseness <= 1) {
+				leftVel = leftCloseness * this.scrollSpeed * -1; // negative. for scrolling left
+			}
+			else if (rightCloseness >= 0 && rightCloseness <= 1) {
+				leftVel = rightCloseness * this.scrollSpeed;
+			}
+		}
+
+		this.setScrollVel(topVel, leftVel);
+	},
+
+
+	// Sets the speed-of-scrolling for the scrollEl
+	setScrollVel: function(topVel, leftVel) {
+
+		this.scrollTopVel = topVel;
+		this.scrollLeftVel = leftVel;
+
+		this.constrainScrollVel(); // massages into realistic values
+
+		// if there is non-zero velocity, and an animation loop hasn't already started, then START
+		if ((this.scrollTopVel || this.scrollLeftVel) && !this.scrollIntervalId) {
+			this.scrollIntervalId = setInterval(
+				proxy(this, 'scrollIntervalFunc'), // scope to `this`
+				this.scrollIntervalMs
+			);
+		}
+	},
+
+
+	// Forces scrollTopVel and scrollLeftVel to be zero if scrolling has already gone all the way
+	constrainScrollVel: function() {
+		var el = this.scrollEl;
+
+		if (this.scrollTopVel < 0) { // scrolling up?
+			if (el.scrollTop() <= 0) { // already scrolled all the way up?
+				this.scrollTopVel = 0;
+			}
+		}
+		else if (this.scrollTopVel > 0) { // scrolling down?
+			if (el.scrollTop() + el[0].clientHeight >= el[0].scrollHeight) { // already scrolled all the way down?
+				this.scrollTopVel = 0;
+			}
+		}
+
+		if (this.scrollLeftVel < 0) { // scrolling left?
+			if (el.scrollLeft() <= 0) { // already scrolled all the left?
+				this.scrollLeftVel = 0;
+			}
+		}
+		else if (this.scrollLeftVel > 0) { // scrolling right?
+			if (el.scrollLeft() + el[0].clientWidth >= el[0].scrollWidth) { // already scrolled all the way right?
+				this.scrollLeftVel = 0;
+			}
+		}
+	},
+
+
+	// This function gets called during every iteration of the scrolling animation loop
+	scrollIntervalFunc: function() {
+		var el = this.scrollEl;
+		var frac = this.scrollIntervalMs / 1000; // considering animation frequency, what the vel should be mult'd by
+
+		// change the value of scrollEl's scroll
+		if (this.scrollTopVel) {
+			el.scrollTop(el.scrollTop() + this.scrollTopVel * frac);
+		}
+		if (this.scrollLeftVel) {
+			el.scrollLeft(el.scrollLeft() + this.scrollLeftVel * frac);
+		}
+
+		this.constrainScrollVel(); // since the scroll values changed, recompute the velocities
+
+		// if scrolled all the way, which causes the vels to be zero, stop the animation loop
+		if (!this.scrollTopVel && !this.scrollLeftVel) {
+			this.endAutoScroll();
+		}
+	},
+
+
+	// Kills any existing scrolling animation loop
+	endAutoScroll: function() {
+		if (this.scrollIntervalId) {
+			clearInterval(this.scrollIntervalId);
+			this.scrollIntervalId = null;
+
+			this.handleScrollEnd();
+		}
+	},
+
+
+	// Get called when the scrollEl is scrolled (NOTE: this is delayed via debounce)
+	handleDebouncedScroll: function() {
+		// recompute all coordinates, but *only* if this is *not* part of our scrolling animation
+		if (!this.scrollIntervalId) {
+			this.handleScrollEnd();
+		}
+	},
+
+
+	// Called when scrolling has stopped, whether through auto scroll, or the user scrolling
+	handleScrollEnd: function() {
+	}
+
+});

+ 212 - 258
src/common/DragListener.js

@@ -3,385 +3,339 @@
 ----------------------------------------------------------------------------------------------------------------------*/
 // TODO: use Emitter
 
-var DragListener = FC.DragListener = Class.extend({
+var DragListener = FC.DragListener = Class.extend(ListenerMixin, {
 
 	options: null,
 
-	isListening: false,
-	isDragging: false,
+	// for IE8 bug-fighting behavior
+	subjectEl: null,
+	subjectHref: null,
 
 	// coordinates of the initial mousedown
 	originX: null,
 	originY: null,
 
-	// handler attached to the document, bound to the DragListener's `this`
-	mousemoveProxy: null,
-	mouseupProxy: null,
-
-	// for IE8 bug-fighting behavior, for now
-	subjectEl: null, // the element being draged. optional
-	subjectHref: null,
-
 	scrollEl: null,
-	scrollBounds: null, // { top, bottom, left, right }
-	scrollTopVel: null, // pixels per second
-	scrollLeftVel: null, // pixels per second
-	scrollIntervalId: null, // ID of setTimeout for scrolling animation loop
-	scrollHandlerProxy: null, // this-scoped function for handling when scrollEl is scrolled
 
-	scrollSensitivity: 30, // pixels from edge for scrolling to start
-	scrollSpeed: 200, // pixels per second, at maximum speed
-	scrollIntervalMs: 50, // millisecond wait between scroll increment
+	isInteracting: false,
+	isDistanceSurpassed: false,
+	isDelayEnded: false,
+	isDragging: false,
+	isTouch: false,
+
+	delay: null,
+	delayTimeoutId: null,
+	minDistance: null,
 
 
 	constructor: function(options) {
-		options = options || {};
-		this.options = options;
-		this.subjectEl = options.subjectEl;
+		this.options = options || {};
 	},
 
 
-	// Call this when the user does a mousedown. Will probably lead to startListening
-	mousedown: function(ev) {
-		if (isPrimaryMouseButton(ev)) {
+	// Interaction (high-level)
+	// -----------------------------------------------------------------------------------------------------------------
 
-			ev.preventDefault(); // prevents native selection in most browsers
 
-			this.startListening(ev);
+	startInteraction: function(ev, extraOptions) {
+		var isTouch = getEvIsTouch(ev);
 
-			// start the drag immediately if there is no minimum distance for a drag start
-			if (!this.options.distance) {
-				this.startDrag(ev);
+		if (ev.type === 'mousedown') {
+			if (!isPrimaryMouseButton(ev)) {
+				return;
+			}
+			else {
+				ev.preventDefault(); // prevents native selection in most browsers
 			}
 		}
-	},
 
+		if (!this.isInteracting) {
 
-	// Call this to start tracking mouse movements
-	startListening: function(ev) {
-		var scrollParent;
+			// process options
+			extraOptions = extraOptions || {};
+			this.delay = firstDefined(extraOptions.delay, this.options.delay, 0);
+			this.minDistance = firstDefined(extraOptions.distance, this.options.distance, 0);
+			this.subjectEl = this.options.subjectEl;
 
-		if (!this.isListening) {
+			this.isInteracting = true;
+			this.isTouch = isTouch;
+			this.isDelayEnded = false;
+			this.isDistanceSurpassed = false;
 
-			// grab scroll container and attach handler
-			if (ev && this.options.scroll) {
-				scrollParent = getScrollParent($(ev.target));
-				if (!scrollParent.is(window) && !scrollParent.is(document)) {
-					this.scrollEl = scrollParent;
+			this.originX = getEvX(ev);
+			this.originY = getEvY(ev);
+			this.scrollEl = getScrollParent($(ev.target));
 
-					// scope to `this`, and use `debounce` to make sure rapid calls don't happen
-					this.scrollHandlerProxy = debounce(proxy(this, 'scrollHandler'), 100);
-					this.scrollEl.on('scroll', this.scrollHandlerProxy);
-				}
+			this.bindHandlers();
+			this.initAutoScroll();
+			this.handleInteractionStart(ev);
+			this.startDelay(ev);
+
+			if (!this.minDistance) {
+				this.handleDistanceSurpassed(ev);
 			}
+		}
+	},
 
-			$(document)
-				.on('mousemove', this.mousemoveProxy = proxy(this, 'mousemove'))
-				.on('mouseup', this.mouseupProxy = proxy(this, 'mouseup'))
-				.on('selectstart', this.preventDefault); // prevents native selection in IE<=8
 
-			if (ev) {
-				this.originX = ev.pageX;
-				this.originY = ev.pageY;
-			}
-			else {
-				// if no starting information was given, origin will be the topleft corner of the screen.
-				// if so, dx/dy in the future will be the absolute coordinates.
-				this.originX = 0;
-				this.originY = 0;
+	handleInteractionStart: function(ev) {
+		this.trigger('interactionStart', ev);
+	},
+
+
+	endInteraction: function(ev) {
+		if (this.isInteracting) {
+			this.endDrag(ev);
+
+			if (this.delayTimeoutId) {
+				clearTimeout(this.delayTimeoutId);
+				this.delayTimeoutId = null;
 			}
 
-			this.isListening = true;
-			this.listenStart(ev);
+			this.destroyAutoScroll();
+			this.unbindHandlers();
+
+			this.isInteracting = false;
+			this.handleInteractionEnd(ev);
 		}
 	},
 
 
-	// Called when drag listening has started (but a real drag has not necessarily began)
-	listenStart: function(ev) {
-		this.trigger('listenStart', ev);
+	handleInteractionEnd: function(ev) {
+		this.trigger('interactionEnd', ev);
 	},
 
 
-	// Called when the user moves the mouse
-	mousemove: function(ev) {
-		var dx = ev.pageX - this.originX;
-		var dy = ev.pageY - this.originY;
-		var minDistance;
-		var distanceSq; // current distance from the origin, squared
+	// Binding To DOM
+	// -----------------------------------------------------------------------------------------------------------------
 
-		if (!this.isDragging) { // if not already dragging...
-			// then start the drag if the minimum distance criteria is met
-			minDistance = this.options.distance || 1;
-			distanceSq = dx * dx + dy * dy;
-			if (distanceSq >= minDistance * minDistance) { // use pythagorean theorem
-				this.startDrag(ev);
+
+	bindHandlers: function() {
+		var _this = this;
+		var touchStartIgnores = 1;
+
+		if (this.isTouch) {
+			this.listenTo($(document), {
+				touchmove: this.handleTouchMove,
+				touchend: this.endInteraction,
+				touchcancel: this.endInteraction,
+
+				// Sometimes touchend doesn't fire
+				// (can't figure out why. touchcancel doesn't fire either. has to do with scrolling?)
+				// If another touchstart happens, we know it's bogus, so cancel the drag.
+				// touchend will continue to be broken until user does a shorttap/scroll, but this is best we can do.
+				touchstart: function(ev) {
+					if (touchStartIgnores) { // bindHandlers is called from within a touchstart,
+						touchStartIgnores--; // and we don't want this to fire immediately, so ignore.
+					}
+					else {
+						_this.endInteraction(ev);
+					}
+				}
+			});
+
+			if (this.scrollEl) {
+				this.listenTo(this.scrollEl, 'scroll', this.handleTouchScroll);
 			}
 		}
-
-		if (this.isDragging) {
-			this.drag(dx, dy, ev); // report a drag, even if this mousemove initiated the drag
+		else {
+			this.listenTo($(document), {
+				mousemove: this.handleMouseMove,
+				mouseup: this.endInteraction
+			});
 		}
+
+		this.listenTo($(document), {
+			selectstart: preventDefault, // don't allow selection while dragging
+			contextmenu: preventDefault // long taps would open menu on Chrome dev tools
+		});
 	},
 
 
-	// Call this to initiate a legitimate drag.
-	// This function is called internally from this class, but can also be called explicitly from outside
-	startDrag: function(ev) {
+	unbindHandlers: function() {
+		this.stopListeningTo($(document));
 
-		if (!this.isListening) { // startDrag must have manually initiated
-			this.startListening();
+		if (this.scrollEl) {
+			this.stopListeningTo(this.scrollEl);
 		}
+	},
+
+
+	// Drag (high-level)
+	// -----------------------------------------------------------------------------------------------------------------
+
+
+	// extraOptions ignored if drag already started
+	startDrag: function(ev, extraOptions) {
+		this.startInteraction(ev, extraOptions); // ensure interaction began
 
 		if (!this.isDragging) {
 			this.isDragging = true;
-			this.dragStart(ev);
+			this.handleDragStart(ev);
 		}
 	},
 
 
-	// Called when the actual drag has started (went beyond minDistance)
-	dragStart: function(ev) {
-		var subjectEl = this.subjectEl;
-
+	handleDragStart: function(ev) {
 		this.trigger('dragStart', ev);
+		this.initHrefHack();
+	},
 
-		// remove a mousedown'd <a>'s href so it is not visited (IE8 bug)
-		if ((this.subjectHref = subjectEl ? subjectEl.attr('href') : null)) {
-			subjectEl.removeAttr('href');
+
+	handleMove: function(ev) {
+		var dx = getEvX(ev) - this.originX;
+		var dy = getEvY(ev) - this.originY;
+		var minDistance = this.minDistance;
+		var distanceSq; // current distance from the origin, squared
+
+		if (!this.isDistanceSurpassed) {
+			distanceSq = dx * dx + dy * dy;
+			if (distanceSq >= minDistance * minDistance) { // use pythagorean theorem
+				this.handleDistanceSurpassed(ev);
+			}
+		}
+
+		if (this.isDragging) {
+			this.handleDrag(dx, dy, ev);
 		}
 	},
 
 
 	// Called while the mouse is being moved and when we know a legitimate drag is taking place
-	drag: function(dx, dy, ev) {
+	handleDrag: function(dx, dy, ev) {
 		this.trigger('drag', dx, dy, ev);
-		this.updateScroll(ev); // will possibly cause scrolling
+		this.updateAutoScroll(ev); // will possibly cause scrolling
 	},
 
 
-	// Called when the user does a mouseup
-	mouseup: function(ev) {
-		this.stopListening(ev);
-	},
-
-
-	// Called when the drag is over. Will not cause listening to stop however.
-	// A concluding 'cellOut' event will NOT be triggered.
-	stopDrag: function(ev) {
+	endDrag: function(ev) {
 		if (this.isDragging) {
-			this.stopScrolling();
-			this.dragStop(ev);
 			this.isDragging = false;
+			this.handleDragEnd(ev);
 		}
 	},
 
 
-	// Called when dragging has been stopped
-	dragStop: function(ev) {
-		var _this = this;
-
-		this.trigger('dragStop', ev);
-
-		// restore a mousedown'd <a>'s href (for IE8 bug)
-		setTimeout(function() { // must be outside of the click's execution
-			if (_this.subjectHref) {
-				_this.subjectEl.attr('href', _this.subjectHref);
-			}
-		}, 0);
+	handleDragEnd: function(ev) {
+		this.trigger('dragEnd', ev);
+		this.destroyHrefHack();
 	},
 
 
-	// Call this to stop listening to the user's mouse events
-	stopListening: function(ev) {
-		this.stopDrag(ev); // if there's a current drag, kill it
-
-		if (this.isListening) {
-
-			// remove the scroll handler if there is a scrollEl
-			if (this.scrollEl) {
-				this.scrollEl.off('scroll', this.scrollHandlerProxy);
-				this.scrollHandlerProxy = null;
-			}
+	// Delay
+	// -----------------------------------------------------------------------------------------------------------------
 
-			$(document)
-				.off('mousemove', this.mousemoveProxy)
-				.off('mouseup', this.mouseupProxy)
-				.off('selectstart', this.preventDefault);
 
-			this.mousemoveProxy = null;
-			this.mouseupProxy = null;
+	startDelay: function(initialEv) {
+		var _this = this;
 
-			this.isListening = false;
-			this.listenStop(ev);
+		if (this.delay) {
+			this.delayTimeoutId = setTimeout(function() {
+				_this.handleDelayEnd(initialEv);
+			}, this.delay);
+		}
+		else {
+			this.handleDelayEnd(initialEv);
 		}
 	},
 
 
-	// Called when drag listening has stopped
-	listenStop: function(ev) {
-		this.trigger('listenStop', ev);
-	},
-
+	handleDelayEnd: function(initialEv) {
+		this.isDelayEnded = true;
 
-	// Triggers a callback. Calls a function in the option hash of the same name.
-	// Arguments beyond the first `name` are forwarded on.
-	trigger: function(name) {
-		if (this.options[name]) {
-			this.options[name].apply(this, Array.prototype.slice.call(arguments, 1));
+		if (this.isDistanceSurpassed) {
+			this.startDrag(initialEv);
 		}
 	},
 
 
-	// Stops a given mouse event from doing it's native browser action. In our case, text selection.
-	preventDefault: function(ev) {
-		ev.preventDefault();
-	},
-
+	// Distance
+	// -----------------------------------------------------------------------------------------------------------------
 
-	/* Scrolling
-	------------------------------------------------------------------------------------------------------------------*/
 
+	handleDistanceSurpassed: function(ev) {
+		this.isDistanceSurpassed = true;
 
-	// Computes and stores the bounding rectangle of scrollEl
-	computeScrollBounds: function() {
-		var el = this.scrollEl;
-
-		this.scrollBounds = el ? getOuterRect(el) : null;
-			// TODO: use getClientRect in future. but prevents auto scrolling when on top of scrollbars
+		if (this.isDelayEnded) {
+			this.startDrag(ev);
+		}
 	},
 
 
-	// Called when the dragging is in progress and scrolling should be updated
-	updateScroll: function(ev) {
-		var sensitivity = this.scrollSensitivity;
-		var bounds = this.scrollBounds;
-		var topCloseness, bottomCloseness;
-		var leftCloseness, rightCloseness;
-		var topVel = 0;
-		var leftVel = 0;
+	// Mouse / Touch
+	// -----------------------------------------------------------------------------------------------------------------
 
-		if (bounds) { // only scroll if scrollEl exists
-
-			// compute closeness to edges. valid range is from 0.0 - 1.0
-			topCloseness = (sensitivity - (ev.pageY - bounds.top)) / sensitivity;
-			bottomCloseness = (sensitivity - (bounds.bottom - ev.pageY)) / sensitivity;
-			leftCloseness = (sensitivity - (ev.pageX - bounds.left)) / sensitivity;
-			rightCloseness = (sensitivity - (bounds.right - ev.pageX)) / sensitivity;
-
-			// translate vertical closeness into velocity.
-			// mouse must be completely in bounds for velocity to happen.
-			if (topCloseness >= 0 && topCloseness <= 1) {
-				topVel = topCloseness * this.scrollSpeed * -1; // negative. for scrolling up
-			}
-			else if (bottomCloseness >= 0 && bottomCloseness <= 1) {
-				topVel = bottomCloseness * this.scrollSpeed;
-			}
 
-			// translate horizontal closeness into velocity
-			if (leftCloseness >= 0 && leftCloseness <= 1) {
-				leftVel = leftCloseness * this.scrollSpeed * -1; // negative. for scrolling left
-			}
-			else if (rightCloseness >= 0 && rightCloseness <= 1) {
-				leftVel = rightCloseness * this.scrollSpeed;
-			}
+	handleTouchMove: function(ev) {
+		// prevent inertia and touchmove-scrolling while dragging
+		if (this.isDragging) {
+			ev.preventDefault();
 		}
 
-		this.setScrollVel(topVel, leftVel);
+		this.handleMove(ev);
 	},
 
 
-	// Sets the speed-of-scrolling for the scrollEl
-	setScrollVel: function(topVel, leftVel) {
-
-		this.scrollTopVel = topVel;
-		this.scrollLeftVel = leftVel;
-
-		this.constrainScrollVel(); // massages into realistic values
-
-		// if there is non-zero velocity, and an animation loop hasn't already started, then START
-		if ((this.scrollTopVel || this.scrollLeftVel) && !this.scrollIntervalId) {
-			this.scrollIntervalId = setInterval(
-				proxy(this, 'scrollIntervalFunc'), // scope to `this`
-				this.scrollIntervalMs
-			);
-		}
+	handleMouseMove: function(ev) {
+		this.handleMove(ev);
 	},
 
 
-	// Forces scrollTopVel and scrollLeftVel to be zero if scrolling has already gone all the way
-	constrainScrollVel: function() {
-		var el = this.scrollEl;
+	// Scrolling (unrelated to auto-scroll)
+	// -----------------------------------------------------------------------------------------------------------------
 
-		if (this.scrollTopVel < 0) { // scrolling up?
-			if (el.scrollTop() <= 0) { // already scrolled all the way up?
-				this.scrollTopVel = 0;
-			}
-		}
-		else if (this.scrollTopVel > 0) { // scrolling down?
-			if (el.scrollTop() + el[0].clientHeight >= el[0].scrollHeight) { // already scrolled all the way down?
-				this.scrollTopVel = 0;
-			}
-		}
 
-		if (this.scrollLeftVel < 0) { // scrolling left?
-			if (el.scrollLeft() <= 0) { // already scrolled all the left?
-				this.scrollLeftVel = 0;
-			}
-		}
-		else if (this.scrollLeftVel > 0) { // scrolling right?
-			if (el.scrollLeft() + el[0].clientWidth >= el[0].scrollWidth) { // already scrolled all the way right?
-				this.scrollLeftVel = 0;
-			}
+	handleTouchScroll: function(ev) {
+		// if the drag is being initiated by touch, but a scroll happens before
+		// the drag-initiating delay is over, cancel the drag
+		if (!this.isDragging) {
+			this.endInteraction(ev);
 		}
 	},
 
 
-	// This function gets called during every iteration of the scrolling animation loop
-	scrollIntervalFunc: function() {
-		var el = this.scrollEl;
-		var frac = this.scrollIntervalMs / 1000; // considering animation frequency, what the vel should be mult'd by
+	// <A> HREF Hack
+	// -----------------------------------------------------------------------------------------------------------------
 
-		// change the value of scrollEl's scroll
-		if (this.scrollTopVel) {
-			el.scrollTop(el.scrollTop() + this.scrollTopVel * frac);
-		}
-		if (this.scrollLeftVel) {
-			el.scrollLeft(el.scrollLeft() + this.scrollLeftVel * frac);
-		}
 
-		this.constrainScrollVel(); // since the scroll values changed, recompute the velocities
+	initHrefHack: function() {
+		var subjectEl = this.subjectEl;
 
-		// if scrolled all the way, which causes the vels to be zero, stop the animation loop
-		if (!this.scrollTopVel && !this.scrollLeftVel) {
-			this.stopScrolling();
+		// remove a mousedown'd <a>'s href so it is not visited (IE8 bug)
+		if ((this.subjectHref = subjectEl ? subjectEl.attr('href') : null)) {
+			subjectEl.removeAttr('href');
 		}
 	},
 
 
-	// Kills any existing scrolling animation loop
-	stopScrolling: function() {
-		if (this.scrollIntervalId) {
-			clearInterval(this.scrollIntervalId);
-			this.scrollIntervalId = null;
+	destroyHrefHack: function() {
+		var subjectEl = this.subjectEl;
+		var subjectHref = this.subjectHref;
 
-			// when all done with scrolling, recompute positions since they probably changed
-			this.scrollStop();
-		}
+		// restore a mousedown'd <a>'s href (for IE8 bug)
+		setTimeout(function() { // must be outside of the click's execution
+			if (subjectHref) {
+				subjectEl.attr('href', subjectHref);
+			}
+		}, 0);
 	},
 
 
-	// Get called when the scrollEl is scrolled (NOTE: this is delayed via debounce)
-	scrollHandler: function() {
-		// recompute all coordinates, but *only* if this is *not* part of our scrolling animation
-		if (!this.scrollIntervalId) {
-			this.scrollStop();
-		}
-	},
+	// Utils
+	// -----------------------------------------------------------------------------------------------------------------
 
 
-	// Called when scrolling has stopped, whether through auto scroll, or the user scrolling
-	scrollStop: function() {
+	// Triggers a callback. Calls a function in the option hash of the same name.
+	// Arguments beyond the first `name` are forwarded on.
+	trigger: function(name) {
+		if (this.options[name]) {
+			this.options[name].apply(this, Array.prototype.slice.call(arguments, 1));
+		}
+		// makes _methods callable by event name. TODO: kill this
+		if (this['_' + name]) {
+			this['_' + name].apply(this, Array.prototype.slice.call(arguments, 1));
+		}
 	}
 
+
 });

+ 0 - 52
src/common/Emitter.js

@@ -1,52 +0,0 @@
-
-var Emitter = FC.Emitter = Class.extend({
-
-	callbackHash: null,
-
-
-	on: function(name, callback) {
-		this.getCallbacks(name).add(callback);
-		return this; // for chaining
-	},
-
-
-	off: function(name, callback) {
-		this.getCallbacks(name).remove(callback);
-		return this; // for chaining
-	},
-
-
-	trigger: function(name) { // args...
-		var args = Array.prototype.slice.call(arguments, 1);
-
-		this.triggerWith(name, this, args);
-
-		return this; // for chaining
-	},
-
-
-	triggerWith: function(name, context, args) {
-		var callbacks = this.getCallbacks(name);
-
-		callbacks.fireWith(context, args);
-
-		return this; // for chaining
-	},
-
-
-	getCallbacks: function(name) {
-		var callbacks;
-
-		if (!this.callbackHash) {
-			this.callbackHash = {};
-		}
-
-		callbacks = this.callbackHash[name];
-		if (!callbacks) {
-			callbacks = this.callbackHash[name] = $.Callbacks();
-		}
-
-		return callbacks;
-	}
-
-});

+ 66 - 0
src/common/EmitterMixin.js

@@ -0,0 +1,66 @@
+
+var EmitterMixin = FC.EmitterMixin = {
+
+	callbackHash: null,
+
+
+	on: function(name, callback) {
+		this.loopCallbacks(name, 'add', [ callback ]);
+
+		return this; // for chaining
+	},
+
+
+	off: function(name, callback) {
+		this.loopCallbacks(name, 'remove', [ callback ]);
+
+		return this; // for chaining
+	},
+
+
+	trigger: function(name) { // args...
+		var args = Array.prototype.slice.call(arguments, 1);
+
+		this.triggerWith(name, this, args);
+
+		return this; // for chaining
+	},
+
+
+	triggerWith: function(name, context, args) {
+		this.loopCallbacks(name, 'fireWith', [ context, args ]);
+
+		return this; // for chaining
+	},
+
+
+	/*
+	Given an event name string with possible namespaces,
+	call the given methodName on all the internal Callback object with the given arguments.
+	*/
+	loopCallbacks: function(name, methodName, args) {
+		var parts = name.split('.'); // "click.namespace" -> [ "click", "namespace" ]
+		var i, part;
+		var callbackObj;
+
+		for (i = 0; i < parts.length; i++) {
+			part = parts[i];
+			if (part) { // in case no event name like "click"
+				callbackObj = this.ensureCallbackObj((i ? '.' : '') + part); // put periods in front of namespaces
+				callbackObj[methodName].apply(callbackObj, args);
+			}
+		}
+	},
+
+
+	ensureCallbackObj: function(name) {
+		if (!this.callbackHash) {
+			this.callbackHash = {};
+		}
+		if (!this.callbackHash[name]) {
+			this.callbackHash[name] = $.Callbacks();
+		}
+		return this.callbackHash[name];
+	}
+
+};

+ 157 - 75
src/common/Grid.events.js

@@ -46,7 +46,8 @@ Grid.mixin({
 
 	// Unrenders all events currently rendered on the grid
 	unrenderEvents: function() {
-		this.triggerSegMouseout(); // trigger an eventMouseout if user's mouse is over an event
+		this.handleSegMouseout(); // trigger an eventMouseout if user's mouse is over an event
+		this.clearDragListeners();
 
 		this.unrenderFgSegs();
 		this.unrenderBgSegs();
@@ -174,46 +175,42 @@ Grid.mixin({
 
 	// Attaches event-element-related handlers to the container element and leverage bubbling
 	bindSegHandlers: function() {
+		if (this.view.calendar.isTouch) {
+			this.bindSegHandler('touchstart', this.handleSegTouchStart);
+		}
+		else {
+			this.bindSegHandler('mouseenter', this.handleSegMouseover);
+			this.bindSegHandler('mouseleave', this.handleSegMouseout);
+			this.bindSegHandler('mousedown', this.handleSegMousedown);
+		}
+
+		this.bindSegHandler('click', this.handleSegClick);
+	},
+
+
+	// Executes a handler for any a user-interaction on a segment.
+	// Handler gets called with (seg, ev), and with the `this` context of the Grid
+	bindSegHandler: function(name, handler) {
 		var _this = this;
-		var view = this.view;
 
-		$.each(
-			{
-				mouseenter: function(seg, ev) {
-					_this.triggerSegMouseover(seg, ev);
-				},
-				mouseleave: function(seg, ev) {
-					_this.triggerSegMouseout(seg, ev);
-				},
-				click: function(seg, ev) {
-					return view.trigger('eventClick', this, seg.event, ev); // can return `false` to cancel
-				},
-				mousedown: function(seg, ev) {
-					if ($(ev.target).is('.fc-resizer') && view.isEventResizable(seg.event)) {
-						_this.segResizeMousedown(seg, ev, $(ev.target).is('.fc-start-resizer'));
-					}
-					else if (view.isEventDraggable(seg.event)) {
-						_this.segDragMousedown(seg, ev);
-					}
-				}
-			},
-			function(name, func) {
-				// attach the handler to the container element and only listen for real event elements via bubbling
-				_this.el.on(name, '.fc-event-container > *', function(ev) {
-					var seg = $(this).data('fc-seg'); // grab segment data. put there by View::renderEvents
-
-					// only call the handlers if there is not a drag/resize in progress
-					if (seg && !_this.isDraggingSeg && !_this.isResizingSeg) {
-						return func.call(this, seg, ev); // `this` will be the event element
-					}
-				});
+		this.el.on(name, '.fc-event-container > *', function(ev) {
+			var seg = $(this).data('fc-seg'); // grab segment data. put there by View::renderEvents
+
+			// only call the handlers if there is not a drag/resize in progress
+			if (seg && !_this.isDraggingSeg && !_this.isResizingSeg) {
+				return handler.call(_this, seg, ev); // context will be the Grid
 			}
-		);
+		});
+	},
+
+
+	handleSegClick: function(seg, ev) {
+		return this.view.trigger('eventClick', seg.el[0], seg.event, ev); // can return `false` to cancel
 	},
 
 
 	// Updates internal state and triggers handlers for when an event element is moused over
-	triggerSegMouseover: function(seg, ev) {
+	handleSegMouseover: function(seg, ev) {
 		if (!this.mousedOverSeg) {
 			this.mousedOverSeg = seg;
 			this.view.trigger('eventMouseover', seg.el[0], seg.event, ev);
@@ -223,7 +220,7 @@ Grid.mixin({
 
 	// Updates internal state and triggers handlers for when an event element is moused out.
 	// Can be given no arguments, in which case it will mouseout the segment that was previously moused over.
-	triggerSegMouseout: function(seg, ev) {
+	handleSegMouseout: function(seg, ev) {
 		ev = ev || {}; // if given no args, make a mock mouse event
 
 		if (this.mousedOverSeg) {
@@ -234,45 +231,112 @@ Grid.mixin({
 	},
 
 
+	handleSegTouchStart: function(seg, ev) {
+		var view = this.view;
+		var event = seg.event;
+		var isSelected = view.isEventSelected(event);
+		var isDraggable = view.isEventDraggable(event);
+		var isResizable = view.isEventResizable(event);
+		var isResizing = false;
+		var dragListener;
+
+		if (isSelected && isResizable) {
+			// only allow resizing of the event is selected
+			isResizing = this.startSegResize(seg, ev);
+		}
+
+		if (!isResizing && (isDraggable || isResizable)) { // allowed to be selected?
+			this.clearDragListeners();
+
+			dragListener = isDraggable ?
+				this.buildSegDragListener(seg) :
+				new DragListener(); // seg isn't draggable, but let's use a generic DragListener
+				                    // simply for the delay, so it can be selected.
+
+			dragListener._dragStart = function() { // TODO: better way of binding
+				// if not previously selected, will fire after a delay. then, select the event
+				if (!isSelected) {
+					view.selectEvent(event);
+				}
+			};
+
+			dragListener.startInteraction(ev, {
+				delay: isSelected ? 0 : this.view.opt('longPressDelay') // do delay if not already selected
+			});
+		}
+	},
+
+
+	handleSegMousedown: function(seg, ev) {
+		var isResizing = this.startSegResize(seg, ev, { distance: 5 });
+
+		if (!isResizing && this.view.isEventDraggable(seg.event)) {
+			this.clearDragListeners();
+			this.buildSegDragListener(seg)
+				.startInteraction(ev, {
+					distance: 5
+				});
+		}
+	},
+
+
+	// returns boolean whether resizing actually started or not.
+	// assumes the seg allows resizing.
+	// `dragOptions` are optional.
+	startSegResize: function(seg, ev, dragOptions) {
+		if ($(ev.target).is('.fc-resizer')) {
+			this.clearDragListeners();
+			this.buildSegResizeListener(seg, $(ev.target).is('.fc-start-resizer'))
+				.startInteraction(ev, dragOptions);
+			return true;
+		}
+		return false;
+	},
+
+
+
 	/* Event Dragging
 	------------------------------------------------------------------------------------------------------------------*/
 
 
-	// Called when the user does a mousedown on an event, which might lead to dragging.
+	// Builds a listener that will track user-dragging on an event segment.
 	// Generic enough to work with any type of Grid.
-	segDragMousedown: function(seg, ev) {
+	buildSegDragListener: function(seg) {
 		var _this = this;
 		var view = this.view;
 		var calendar = view.calendar;
 		var el = seg.el;
 		var event = seg.event;
+		var isDragging;
+		var mouseFollower; // A clone of the original element that will move with the mouse
 		var dropLocation; // zoned event date properties
 
-		// A clone of the original element that will move with the mouse
-		var mouseFollower = new MouseFollower(seg.el, {
-			parentEl: view.el,
-			opacity: view.opt('dragOpacity'),
-			revertDuration: view.opt('dragRevertDuration'),
-			zIndex: 2 // one above the .fc-view
-		});
-
 		// Tracks mouse movement over the *view's* coordinate map. Allows dragging and dropping between subcomponents
 		// of the view.
-		var dragListener = new HitDragListener(view, {
-			distance: 5,
+		var dragListener = this.segDragListener = new HitDragListener(view, {
 			scroll: view.opt('dragScroll'),
 			subjectEl: el,
 			subjectCenter: true,
-			listenStart: function(ev) {
+			interactionStart: function(ev) {
+				isDragging = false;
+				mouseFollower = new MouseFollower(seg.el, {
+					additionalClass: 'fc-dragging',
+					parentEl: view.el,
+					opacity: dragListener.isTouch ? null : view.opt('dragOpacity'),
+					revertDuration: view.opt('dragRevertDuration'),
+					zIndex: 2 // one above the .fc-view
+				});
 				mouseFollower.hide(); // don't show until we know this is a real drag
 				mouseFollower.start(ev);
 			},
 			dragStart: function(ev) {
-				_this.triggerSegMouseout(seg, ev); // ensure a mouseout on the manipulated event has been reported
+				isDragging = true;
+				_this.handleSegMouseout(seg, ev); // ensure a mouseout on the manipulated event has been reported
 				_this.segDragStart(seg, ev);
 				view.hideEvent(event); // hide all event segments. our mouseFollower will take over
 			},
 			hitOver: function(hit, isOrig, origHit) {
+				var dragHelperEls;
 
 				// starting hit could be forced (DayGrid.limit)
 				if (seg.hit) {
@@ -292,7 +356,13 @@ Grid.mixin({
 				}
 
 				// if a valid drop location, have the subclass render a visual indication
-				if (dropLocation && view.renderDrag(dropLocation, seg)) {
+				if (dropLocation && (dragHelperEls = view.renderDrag(dropLocation, seg))) {
+
+					dragHelperEls.addClass('fc-dragging');
+					if (!dragListener.isTouch) {
+						_this.applyDragOpacity(dragHelperEls);
+					}
+
 					mouseFollower.hide(); // if the subclass is already using a mock event "helper", hide our own
 				}
 				else {
@@ -308,27 +378,26 @@ Grid.mixin({
 				mouseFollower.show(); // show in case we are moving out of all hits
 				dropLocation = null;
 			},
-			hitDone: function() { // Called after a hitOut OR before a dragStop
+			hitDone: function() { // Called after a hitOut OR before a dragEnd
 				enableCursor();
 			},
-			dragStop: function(ev) {
+			interactionEnd: function(ev) {
 				// do revert animation if hasn't changed. calls a callback when finished (whether animation or not)
 				mouseFollower.stop(!dropLocation, function() {
-					view.unrenderDrag();
-					view.showEvent(event);
-					_this.segDragStop(seg, ev);
-
+					if (isDragging) {
+						view.unrenderDrag();
+						view.showEvent(event);
+						_this.segDragStop(seg, ev);
+					}
 					if (dropLocation) {
 						view.reportEventDrop(event, dropLocation, this.largeUnit, el, ev);
 					}
 				});
-			},
-			listenStop: function() {
-				mouseFollower.stop(); // put in listenStop in case there was a mousedown but the drag never started
+				_this.segDragListener = null;
 			}
 		});
 
-		dragListener.mousedown(ev); // start listening, which will eventually lead to a dragStart
+		return dragListener;
 	},
 
 
@@ -444,8 +513,8 @@ Grid.mixin({
 		var dropLocation; // a null value signals an unsuccessful drag
 
 		// listener that tracks mouse movement over date-associated pixel regions
-		var dragListener = new HitDragListener(this, {
-			listenStart: function() {
+		var dragListener = _this.externalDragListener = new HitDragListener(this, {
+			interactionStart: function() {
 				_this.isDraggingExternal = true;
 			},
 			hitOver: function(hit) {
@@ -469,17 +538,16 @@ Grid.mixin({
 			hitOut: function() {
 				dropLocation = null; // signal unsuccessful
 			},
-			hitDone: function() { // Called after a hitOut OR before a dragStop
+			hitDone: function() { // Called after a hitOut OR before a dragEnd
 				enableCursor();
 				_this.unrenderDrag();
 			},
-			dragStop: function() {
+			interactionEnd: function(ev) {
 				if (dropLocation) { // element was dropped on a valid hit
 					_this.view.reportExternalDrop(meta, dropLocation, el, ev, ui);
 				}
-			},
-			listenStop: function() {
 				_this.isDraggingExternal = false;
+				_this.externalDragListener = null;
 			}
 		});
 
@@ -520,6 +588,7 @@ Grid.mixin({
 	// `dropLocation` contains hypothetical start/end/allDay values the event would have if dropped. end can be null.
 	// `seg` is the internal segment object that is being dragged. If dragging an external element, `seg` is null.
 	// A truthy returned value indicates this method has rendered a helper element.
+	// Must return elements used for any mock events.
 	renderDrag: function(dropLocation, seg) {
 		// subclasses must implement
 	},
@@ -535,24 +604,28 @@ Grid.mixin({
 	------------------------------------------------------------------------------------------------------------------*/
 
 
-	// Called when the user does a mousedown on an event's resizer, which might lead to resizing.
+	// Creates a listener that tracks the user as they resize an event segment.
 	// Generic enough to work with any type of Grid.
-	segResizeMousedown: function(seg, ev, isStart) {
+	buildSegResizeListener: function(seg, isStart) {
 		var _this = this;
 		var view = this.view;
 		var calendar = view.calendar;
 		var el = seg.el;
 		var event = seg.event;
 		var eventEnd = calendar.getEventEnd(event);
+		var isDragging;
 		var resizeLocation; // zoned event date properties. falsy if invalid resize
 
 		// Tracks mouse movement over the *grid's* coordinate map
-		var dragListener = new HitDragListener(this, {
-			distance: 5,
+		var dragListener = this.segResizeListener = new HitDragListener(this, {
 			scroll: view.opt('dragScroll'),
 			subjectEl: el,
+			interactionStart: function() {
+				isDragging = false;
+			},
 			dragStart: function(ev) {
-				_this.triggerSegMouseout(seg, ev); // ensure a mouseout on the manipulated event has been reported
+				isDragging = true;
+				_this.handleSegMouseout(seg, ev); // ensure a mouseout on the manipulated event has been reported
 				_this.segResizeStart(seg, ev);
 			},
 			hitOver: function(hit, isOrig, origHit) {
@@ -587,16 +660,18 @@ Grid.mixin({
 				view.showEvent(event);
 				enableCursor();
 			},
-			dragStop: function(ev) {
-				_this.segResizeStop(seg, ev);
-
+			interactionEnd: function(ev) {
+				if (isDragging) {
+					_this.segResizeStop(seg, ev);
+				}
 				if (resizeLocation) { // valid date to resize to?
 					view.reportEventResize(event, resizeLocation, this.largeUnit, el, ev);
 				}
+				_this.segResizeListener = null;
 			}
 		});
 
-		dragListener.mousedown(ev); // start listening, which will eventually lead to a dragStart
+		return dragListener;
 	},
 
 
@@ -673,6 +748,7 @@ Grid.mixin({
 
 	// Renders a visual indication of an event being resized.
 	// `range` has the updated dates of the event. `seg` is the original segment object involved in the drag.
+	// Must return elements used for any mock events.
 	renderEventResize: function(range, seg) {
 		// subclasses must implement
 	},
@@ -718,6 +794,7 @@ Grid.mixin({
 
 	// Generic utility for generating the HTML classNames for an event segment's element
 	getSegClasses: function(seg, isDraggable, isResizable) {
+		var view = this.view;
 		var event = seg.event;
 		var classes = [
 			'fc-event',
@@ -735,6 +812,11 @@ Grid.mixin({
 			classes.push('fc-resizable');
 		}
 
+		// event is currently selected? attach a className.
+		if (view.isEventSelected(event)) {
+			classes.push('fc-selected');
+		}
+
 		return classes;
 	},
 

+ 77 - 22
src/common/Grid.js

@@ -2,7 +2,7 @@
 /* An abstract class comprised of a "grid" of areas that each represent a specific datetime
 ----------------------------------------------------------------------------------------------------------------------*/
 
-var Grid = FC.Grid = Class.extend({
+var Grid = FC.Grid = Class.extend(ListenerMixin, {
 
 	view: null, // a View object
 	isRTL: null, // shortcut to the view's isRTL option
@@ -13,8 +13,6 @@ var Grid = FC.Grid = Class.extend({
 	el: null, // the containing element
 	elsByFill: null, // a hash of jQuery element sets used for rendering each fill. Keyed by fill name.
 
-	externalDragStartProxy: null, // binds the Grid's scope to externalDragStart (in DayGrid.events)
-
 	// derived from options
 	eventTimeFormat: null,
 	displayEventTime: null,
@@ -27,13 +25,16 @@ var Grid = FC.Grid = Class.extend({
 	// TODO: port isTimeScale into same system?
 	largeUnit: null,
 
+	dayDragListener: null,
+	segDragListener: null,
+	segResizeListener: null,
+	externalDragListener: null,
+
 
 	constructor: function(view) {
 		this.view = view;
 		this.isRTL = view.opt('isRTL');
-
 		this.elsByFill = {};
-		this.externalDragStartProxy = proxy(this, 'externalDragStart');
 	},
 
 
@@ -166,26 +167,37 @@ var Grid = FC.Grid = Class.extend({
 	// Sets the container element that the grid should render inside of.
 	// Does other DOM-related initializations.
 	setElement: function(el) {
-		var _this = this;
-
 		this.el = el;
+		preventSelection(el);
+
+		if (this.view.calendar.isTouch) {
+			this.bindDayHandler('touchstart', this.dayTouchStart);
+		}
+		else {
+			this.bindDayHandler('mousedown', this.dayMousedown);
+		}
+
+		// attach event-element-related handlers. in Grid.events
+		// same garbage collection note as above.
+		this.bindSegHandlers();
+
+		this.bindGlobalHandlers();
+	},
+
+
+	bindDayHandler: function(name, handler) {
+		var _this = this;
 
 		// attach a handler to the grid's root element.
 		// jQuery will take care of unregistering them when removeElement gets called.
-		el.on('mousedown', function(ev) {
+		this.el.on(name, function(ev) {
 			if (
 				!$(ev.target).is('.fc-event-container *, .fc-more') && // not an an event element, or "more.." link
 				!$(ev.target).closest('.fc-popover').length // not on a popover (like the "more.." events one)
 			) {
-				_this.dayMousedown(ev);
+				return handler.call(_this, ev);
 			}
 		});
-
-		// attach event-element-related handlers. in Grid.events
-		// same garbage collection note as above.
-		this.bindSegHandlers();
-
-		this.bindGlobalHandlers();
 	},
 
 
@@ -193,6 +205,7 @@ var Grid = FC.Grid = Class.extend({
 	// DOES NOT remove any content beforehand (doesn't clear events or call unrenderDates), unlike View
 	removeElement: function() {
 		this.unbindGlobalHandlers();
+		this.clearDragListeners();
 
 		this.el.remove();
 
@@ -225,18 +238,39 @@ var Grid = FC.Grid = Class.extend({
 
 	// Binds DOM handlers to elements that reside outside the grid, such as the document
 	bindGlobalHandlers: function() {
-		$(document).on('dragstart sortstart', this.externalDragStartProxy); // jqui
+		this.listenTo($(document), {
+			dragstart: this.externalDragStart, // jqui
+			sortstart: this.externalDragStart // jqui
+		});
 	},
 
 
 	// Unbinds DOM handlers from elements that reside outside the grid
 	unbindGlobalHandlers: function() {
-		$(document).off('dragstart sortstart', this.externalDragStartProxy); // jqui
+		this.stopListeningTo($(document));
 	},
 
 
 	// Process a mousedown on an element that represents a day. For day clicking and selecting.
 	dayMousedown: function(ev) {
+		this.clearDragListeners();
+		this.buildDayDragListener().startInteraction(ev, {
+			//distance: 5, // needs more work if we want dayClick to fire correctly
+		});
+	},
+
+
+	dayTouchStart: function(ev) {
+		this.clearDragListeners();
+		this.buildDayDragListener().startInteraction(ev, {
+			delay: this.view.opt('longPressDelay')
+		});
+	},
+
+
+	// Creates a listener that tracks the user's drag across day elements.
+	// For day clicking and selecting.
+	buildDayDragListener: function() {
 		var _this = this;
 		var view = this.view;
 		var isSelectable = view.opt('selectable');
@@ -246,8 +280,7 @@ var Grid = FC.Grid = Class.extend({
 		// this listener tracks a mousedown on a day element, and a subsequent drag.
 		// if the drag ends on the same day, it is a 'dayClick'.
 		// if 'selectable' is enabled, this listener also detects selections.
-		var dragListener = new HitDragListener(this, {
-			//distance: 5, // needs more work if we want dayClick to fire correctly
+		var dragListener = this.dayDragListener = new HitDragListener(this, {
 			scroll: view.opt('dragScroll'),
 			dragStart: function() {
 				view.unselect(); // since we could be rendering a new selection, we want to clear any old one
@@ -275,7 +308,7 @@ var Grid = FC.Grid = Class.extend({
 				_this.unrenderSelection();
 				enableCursor();
 			},
-			listenStop: function(ev) {
+			interactionEnd: function(ev) {
 				if (dayClickHit) {
 					view.triggerDayClick(
 						_this.getHitSpan(dayClickHit),
@@ -288,10 +321,30 @@ var Grid = FC.Grid = Class.extend({
 					view.reportSelection(selectionSpan, ev);
 				}
 				enableCursor();
+				_this.dayDragListener = null;
 			}
 		});
 
-		dragListener.mousedown(ev); // start listening, which will eventually initiate a dragStart
+		return dragListener;
+	},
+
+
+	// Kills all in-progress dragging.
+	// Useful for when public API methods that result in re-rendering are invoked during a drag.
+	// Also useful for when touch devices misbehave and don't fire their touchend.
+	clearDragListeners: function() {
+		if (this.dayDragListener) {
+			this.dayDragListener.endInteraction(); // will clear this.dayDragListener
+		}
+		if (this.segDragListener) {
+			this.segDragListener.endInteraction(); // will clear this.segDragListener
+		}
+		if (this.segResizeListener) {
+			this.segResizeListener.endInteraction(); // will clear this.segResizeListener
+		}
+		if (this.externalDragListener) {
+			this.externalDragListener.endInteraction(); // will clear this.externalDragListener
+		}
 	},
 
 
@@ -301,10 +354,11 @@ var Grid = FC.Grid = Class.extend({
 
 
 	// Renders a mock event at the given event location, which contains zoned start/end properties.
+	// Returns all mock event elements.
 	renderEventLocationHelper: function(eventLocation, sourceSeg) {
 		var fakeEvent = this.fabricateHelperEvent(eventLocation, sourceSeg);
 
-		this.renderHelper(fakeEvent, sourceSeg); // do the actual rendering
+		return this.renderHelper(fakeEvent, sourceSeg); // do the actual rendering
 	},
 
 
@@ -332,6 +386,7 @@ var Grid = FC.Grid = Class.extend({
 
 
 	// Renders a mock event. Given zoned event date properties.
+	// Must return all mock event elements.
 	renderHelper: function(eventLocation, sourceSeg) {
 		// subclasses must implement
 	},

+ 25 - 25
src/common/HitDragListener.js

@@ -25,18 +25,18 @@ var HitDragListener = DragListener.extend({
 
 	// Called when drag listening starts (but a real drag has not necessarily began).
 	// ev might be undefined if dragging was started manually.
-	listenStart: function(ev) {
+	handleInteractionStart: function(ev) {
 		var subjectEl = this.subjectEl;
 		var subjectRect;
 		var origPoint;
 		var point;
 
-		DragListener.prototype.listenStart.apply(this, arguments); // call the super-method
+		DragListener.prototype.handleInteractionStart.apply(this, arguments); // call the super-method
 
 		this.computeCoords();
 
 		if (ev) {
-			origPoint = { left: ev.pageX, top: ev.pageY };
+			origPoint = { left: getEvX(ev), top: getEvY(ev) };
 			point = origPoint;
 
 			// constrain the point to bounds of the element being dragged
@@ -72,55 +72,55 @@ var HitDragListener = DragListener.extend({
 	// Recomputes the drag-critical positions of elements
 	computeCoords: function() {
 		this.component.prepareHits();
-		this.computeScrollBounds(); // why is this here???
+		this.computeScrollBounds(); // why is this here??????
 	},
 
 
 	// Called when the actual drag has started
-	dragStart: function(ev) {
+	handleDragStart: function(ev) {
 		var hit;
 
-		DragListener.prototype.dragStart.apply(this, arguments); // call the super-method
+		DragListener.prototype.handleDragStart.apply(this, arguments); // call the super-method
 
 		// might be different from this.origHit if the min-distance is large
-		hit = this.queryHit(ev.pageX, ev.pageY);
+		hit = this.queryHit(getEvX(ev), getEvY(ev));
 
 		// report the initial hit the mouse is over
 		// especially important if no min-distance and drag starts immediately
 		if (hit) {
-			this.hitOver(hit);
+			this.handleHitOver(hit);
 		}
 	},
 
 
 	// Called when the drag moves
-	drag: function(dx, dy, ev) {
+	handleDrag: function(dx, dy, ev) {
 		var hit;
 
-		DragListener.prototype.drag.apply(this, arguments); // call the super-method
+		DragListener.prototype.handleDrag.apply(this, arguments); // call the super-method
 
-		hit = this.queryHit(ev.pageX, ev.pageY);
+		hit = this.queryHit(getEvX(ev), getEvY(ev));
 
 		if (!isHitsEqual(hit, this.hit)) { // a different hit than before?
 			if (this.hit) {
-				this.hitOut();
+				this.handleHitOut();
 			}
 			if (hit) {
-				this.hitOver(hit);
+				this.handleHitOver(hit);
 			}
 		}
 	},
 
 
 	// Called when dragging has been stopped
-	dragStop: function() {
-		this.hitDone();
-		DragListener.prototype.dragStop.apply(this, arguments); // call the super-method
+	handleDragEnd: function() {
+		this.handleHitDone();
+		DragListener.prototype.handleDragEnd.apply(this, arguments); // call the super-method
 	},
 
 
 	// Called when a the mouse has just moved over a new hit
-	hitOver: function(hit) {
+	handleHitOver: function(hit) {
 		var isOrig = isHitsEqual(hit, this.origHit);
 
 		this.hit = hit;
@@ -130,26 +130,26 @@ var HitDragListener = DragListener.extend({
 
 
 	// Called when the mouse has just moved out of a hit
-	hitOut: function() {
+	handleHitOut: function() {
 		if (this.hit) {
 			this.trigger('hitOut', this.hit);
-			this.hitDone();
+			this.handleHitDone();
 			this.hit = null;
 		}
 	},
 
 
 	// Called after a hitOut. Also called before a dragStop
-	hitDone: function() {
+	handleHitDone: function() {
 		if (this.hit) {
 			this.trigger('hitDone', this.hit);
 		}
 	},
 
 
-	// Called when drag listening has stopped
-	listenStop: function() {
-		DragListener.prototype.listenStop.apply(this, arguments); // call the super-method
+	// Called when the interaction ends, whether there was a real drag or not
+	handleInteractionEnd: function() {
+		DragListener.prototype.handleInteractionEnd.apply(this, arguments); // call the super-method
 
 		this.origHit = null;
 		this.hit = null;
@@ -159,8 +159,8 @@ var HitDragListener = DragListener.extend({
 
 
 	// Called when scrolling has stopped, whether through auto scroll, or the user scrolling
-	scrollStop: function() {
-		DragListener.prototype.scrollStop.apply(this, arguments); // call the super-method
+	handleScrollEnd: function() {
+		DragListener.prototype.handleScrollEnd.apply(this, arguments); // call the super-method
 
 		this.computeCoords(); // hits' absolute positions will be in new places. recompute
 	},

+ 61 - 0
src/common/ListenerMixin.js

@@ -0,0 +1,61 @@
+
+/*
+Utility methods for easily listening to events on another object,
+and more importantly, easily unlistening from them.
+*/
+var ListenerMixin = FC.ListenerMixin = (function() {
+	var guid = 0;
+	var ListenerMixin = {
+
+		listenerId: null,
+
+		/*
+		Given an `other` object that has on/off methods, bind the given `callback` to an event by the given name.
+		The `callback` will be called with the `this` context of the object that .listenTo is being called on.
+		Can be called:
+			.listenTo(other, eventName, callback)
+		OR
+			.listenTo(other, {
+				eventName1: callback1,
+				eventName2: callback2
+			})
+		*/
+		listenTo: function(other, arg, callback) {
+			if (typeof arg === 'object') { // given dictionary of callbacks
+				for (var eventName in arg) {
+					if (arg.hasOwnProperty(eventName)) {
+						this.listenTo(other, eventName, arg[eventName]);
+					}
+				}
+			}
+			else if (typeof arg === 'string') {
+				other.on(
+					arg + '.' + this.getListenerNamespace(), // use event namespacing to identify this object
+					$.proxy(callback, this) // always use `this` context
+						// the usually-undesired jQuery guid behavior doesn't matter,
+						// because we always unbind via namespace
+				);
+			}
+		},
+
+		/*
+		Causes the current object to stop listening to events on the `other` object.
+		`eventName` is optional. If omitted, will stop listening to ALL events on `other`.
+		*/
+		stopListeningTo: function(other, eventName) {
+			other.off((eventName || '') + '.' + this.getListenerNamespace());
+		},
+
+		/*
+		Returns a string, unique to this object, to be used for event namespacing
+		*/
+		getListenerNamespace: function() {
+			if (this.listenerId == null) {
+				this.listenerId = guid++;
+			}
+			return '_listener' + this.listenerId;
+		}
+
+	};
+	return ListenerMixin;
+})();

+ 24 - 15
src/common/MouseFollower.js

@@ -2,7 +2,7 @@
 /* Creates a clone of an element and lets it track the mouse as it moves
 ----------------------------------------------------------------------------------------------------------------------*/
 
-var MouseFollower = Class.extend({
+var MouseFollower = Class.extend(ListenerMixin, {
 
 	options: null,
 
@@ -14,16 +14,14 @@ var MouseFollower = Class.extend({
 	top0: null,
 	left0: null,
 
-	// the initial position of the mouse
-	mouseY0: null,
-	mouseX0: null,
+	// the absolute coordinates of the initiating touch/mouse action
+	y0: null,
+	x0: null,
 
 	// the number of pixels the mouse has moved from its initial position
 	topDelta: null,
 	leftDelta: null,
 
-	mousemoveProxy: null, // document mousemove handler, bound to the MouseFollower's `this`
-
 	isFollowing: false,
 	isHidden: false,
 	isAnimating: false, // doing the revert animation?
@@ -40,8 +38,8 @@ var MouseFollower = Class.extend({
 		if (!this.isFollowing) {
 			this.isFollowing = true;
 
-			this.mouseY0 = ev.pageY;
-			this.mouseX0 = ev.pageX;
+			this.y0 = getEvY(ev);
+			this.x0 = getEvX(ev);
 			this.topDelta = 0;
 			this.leftDelta = 0;
 
@@ -49,7 +47,12 @@ var MouseFollower = Class.extend({
 				this.updatePosition();
 			}
 
-			$(document).on('mousemove', this.mousemoveProxy = proxy(this, 'mousemove'));
+			if (getEvIsTouch(ev)) {
+				this.listenTo($(document), 'touchmove', this.handleMove);
+			}
+			else {
+				this.listenTo($(document), 'mousemove', this.handleMove);
+			}
 		}
 	},
 
@@ -74,7 +77,7 @@ var MouseFollower = Class.extend({
 		if (this.isFollowing && !this.isAnimating) { // disallow more than one stop animation at a time
 			this.isFollowing = false;
 
-			$(document).off('mousemove', this.mousemoveProxy);
+			this.stopListeningTo($(document));
 
 			if (shouldRevert && revertDuration && !this.isHidden) { // do a revert animation?
 				this.isAnimating = true;
@@ -100,6 +103,7 @@ var MouseFollower = Class.extend({
 		if (!el) {
 			this.sourceEl.width(); // hack to force IE8 to compute correct bounding box
 			el = this.el = this.sourceEl.clone()
+				.addClass(this.options.additionalClass || '')
 				.css({
 					position: 'absolute',
 					visibility: '', // in case original element was hidden (commonly through hideEvents())
@@ -111,8 +115,13 @@ var MouseFollower = Class.extend({
 					height: this.sourceEl.height(), // explicit width in case there was a 'bottom' value
 					opacity: this.options.opacity || '',
 					zIndex: this.options.zIndex
-				})
-				.appendTo(this.parentEl);
+				});
+
+			// we don't want long taps or any mouse interaction causing selection/menus.
+			// would use preventSelection(), but that prevents selectstart, causing problems.
+			el.addClass('fc-unselectable');
+
+			el.appendTo(this.parentEl);
 		}
 
 		return el;
@@ -152,9 +161,9 @@ var MouseFollower = Class.extend({
 
 
 	// Gets called when the user moves the mouse
-	mousemove: function(ev) {
-		this.topDelta = ev.pageY - this.mouseY0;
-		this.leftDelta = ev.pageX - this.mouseX0;
+	handleMove: function(ev) {
+		this.topDelta = getEvY(ev) - this.y0;
+		this.leftDelta = getEvX(ev) - this.x0;
 
 		if (!this.isHidden) {
 			this.updatePosition();

+ 3 - 4
src/common/Popover.js

@@ -13,12 +13,11 @@ Options:
 	- hide (callback)
 */
 
-var Popover = Class.extend({
+var Popover = Class.extend(ListenerMixin, {
 
 	isHidden: true,
 	options: null,
 	el: null, // the container element for the popover. generated by this object
-	documentMousedownProxy: null, // document mousedown handler bound to `this`
 	margin: 10, // the space required between the popover and the edges of the scroll container
 
 
@@ -72,7 +71,7 @@ var Popover = Class.extend({
 		});
 
 		if (options.autoHide) {
-			$(document).on('mousedown', this.documentMousedownProxy = proxy(this, 'documentMousedown'));
+			this.listenTo($(document), 'mousedown', this.documentMousedown);
 		}
 	},
 
@@ -95,7 +94,7 @@ var Popover = Class.extend({
 			this.el = null;
 		}
 
-		$(document).off('mousedown', this.documentMousedownProxy);
+		this.stopListeningTo($(document), 'mousedown');
 	},
 
 

+ 119 - 0
src/common/Scroller.js

@@ -0,0 +1,119 @@
+
+/*
+Embodies a div that has potential scrollbars
+*/
+var Scroller = FC.Scroller = Class.extend({
+
+	el: null, // the guaranteed outer element
+	scrollEl: null, // the element with the scrollbars
+	overflowX: null,
+	overflowY: null,
+
+
+	constructor: function(options) {
+		options = options || {};
+		this.overflowX = options.overflowX || options.overflow || 'auto';
+		this.overflowY = options.overflowY || options.overflow || 'auto';
+	},
+
+
+	render: function() {
+		this.el = this.renderEl();
+		this.applyOverflow();
+	},
+
+
+	renderEl: function() {
+		return (this.scrollEl = $('<div class="fc-scroller"></div>'));
+	},
+
+
+	// sets to natural height, unlocks overflow
+	clear: function() {
+		this.setHeight('auto');
+		this.applyOverflow();
+	},
+
+
+	destroy: function() {
+		this.el.remove();
+	},
+
+
+	// Overflow
+	// -----------------------------------------------------------------------------------------------------------------
+
+
+	applyOverflow: function() {
+		this.scrollEl.css({
+			'overflow-x': this.overflowX,
+			'overflow-y': this.overflowY
+		});
+	},
+
+
+	// Causes any 'auto' overflow values to resolves to 'scroll' or 'hidden'.
+	// Useful for preserving scrollbar widths regardless of future resizes.
+	// Can pass in scrollbarWidths for optimization.
+	lockOverflow: function(scrollbarWidths) {
+		var overflowX = this.overflowX;
+		var overflowY = this.overflowY;
+
+		scrollbarWidths = scrollbarWidths || this.getScrollbarWidths();
+
+		if (overflowX === 'auto') {
+			overflowX = (
+					scrollbarWidths.top || scrollbarWidths.bottom || // horizontal scrollbars?
+					// OR scrolling pane with massless scrollbars?
+					this.scrollEl[0].scrollWidth - 1 > this.scrollEl[0].clientWidth
+						// subtract 1 because of IE off-by-one issue
+				) ? 'scroll' : 'hidden';
+		}
+
+		if (overflowY === 'auto') {
+			overflowY = (
+					scrollbarWidths.left || scrollbarWidths.right || // vertical scrollbars?
+					// OR scrolling pane with massless scrollbars?
+					this.scrollEl[0].scrollHeight - 1 > this.scrollEl[0].clientHeight
+						// subtract 1 because of IE off-by-one issue
+				) ? 'scroll' : 'hidden';
+		}
+
+		this.scrollEl.css({ 'overflow-x': overflowX, 'overflow-y': overflowY });
+	},
+
+
+	// Getters / Setters
+	// -----------------------------------------------------------------------------------------------------------------
+
+
+	setHeight: function(height) {
+		this.scrollEl.height(height);
+	},
+
+
+	getScrollTop: function() {
+		return this.scrollEl.scrollTop();
+	},
+
+
+	setScrollTop: function(top) {
+		this.scrollEl.scrollTop(top);
+	},
+
+
+	getClientWidth: function() {
+		return this.scrollEl[0].clientWidth;
+	},
+
+
+	getClientHeight: function() {
+		return this.scrollEl[0].clientHeight;
+	},
+
+
+	getScrollbarWidths: function() {
+		return getScrollbarWidths(this.scrollEl);
+	}
+
+});

+ 4 - 0
src/common/TimeGrid.events.js

@@ -82,6 +82,7 @@ TimeGrid.mixin({
 
 
 	renderHelperSegs: function(segs, sourceSeg) {
+		var helperEls = [];
 		var i, seg;
 		var sourceEl;
 
@@ -99,9 +100,12 @@ TimeGrid.mixin({
 					'margin-right': sourceEl.css('margin-right')
 				});
 			}
+			helperEls.push(seg.el[0]);
 		}
 
 		this.helperSegs = segs;
+
+		return $(helperEls); // must return rendered helpers
 	},
 
 

+ 13 - 9
src/common/TimeGrid.js

@@ -14,6 +14,7 @@ var TimeGrid = FC.TimeGrid = Grid.extend(DayTableMixin, {
 	labelInterval: null, // duration of how often a label should be displayed for a slot
 
 	colEls: null, // cells elements in the day-row background
+	slatContainerEl: null, // div that wraps all the slat rows
 	slatEls: null, // elements running horizontally across all columns
 	nowIndicatorEls: null,
 
@@ -33,7 +34,8 @@ var TimeGrid = FC.TimeGrid = Grid.extend(DayTableMixin, {
 	renderDates: function() {
 		this.el.html(this.renderHtml());
 		this.colEls = this.el.find('.fc-day');
-		this.slatEls = this.el.find('.fc-slats tr');
+		this.slatContainerEl = this.el.find('.fc-slats');
+		this.slatEls = this.slatContainerEl.find('tr');
 
 		this.colCoordCache = new CoordCache({
 			els: this.colEls,
@@ -312,6 +314,11 @@ var TimeGrid = FC.TimeGrid = Grid.extend(DayTableMixin, {
 	},
 
 
+	getTotalSlatHeight: function() {
+		return this.slatContainerEl.outerHeight();
+	},
+
+
 	// Computes the top coordinate, relative to the bounds of the grid, of the given date.
 	// A `startOfDayDate` must be given for avoiding ambiguity over how to treat midnight.
 	computeDateTop: function(date, startOfDayDate) {
@@ -360,13 +367,10 @@ var TimeGrid = FC.TimeGrid = Grid.extend(DayTableMixin, {
 	renderDrag: function(eventLocation, seg) {
 
 		if (seg) { // if there is event information for this drag, render a helper event
-			this.renderEventLocationHelper(eventLocation, seg);
-
-			for (var i = 0; i < this.helperSegs.length; i++) {
-				this.applyDragOpacity(this.helperSegs[i].el);
-			}
 
-			return true; // signal that a helper has been rendered
+			// returns mock event elements
+			// signal that a helper has been rendered
+			return this.renderEventLocationHelper(eventLocation, seg);
 		}
 		else {
 			// otherwise, just render a highlight
@@ -388,7 +392,7 @@ var TimeGrid = FC.TimeGrid = Grid.extend(DayTableMixin, {
 
 	// Renders a visual indication of an event being resized
 	renderEventResize: function(eventLocation, seg) {
-		this.renderEventLocationHelper(eventLocation, seg);
+		return this.renderEventLocationHelper(eventLocation, seg); // returns mock event elements
 	},
 
 
@@ -404,7 +408,7 @@ var TimeGrid = FC.TimeGrid = Grid.extend(DayTableMixin, {
 
 	// Renders a mock "helper" event. `sourceSeg` is the original segment object and might be null (an external drag)
 	renderHelper: function(event, sourceSeg) {
-		this.renderHelperSegs(this.eventToSegs(event), sourceSeg);
+		return this.renderHelperSegs(this.eventToSegs(event), sourceSeg); // returns mock event elements
 	},
 
 

+ 81 - 46
src/common/View.js

@@ -2,7 +2,7 @@
 /* An abstract class from which other views inherit from
 ----------------------------------------------------------------------------------------------------------------------*/
 
-var View = FC.View = Class.extend({
+var View = FC.View = Class.extend(EmitterMixin, ListenerMixin, {
 
 	type: null, // subclass' view name (string)
 	name: null, // deprecated. use `type` instead
@@ -29,13 +29,10 @@ var View = FC.View = Class.extend({
 
 	isRTL: false,
 	isSelected: false, // boolean whether a range of time is user-selected or not
+	selectedEvent: null,
 
 	eventOrderSpecs: null, // criteria for ordering events when they have same date/time
 
-	// subclasses can optionally use a scroll container
-	scrollerEl: null, // the element that will most likely scroll when content is too tall
-	scrollTop: null, // cached vertical scroll value
-
 	// classNames styled by jqui themes
 	widgetHeaderClass: null,
 	widgetContentClass: null,
@@ -45,9 +42,6 @@ var View = FC.View = Class.extend({
 	nextDayThreshold: null,
 	isHiddenDayHash: null,
 
-	// document handlers, bound to `this` object
-	documentMousedownProxy: null, // TODO: doesn't work with touch
-
 	// now indicator
 	isNowIndicatorRendered: null,
 	initialNowDate: null, // result first getNow call
@@ -70,8 +64,6 @@ var View = FC.View = Class.extend({
 
 		this.eventOrderSpecs = parseFieldSpecs(this.opt('eventOrder'));
 
-		this.documentMousedownProxy = proxy(this, 'documentMousedown');
-
 		this.initialize();
 	},
 
@@ -397,13 +389,15 @@ var View = FC.View = Class.extend({
 
 	// Binds DOM handlers to elements that reside outside the view container, such as the document
 	bindGlobalHandlers: function() {
-		$(document).on('mousedown', this.documentMousedownProxy);
+		this.listenTo($(document), 'mousedown', this.handleDocumentMousedown);
+		this.listenTo($(document), 'touchstart', this.handleDocumentTouchStart);
+		this.listenTo($(document), 'touchend', this.handleDocumentTouchEnd);
 	},
 
 
 	// Unbinds DOM handlers from elements that reside outside the view container
 	unbindGlobalHandlers: function() {
-		$(document).off('mousedown', this.documentMousedownProxy);
+		this.stopListeningTo($(document));
 	},
 
 
@@ -571,27 +565,6 @@ var View = FC.View = Class.extend({
 	------------------------------------------------------------------------------------------------------------------*/
 
 
-	// Given the total height of the view, return the number of pixels that should be used for the scroller.
-	// Utility for subclasses.
-	computeScrollerHeight: function(totalHeight) {
-		var scrollerEl = this.scrollerEl;
-		var both;
-		var otherHeight; // cumulative height of everything that is not the scrollerEl in the view (header+borders)
-
-		both = this.el.add(scrollerEl);
-
-		// fuckin IE8/9/10/11 sometimes returns 0 for dimensions. this weird hack was the only thing that worked
-		both.css({
-			position: 'relative', // cause a reflow, which will force fresh dimension recalculation
-			left: -1 // ensure reflow in case the el was already relative. negative is less likely to cause new scroll
-		});
-		otherHeight = this.el.outerHeight() - scrollerEl.height(); // grab the dimensions
-		both.css({ position: '', left: '' }); // undo hack
-
-		return totalHeight - otherHeight;
-	},
-
-
 	// Computes the initial pre-configured scroll state prior to allowing the user to change it.
 	// Given the scroll state from the previous rendering. If first time rendering, given null.
 	computeInitialScroll: function(previousScrollState) {
@@ -601,17 +574,13 @@ var View = FC.View = Class.extend({
 
 	// Retrieves the view's current natural scroll state. Can return an arbitrary format.
 	queryScroll: function() {
-		if (this.scrollerEl) {
-			return this.scrollerEl.scrollTop(); // operates on scrollerEl by default
-		}
+		// subclasses must implement
 	},
 
 
 	// Sets the view's scroll state. Will accept the same format computeInitialScroll and queryScroll produce.
 	setScroll: function(scrollState) {
-		if (this.scrollerEl) {
-			return this.scrollerEl.scrollTop(scrollState); // operates on scrollerEl by default
-		}
+		// subclasses must implement
 	},
 
 
@@ -826,7 +795,8 @@ var View = FC.View = Class.extend({
 
 
 	// Renders a visual indication of a event or external-element drag over the given drop zone.
-	// If an external-element, seg will be `null`
+	// If an external-element, seg will be `null`.
+	// Must return elements used for any mock events.
 	renderDrag: function(dropLocation, seg) {
 		// subclasses must implement
 	},
@@ -889,7 +859,7 @@ var View = FC.View = Class.extend({
 	},
 
 
-	/* Selection
+	/* Selection (time range)
 	------------------------------------------------------------------------------------------------------------------*/
 
 
@@ -947,13 +917,69 @@ var View = FC.View = Class.extend({
 	},
 
 
-	// Handler for unselecting when the user clicks something and the 'unselectAuto' setting is on
-	documentMousedown: function(ev) {
-		var ignore;
+	/* Event Selection
+	------------------------------------------------------------------------------------------------------------------*/
+
+
+	selectEvent: function(event) {
+		if (!this.selectedEvent || this.selectedEvent !== event) {
+			this.unselectEvent();
+			this.renderedEventSegEach(function(seg) {
+				seg.el.addClass('fc-selected');
+			}, event);
+			this.selectedEvent = event;
+		}
+	},
+
+
+	unselectEvent: function() {
+		if (this.selectedEvent) {
+			this.renderedEventSegEach(function(seg) {
+				seg.el.removeClass('fc-selected');
+			}, this.selectedEvent);
+			this.selectedEvent = null;
+		}
+	},
 
-		// is there a selection, and has the user made a proper left click?
-		if (this.isSelected && this.opt('unselectAuto') && isPrimaryMouseButton(ev)) {
 
+	isEventSelected: function(event) {
+		// event references might change on refetchEvents(), while selectedEvent doesn't,
+		// so compare IDs
+		return this.selectedEvent && this.selectedEvent._id === event._id;
+	},
+
+
+	/* Mouse / Touch Unselecting (time range & event unselection)
+	------------------------------------------------------------------------------------------------------------------*/
+	// TODO: move consistently to down/start or up/end?
+
+
+	handleDocumentMousedown: function(ev) {
+		// touch devices fire simulated mouse events on a "click".
+		// only process mousedown if we know this isn't a touch device.
+		if (!this.calendar.isTouch && isPrimaryMouseButton(ev)) {
+			this.processRangeUnselect(ev);
+			this.processEventUnselect(ev);
+		}
+	},
+
+
+	handleDocumentTouchStart: function(ev) {
+		this.processRangeUnselect(ev);
+	},
+
+
+	handleDocumentTouchEnd: function(ev) {
+		// TODO: don't do this if because of touch-scrolling
+		this.processEventUnselect(ev);
+	},
+
+
+	processRangeUnselect: function(ev) {
+		var ignore;
+
+		// is there a time-range selection?
+		if (this.isSelected && this.opt('unselectAuto')) {
 			// only unselect if the clicked element is not identical to or inside of an 'unselectCancel' element
 			ignore = this.opt('unselectCancel');
 			if (!ignore || !$(ev.target).closest(ignore).length) {
@@ -963,6 +989,15 @@ var View = FC.View = Class.extend({
 	},
 
 
+	processEventUnselect: function(ev) {
+		if (this.selectedEvent) {
+			if (!$(ev.target).closest('.fc-selected').length) {
+				this.unselectEvent();
+			}
+		}
+	},
+
+
 	/* Day Click
 	------------------------------------------------------------------------------------------------------------------*/
 

+ 147 - 31
src/common/common.css

@@ -28,6 +28,7 @@ body .fc { /* extra precedence to overcome jqui */
 .fc-unthemed tbody,
 .fc-unthemed .fc-divider,
 .fc-unthemed .fc-row,
+.fc-unthemed .fc-content, /* for gutter border */
 .fc-unthemed .fc-popover {
 	border-color: #ddd;
 }
@@ -491,15 +492,15 @@ temporary rendered events).
 /* Scrolling Container
 --------------------------------------------------------------------------------------------------*/
 
-.fc-scroller { /* this class goes on elements for guaranteed vertical scrollbars */
-	overflow-y: scroll;
-	overflow-x: hidden;
+.fc-scroller {
+	-webkit-overflow-scrolling: touch;
 }
 
-.fc-scroller > * { /* we expect an immediate inner element */
+/* TODO: move to agenda/basic */
+.fc-scroller > .fc-day-grid,
+.fc-scroller > .fc-time-grid {
 	position: relative; /* re-scope all positions */
 	width: 100%; /* hack to force re-sizing this inner element when scrollbars appear/disappear */
-	overflow: hidden; /* don't let negative margins or absolute positioning create further scroll */
 }
 
 
@@ -547,15 +548,73 @@ temporary rendered events).
 	z-index: 2;
 }
 
+/* resizer (cursor AND touch devices) */
+
 .fc-event .fc-resizer {
 	position: absolute;
-	z-index: 3;
+	z-index: 4;
+}
+
+/* resizer (touch devices) */
+
+.fc-touch .fc-event .fc-resizer {
+	display: none; /* only show when selected */
+}
+
+.fc-touch .fc-event.fc-selected .fc-resizer {
+	display: block;
+}
+
+
+/* Hit Area (for events and expander)
+--------------------------------------------------------------------------------------------------*/
+
+.fc-expander { /* fc-event is already position:relative */
+	position: relative;
+}
+
+.fc-touch .fc-expander:before,
+.fc-touch .fc-event .fc-resizer:before {
+	/* 40x40 touch area */
+	content: "";
+	position: absolute;
+	z-index: 9999; /* user of this util can scope within a lower z-index */
+	top: 50%;
+	left: 50%;
+	width: 40px;
+	height: 40px;
+	margin-left: -20px;
+	margin-top: -20px;
+}
+
+
+/* Event Selection (only for touch devices)
+--------------------------------------------------------------------------------------------------*/
+
+.fc-event.fc-selected {
+	z-index: 9999 !important; /* overcomes inline z-index */
+	box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
+}
+
+.fc-event.fc-selected.fc-dragging {
+	box-shadow: 0 2px 7px rgba(0, 0, 0, 0.3);
 }
 
 
 /* Horizontal Events
 --------------------------------------------------------------------------------------------------*/
 
+/* bigger touch area when selected */
+.fc-h-event.fc-selected:before {
+	content: "";
+	position: absolute;
+	z-index: 3; /* below resizers */
+	top: -10px;
+	bottom: -10px;
+	left: 0;
+	right: 0;
+}
+
 /* events that are continuing to/from another week. kill rounded corners and butt up against edge */
 
 .fc-ltr .fc-h-event.fc-not-start,
@@ -576,36 +635,56 @@ temporary rendered events).
 	border-bottom-right-radius: 0;
 }
 
-/* resizer */
-
-.fc-h-event .fc-resizer { /* positioned it to overcome the event's borders */
-	top: -1px;
-	bottom: -1px;
-	left: -1px;
-	right: -1px;
-	width: 5px;
-}
+/* resizer (cursor AND touch devices) */
 
 /* left resizer  */
 .fc-ltr .fc-h-event .fc-start-resizer,
-.fc-ltr .fc-h-event .fc-start-resizer:before,
-.fc-ltr .fc-h-event .fc-start-resizer:after,
-.fc-rtl .fc-h-event .fc-end-resizer,
-.fc-rtl .fc-h-event .fc-end-resizer:before,
-.fc-rtl .fc-h-event .fc-end-resizer:after {
-	right: auto; /* ignore the right and only use the left */
+.fc-rtl .fc-h-event .fc-end-resizer {
 	cursor: w-resize;
+	left: -1px; /* overcome border */
 }
 
 /* right resizer */
 .fc-ltr .fc-h-event .fc-end-resizer,
-.fc-ltr .fc-h-event .fc-end-resizer:before,
-.fc-ltr .fc-h-event .fc-end-resizer:after,
-.fc-rtl .fc-h-event .fc-start-resizer,
-.fc-rtl .fc-h-event .fc-start-resizer:before,
-.fc-rtl .fc-h-event .fc-start-resizer:after {
-	left: auto; /* ignore the left and only use the right */
+.fc-rtl .fc-h-event .fc-start-resizer {
 	cursor: e-resize;
+	right: -1px; /* overcome border */
+}
+
+/* resizer (cursor devices) */
+
+.fc-cursor .fc-h-event .fc-resizer {
+	width: 7px;
+	top: -1px; /* overcome top border */
+	bottom: -1px; /* overcome bottom border */
+}
+
+/* resizer (touch devices) */
+
+.fc-touch .fc-h-event .fc-resizer {
+	/* 8x8 little dot */
+	border-radius: 4px;
+	border-width: 1px;
+	width: 6px;
+	height: 6px;
+	border-style: solid;
+	border-color: inherit;
+	background: #fff;
+	/* vertically center */
+	top: 50%;
+	margin-top: -4px;
+}
+
+/* left resizer  */
+.fc-touch.fc-ltr .fc-h-event .fc-start-resizer,
+.fc-touch.fc-rtl .fc-h-event .fc-end-resizer {
+	margin-left: -4px; /* centers the 8x8 dot on the left edge */
+}
+
+/* right resizer */
+.fc-touch.fc-ltr .fc-h-event .fc-end-resizer,
+.fc-touch.fc-rtl .fc-h-event .fc-start-resizer {
+	margin-right: -4px; /* centers the 8x8 dot on the right edge */
 }
 
 
@@ -624,6 +703,21 @@ tr:first-child > td > .fc-day-grid-event {
 	margin-top: 2px; /* a little bit more space before the first event */
 }
 
+.fc-day-grid-event.fc-selected:after {
+	content: "";
+	position: absolute;
+	z-index: 1; /* same z-index as fc-bg, behind text */
+	/* overcome the borders */
+	top: -1px;
+	right: -1px;
+	bottom: -1px;
+	left: -1px;
+	/* darkening effect */
+	background: #000;
+	opacity: .25;
+	filter: alpha(opacity=25); /* for IE */
+}
+
 .fc-day-grid-event .fc-content { /* force events to be one-line tall */
 	white-space: nowrap;
 	overflow: hidden;
@@ -633,10 +727,18 @@ tr:first-child > td > .fc-day-grid-event {
 	font-weight: bold;
 }
 
-.fc-day-grid-event .fc-resizer { /* enlarge the default hit area */
-	left: -3px;
-	right: -3px;
-	width: 7px;
+/* resizer (cursor devices) */
+
+/* left resizer  */
+.fc-cursor.fc-ltr .fc-day-grid-event .fc-start-resizer,
+.fc-cursor.fc-rtl .fc-day-grid-event .fc-end-resizer {
+	margin-left: -2px; /* to the day cell's edge */
+}
+
+/* right resizer */
+.fc-cursor.fc-ltr .fc-day-grid-event .fc-end-resizer,
+.fc-cursor.fc-rtl .fc-day-grid-event .fc-start-resizer {
+	margin-right: -2px; /* to the day cell's edge */
 }
 
 
@@ -683,3 +785,17 @@ a.fc-more:hover {
 	position: absolute;
 	border: 0 solid red;
 }
+
+
+/* Utilities
+--------------------------------------------------------------------------------------------------*/
+
+.fc-unselectable {
+	-webkit-user-select: none;
+	 -khtml-user-select: none;
+	   -moz-user-select: none;
+	    -ms-user-select: none;
+	        user-select: none;
+	-webkit-touch-callout: none;
+	-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
+}

+ 3 - 1
src/defaults.js

@@ -87,7 +87,9 @@ Calendar.defaults = {
 	dayPopoverFormat: 'LL',
 	
 	handleWindowResize: true,
-	windowResizeDelay: 200 // milliseconds before an updateSize happens
+	windowResizeDelay: 200, // milliseconds before an updateSize happens
+
+	longPressDelay: 1000
 	
 };
 

+ 3 - 0
src/main.js

@@ -6,6 +6,9 @@ var FC = $.fullCalendar = {
 var fcViews = FC.views = {};
 
 
+FC.isTouch = 'ontouchstart' in document;
+
+
 $.fn.fullCalendar = function(options) {
 	var args = Array.prototype.slice.call(arguments, 1); // for a possible method call
 	var res = this; // what this function will return (this jQuery object by default)

+ 97 - 44
src/util.js

@@ -138,29 +138,25 @@ function matchCellWidths(els) {
 }
 
 
-// Turns a container element into a scroller if its contents is taller than the allotted height.
-// Returns true if the element is now a scroller, false otherwise.
-// NOTE: this method is best because it takes weird zooming dimensions into account
-function setPotentialScroller(containerEl, height) {
-	containerEl.height(height).addClass('fc-scroller');
-
-	// are scrollbars needed?
-	if (containerEl[0].scrollHeight - 1 > containerEl[0].clientHeight) { // !!! -1 because IE is often off-by-one :(
-		return true;
-	}
-
-	unsetScroller(containerEl); // undo
-	return false;
-}
-
+// Given one element that resides inside another,
+// Subtracts the height of the inner element from the outer element.
+function subtractInnerElHeight(outerEl, innerEl) {
+	var both = outerEl.add(innerEl);
+	var diff;
+
+	// fuckin IE8/9/10/11 sometimes returns 0 for dimensions. this weird hack was the only thing that worked
+	both.css({
+		position: 'relative', // cause a reflow, which will force fresh dimension recalculation
+		left: -1 // ensure reflow in case the el was already relative. negative is less likely to cause new scroll
+	});
+	diff = outerEl.outerHeight() - innerEl.outerHeight(); // grab the dimensions
+	both.css({ position: '', left: '' }); // undo hack
 
-// Takes an element that might have been a scroller, and turns it back into a normal element.
-function unsetScroller(containerEl) {
-	containerEl.height('').removeClass('fc-scroller');
+	return diff;
 }
 
 
-/* General DOM Utilities
+/* Element Geom Utilities
 ----------------------------------------------------------------------------------------------------------------------*/
 
 FC.getOuterRect = getOuterRect;
@@ -185,26 +181,30 @@ function getScrollParent(el) {
 
 // Queries the outer bounding area of a jQuery element.
 // Returns a rectangle with absolute coordinates: left, right (exclusive), top, bottom (exclusive).
-function getOuterRect(el) {
+// Origin is optional.
+function getOuterRect(el, origin) {
 	var offset = el.offset();
+	var left = offset.left - (origin ? origin.left : 0);
+	var top = offset.top - (origin ? origin.top : 0);
 
 	return {
-		left: offset.left,
-		right: offset.left + el.outerWidth(),
-		top: offset.top,
-		bottom: offset.top + el.outerHeight()
+		left: left,
+		right: left + el.outerWidth(),
+		top: top,
+		bottom: top + el.outerHeight()
 	};
 }
 
 
 // Queries the area within the margin/border/scrollbars of a jQuery element. Does not go within the padding.
 // Returns a rectangle with absolute coordinates: left, right (exclusive), top, bottom (exclusive).
+// Origin is optional.
 // NOTE: should use clientLeft/clientTop, but very unreliable cross-browser.
-function getClientRect(el) {
+function getClientRect(el, origin) {
 	var offset = el.offset();
 	var scrollbarWidths = getScrollbarWidths(el);
-	var left = offset.left + getCssFloat(el, 'border-left-width') + scrollbarWidths.left;
-	var top = offset.top + getCssFloat(el, 'border-top-width') + scrollbarWidths.top;
+	var left = offset.left + getCssFloat(el, 'border-left-width') + scrollbarWidths.left - (origin ? origin.left : 0);
+	var top = offset.top + getCssFloat(el, 'border-top-width') + scrollbarWidths.top - (origin ? origin.top : 0);
 
 	return {
 		left: left,
@@ -217,10 +217,13 @@ function getClientRect(el) {
 
 // Queries the area within the margin/border/padding of a jQuery element. Assumed not to have scrollbars.
 // Returns a rectangle with absolute coordinates: left, right (exclusive), top, bottom (exclusive).
-function getContentRect(el) {
+// Origin is optional.
+function getContentRect(el, origin) {
 	var offset = el.offset(); // just outside of border, margin not included
-	var left = offset.left + getCssFloat(el, 'border-left-width') + getCssFloat(el, 'padding-left');
-	var top = offset.top + getCssFloat(el, 'border-top-width') + getCssFloat(el, 'padding-top');
+	var left = offset.left + getCssFloat(el, 'border-left-width') + getCssFloat(el, 'padding-left') -
+		(origin ? origin.left : 0);
+	var top = offset.top + getCssFloat(el, 'border-top-width') + getCssFloat(el, 'padding-top') -
+		(origin ? origin.top : 0);
 
 	return {
 		left: left,
@@ -290,13 +293,58 @@ function getCssFloat(el, prop) {
 }
 
 
+/* Mouse / Touch Utilities
+----------------------------------------------------------------------------------------------------------------------*/
+
+FC.preventDefault = preventDefault;
+
+
 // Returns a boolean whether this was a left mouse click and no ctrl key (which means right click on Mac)
 function isPrimaryMouseButton(ev) {
 	return ev.which == 1 && !ev.ctrlKey;
 }
 
 
-/* Geometry
+function getEvX(ev) {
+	if (ev.pageX !== undefined) {
+		return ev.pageX;
+	}
+	var touches = ev.originalEvent.touches;
+	if (touches) {
+		return touches[0].pageX;
+	}
+}
+
+
+function getEvY(ev) {
+	if (ev.pageY !== undefined) {
+		return ev.pageY;
+	}
+	var touches = ev.originalEvent.touches;
+	if (touches) {
+		return touches[0].pageY;
+	}
+}
+
+
+function getEvIsTouch(ev) {
+	return /^touch/.test(ev.type);
+}
+
+
+function preventSelection(el) {
+	el.addClass('fc-unselectable')
+		.on('selectstart', preventDefault);
+}
+
+
+// Stops a mouse/touch event from doing it's native browser action
+function preventDefault(ev) {
+	ev.preventDefault();
+}
+
+
+/* General Geometry Utils
 ----------------------------------------------------------------------------------------------------------------------*/
 
 FC.intersectRects = intersectRects;
@@ -822,22 +870,21 @@ function proxy(obj, methodName) {
 
 // Returns a function, that, as long as it continues to be invoked, will not
 // be triggered. The function will be called after it stops being called for
-// N milliseconds.
+// N milliseconds. If `immediate` is passed, trigger the function on the
+// leading edge, instead of the trailing.
 // https://github.com/jashkenas/underscore/blob/1.6.0/underscore.js#L714
-function debounce(func, wait) {
-	var timeoutId;
-	var args;
-	var context;
-	var timestamp; // of most recent call
+function debounce(func, wait, immediate) {
+	var timeout, args, context, timestamp, result;
+
 	var later = function() {
 		var last = +new Date() - timestamp;
-		if (last < wait && last > 0) {
-			timeoutId = setTimeout(later, wait - last);
+		if (last < wait) {
+			timeout = setTimeout(later, wait - last);
 		}
 		else {
-			timeoutId = null;
-			func.apply(context, args);
-			if (!timeoutId) {
+			timeout = null;
+			if (!immediate) {
+				result = func.apply(context, args);
 				context = args = null;
 			}
 		}
@@ -847,8 +894,14 @@ function debounce(func, wait) {
 		context = this;
 		args = arguments;
 		timestamp = +new Date();
-		if (!timeoutId) {
-			timeoutId = setTimeout(later, wait);
+		var callNow = immediate && !timeout;
+		if (!timeout) {
+			timeout = setTimeout(later, wait);
+		}
+		if (callNow) {
+			result = func.apply(context, args);
+			context = args = null;
 		}
+		return result;
 	};
 }

+ 89 - 62
tests/automated/event-dnd.js

@@ -19,37 +19,49 @@ describe('eventDrop', function() {
 			options.defaultView = 'month';
 		});
 
-		describe('when dragging an all-day event to another day', function() {
-			it('should be given correct arguments, with whole-day delta', function(done) {
-				options.events = [ {
-					title: 'all-day event',
-					start: '2014-06-11',
-					allDay: true
-				} ];
-
-				init(
-					function() {
-						$('.fc-event').simulate('drag', {
-							dx: $('.fc-day').width() * 2,
-							dy: $('.fc-day').height()
-						});
-					},
-					function(event, delta, revertFunc) {
-						expect(delta.asDays()).toBe(9);
-						expect(delta.hours()).toBe(0);
-						expect(delta.minutes()).toBe(0);
-						expect(delta.seconds()).toBe(0);
-						expect(delta.milliseconds()).toBe(0);
-
-						expect(event.start).toEqualMoment('2014-06-20');
-						expect(event.end).toBeNull();
-						revertFunc();
-						expect(event.start).toEqualMoment('2014-06-11');
-						expect(event.end).toBeNull();
-
-						done();
-					}
-				);
+		[ false, true ].forEach(function(isTouch) {
+			describe('with ' + (isTouch ? 'touch' : 'mouse'), function() {
+				beforeEach(function() {
+					options.isTouch = isTouch;
+					options.longPressDelay = isTouch ? 100 : 0;
+				});
+
+				describe('when dragging an all-day event to another day', function() {
+					it('should be given correct arguments, with whole-day delta', function(done) {
+
+						options.events = [ {
+							title: 'all-day event',
+							start: '2014-06-11',
+							allDay: true
+						} ];
+
+						init(
+							function() {
+								$('.fc-event').simulate('drag', {
+									dx: $('.fc-day').width() * 2,
+									dy: $('.fc-day').height(),
+									isTouch: isTouch,
+									delay: isTouch ? 200 : 0
+								});
+							},
+							function(event, delta, revertFunc) {
+								expect(delta.asDays()).toBe(9);
+								expect(delta.hours()).toBe(0);
+								expect(delta.minutes()).toBe(0);
+								expect(delta.seconds()).toBe(0);
+								expect(delta.milliseconds()).toBe(0);
+
+								expect(event.start).toEqualMoment('2014-06-20');
+								expect(event.end).toBeNull();
+								revertFunc();
+								expect(event.start).toEqualMoment('2014-06-11');
+								expect(event.end).toBeNull();
+
+								done();
+							}
+						);
+					});
+				});
 			});
 		});
 
@@ -138,37 +150,52 @@ describe('eventDrop', function() {
 			options.defaultView = 'agendaWeek';
 		});
 
-		describe('when dragging a timed event to another time on a different day', function() {
-			it('should be given correct arguments and delta with days/time', function(done) {
-				options.events = [ {
-					title: 'timed event',
-					start: '2014-06-11T06:00:00',
-					allDay: false
-				} ];
-
-				init(
-					function() {
-						$('.fc-event .fc-time').simulate('drag', {
-							dx: $('th.fc-wed').width(), // 1 day
-							dy: $('.fc-slats tr:eq(1)').outerHeight() * 2.9 // 1.5 hours
-						});
-					},
-					function(event, delta, revertFunc) {
-						expect(delta.days()).toBe(1);
-						expect(delta.hours()).toBe(1);
-						expect(delta.minutes()).toBe(30);
-						expect(delta.seconds()).toBe(0);
-						expect(delta.milliseconds()).toBe(0);
-
-						expect(event.start).toEqualMoment('2014-06-12T07:30:00');
-						expect(event.end).toBeNull();
-						revertFunc();
-						expect(event.start).toEqualMoment('2014-06-11T06:00:00');
-						expect(event.end).toBeNull();
-
-						done();
-					}
-				);
+		[ false, true ].forEach(function(isTouch) {
+			describe('with ' + (isTouch ? 'touch' : 'mouse'), function() {
+				beforeEach(function() {
+					options.isTouch = isTouch;
+					options.longPressDelay = isTouch ? 100 : 0;
+				});
+
+				describe('when dragging a timed event to another time on a different day', function() {
+					it('should be given correct arguments and delta with days/time', function(done) {
+						options.events = [ {
+							title: 'timed event',
+							start: '2014-06-11T06:00:00',
+							allDay: false
+						} ];
+
+						init(
+							function() {
+								// setTimeout waits for full render, so there's no scroll,
+								// because scroll kills touch drag
+								setTimeout(function() {
+									$('.fc-event .fc-time').simulate('drag', {
+										dx: $('th.fc-wed').width(), // 1 day
+										dy: $('.fc-slats tr:eq(1)').outerHeight() * 2.9, // 1.5 hours
+										isTouch: isTouch,
+										delay: isTouch ? 200 : 0
+									});
+								}, 0);
+							},
+							function(event, delta, revertFunc) {
+								expect(delta.days()).toBe(1);
+								expect(delta.hours()).toBe(1);
+								expect(delta.minutes()).toBe(30);
+								expect(delta.seconds()).toBe(0);
+								expect(delta.milliseconds()).toBe(0);
+
+								expect(event.start).toEqualMoment('2014-06-12T07:30:00');
+								expect(event.end).toBeNull();
+								revertFunc();
+								expect(event.start).toEqualMoment('2014-06-11T06:00:00');
+								expect(event.end).toBeNull();
+
+								done();
+							}
+						);
+					});
+				});
 			});
 		});
 

+ 93 - 1
tests/automated/event-resize.js

@@ -18,7 +18,7 @@ describe('eventResize', function() {
 			options.defaultView = 'month';
 		});
 
-		describe('when resizing an all-day event', function() {
+		describe('when resizing an all-day event with mouse', function() {
 			it('should have correct arguments with a whole-day delta', function(done) {
 				options.events = [ {
 					title: 'all-day event',
@@ -52,6 +52,60 @@ describe('eventResize', function() {
 			});
 		});
 
+		describe('when resizing an all-day event via touch', function() {
+
+			// for https://github.com/fullcalendar/fullcalendar/issues/3118
+			[ true, false ].forEach(function(eventStartEditable) {
+				describe('when eventStartEditable is ' + eventStartEditable, function() {
+					beforeEach(function() {
+						options.eventStartEditable = eventStartEditable;
+					});
+
+					it('should have correct arguments with a whole-day delta', function(done) {
+						options.isTouch = true;
+						options.longPressDelay = 100;
+						options.dragRevertDuration = 0; // so that eventDragStop happens immediately after touchend
+						options.events = [ {
+							title: 'all-day event',
+							start: '2014-06-11',
+							allDay: true
+						} ];
+
+						init(
+							function() {
+								$('.fc-event').simulate('drag', {
+									isTouch: true,
+									delay: 200,
+									onRelease: function() {
+										$('.fc-event .fc-resizer').simulate('drag', {
+											dx: $('.fc-day').width() * -2.5, // guarantee 2 days to left
+											dy: $('.fc-day').height(),
+											isTouch: true
+										});
+									}
+								});
+							},
+							function(event, delta, revertFunc) {
+								expect(delta.asDays()).toBe(5);
+								expect(delta.hours()).toBe(0);
+								expect(delta.minutes()).toBe(0);
+								expect(delta.seconds()).toBe(0);
+								expect(delta.milliseconds()).toBe(0);
+
+								expect(event.start).toEqualMoment('2014-06-11');
+								expect(event.end).toEqualMoment('2014-06-17');
+								revertFunc();
+								expect(event.start).toEqualMoment('2014-06-11');
+								expect(event.end).toBeNull();
+
+								done();
+							}
+						);
+					});
+				});
+			});
+		});
+
 		describe('when rendering a timed event', function() {
 			it('should not have resize capabilities', function(done) {
 				options.events = [ {
@@ -141,6 +195,44 @@ describe('eventResize', function() {
 				);
 			});
 
+			it('should have correct arguments with a timed delta via touch', function(done) {
+				options.isTouch = true;
+				options.longPressDelay = 100;
+				options.dragRevertDuration = 0; // so that eventDragStop happens immediately after touchend
+
+				init(
+					function() {
+						setTimeout(function() { // wait for scroll to init, so don't do a rescroll which kills drag
+							$('.fc-event').simulate('drag', {
+								isTouch: true,
+								delay: 200,
+								onRelease: function() {
+									$('.fc-event .fc-resizer').simulate('drag', {
+										dy: $('.fc-slats tr:eq(1)').height() * 4.5, // 5 slots, so 2.5 hours
+										isTouch: true
+									});
+								}
+							});
+						}, 0);
+					},
+					function(event, delta, revertFunc) {
+						expect(delta.days()).toBe(0);
+						expect(delta.hours()).toBe(2);
+						expect(delta.minutes()).toBe(30);
+						expect(delta.seconds()).toBe(0);
+						expect(delta.milliseconds()).toBe(0);
+
+						expect(event.start).toEqualMoment('2014-06-11T05:00:00');
+						expect(event.end).toEqualMoment('2014-06-11T09:30:00');
+						revertFunc();
+						expect(event.start).toEqualMoment('2014-06-11T05:00:00');
+						expect(event.end).toEqualMoment('2014-06-11T07:00:00');
+
+						done();
+					}
+				);
+			});
+
 			// TODO: test RTL
 			it('should have correct arguments with a timed delta when resized to a different day', function(done) {
 				init(

+ 39 - 0
tests/automated/eventClick.js

@@ -0,0 +1,39 @@
+
+describe('eventClick', function() {
+	var options;
+
+	beforeEach(function() {
+		affix('#cal');
+		options = {
+			defaultDate: '2014-08-01'
+		};
+	});
+
+	it('works in month view', function(done) {
+		options.events = [
+			{ start: '2014-08-01', title: 'event1', className: 'event1' }
+		];
+		options.eventAfterAllRender = function() {
+			$('.event1').simulate('click');
+		};
+		options.eventClick = function() {
+			done();
+		};
+		$('#cal').fullCalendar(options);
+	});
+
+	it('works in month view via touch', function(done) {
+		options.isTouch = true;
+		options.events = [
+			{ start: '2014-08-01', title: 'event1', className: 'event1' }
+		];
+		options.eventAfterAllRender = function() {
+			$.simulateTouchClick($('.event1'));
+		};
+		options.eventClick = function() {
+			done();
+		};
+		$('#cal').fullCalendar(options);
+	});
+
+});

+ 52 - 0
tests/automated/select-callback.js

@@ -44,6 +44,31 @@ describe('select callback', function() {
 						}
 					});
 				});
+				it('gets fired correctly when the user selects cells via touch', function(done) {
+					options.isTouch = true;
+					options.longPressDelay = 100;
+					options.select = function(start, end, jsEvent, view) {
+						expect(moment.isMoment(start)).toEqual(true);
+						expect(moment.isMoment(end)).toEqual(true);
+						expect(typeof jsEvent).toEqual('object'); // TODO: more descrimination
+						expect(typeof view).toEqual('object'); // "
+						expect(start.hasTime()).toEqual(false);
+						expect(end.hasTime()).toEqual(false);
+						expect(start).toEqualMoment('2014-04-28');
+						expect(end).toEqualMoment('2014-05-07');
+					};
+					spyOn(options, 'select').and.callThrough();
+					$('#cal').fullCalendar(options);
+					$('.fc-day[data-date="2014-04-28"]').simulate('drag', {
+						isTouch: true,
+						delay: 200,
+						end: '.fc-day[data-date="2014-05-06"]',
+						callback: function() {
+							expect(options.select).toHaveBeenCalled();
+							done();
+						}
+					});
+				});
 				it('gets fired correctly when the user selects just one cell', function(done) {
 					options.select = function(start, end, jsEvent, view) {
 						expect(moment.isMoment(start)).toEqual(true);
@@ -136,6 +161,33 @@ describe('select callback', function() {
 							}
 						});
 					});
+					it('gets fired correctly when the user selects slots via touch', function(done) {
+						options.isTouch = true;
+						options.longPressDelay = 1000;
+						options.select = function(start, end, jsEvent, view) {
+							expect(moment.isMoment(start)).toEqual(true);
+							expect(moment.isMoment(end)).toEqual(true);
+							expect(typeof jsEvent).toEqual('object'); // TODO: more descrimination
+							expect(typeof view).toEqual('object'); // "
+							expect(start.hasTime()).toEqual(true);
+							expect(end.hasTime()).toEqual(true);
+							expect(start).toEqualMoment('2014-05-28T09:00:00');
+							expect(end).toEqualMoment('2014-05-28T10:30:00');
+						};
+						spyOn(options, 'select').and.callThrough();
+						$('#cal').fullCalendar(options);
+						setTimeout(function() { // prevent scroll from being triggered, killing the select interaction
+							$('.fc-slats tr:eq(18) td:not(.fc-time)').simulate('drag', { // middle will be 2014-05-28T09:00:00
+								isTouch: true,
+								delay: 1500,
+								dy: $('.fc-slats tr:eq(18)').outerHeight() * 2, // move down two slots
+								callback: function() {
+									expect(options.select).toHaveBeenCalled();
+									done();
+								}
+							});
+						}, 0);
+					});
 					it('gets fired correctly when the user selects slots in a different day', function(done) {
 						options.select = function(start, end, jsEvent, view) {
 							expect(moment.isMoment(start)).toEqual(true);

+ 6 - 0
tests/lib/jasmine-ext.js

@@ -7,6 +7,12 @@ beforeEach(function() {
 	// (not the best place for this)
 	moment.suppressDeprecationWarnings = true;
 
+	// phantom JS falsely reports touch abilities, so explicitly disable.
+	// tests can override this on a per-calendar basis.
+	// (not the best place for this)
+	$.fullCalendar.isTouch = false;
+
+
 	jasmine.addMatchers({
 
 		// Moment and Duration

+ 63 - 5
tests/lib/simulate.js

@@ -1,5 +1,7 @@
 (function($) {
 
+/* General Utils
+----------------------------------------------------------------------------------------------------------------------*/
 
 $.simulateByPoint = function(type, options) {
 	var docEl = $(document);
@@ -16,6 +18,54 @@ $.simulateByPoint = function(type, options) {
 };
 
 
+/* Touch
+----------------------------------------------------------------------------------------------------------------------*/
+
+var origSimulateEvent = $.simulate.prototype.simulateEvent;
+var touchUID = Date.now();
+
+$.simulate.prototype.simulateEvent = function(elem, type, options) {
+	if (/^touch/.test(type)) {
+		return this.simulateTouchEvent(elem, type, options);
+	}
+	else {
+		return origSimulateEvent.apply(this, arguments);
+	}
+};
+
+$.simulate.prototype.simulateTouchEvent = function(elem, type, options) {
+	// http://stackoverflow.com/a/29019278/96342
+	var event = document.createEvent('Event');
+	event.initEvent(type, true, true); // cancelable, bubbleable
+	event.touches = [{
+		target: elem,
+		identifier: touchUID++,
+		pageX: options.clientX,
+		pageY: options.clientY,
+		screenX: options.clientX,
+		screenY: options.clientY,
+		clientX: options.clientX,
+		clientY: options.clientY
+	}];
+	this.dispatchEvent(elem, type, event, options);
+};
+
+$.simulateTouchClick = function(elem) {
+	var $elem = $(elem);
+	var clientCoords = {
+		clientX: $elem.offset().left,
+		clientY: $elem.offset().top
+	};
+	$elem.simulate('mousemove', clientCoords);
+	$elem.simulate('mousedown', clientCoords);
+	$elem.simulate('mouseup', clientCoords);
+	$elem.simulate('click', clientCoords);
+};
+
+
+/* Drag-n-drop
+----------------------------------------------------------------------------------------------------------------------*/
+
 var DEBUG_DELAY = 500;
 var DEBUG_MIN_DURATION = 2000;
 var DEBUG_MIN_MOVES = 100;
@@ -99,6 +149,7 @@ $.simulate.prototype.simulateDrag = function() {
 
 function simulateDrag(self, targetNode, startPoint, dx, dy, moveCnt, duration, options) {
 	var debug = options.debug;
+	var isTouch = options.isTouch;
 	var docNode = targetNode.ownerDocument;
 	var docEl = $(docNode);
 	var waitTime = duration / moveCnt;
@@ -141,13 +192,18 @@ function simulateDrag(self, targetNode, startPoint, dx, dy, moveCnt, duration, o
 
 		// simulate a drag-start only if another drag isn't already happening
 		if (dragStackCnt === 1) {
-			self.simulateEvent(targetNode, 'mousedown', clientCoords);
+			self.simulateEvent(targetNode, isTouch ? 'touchstart' : 'mousedown', clientCoords);
 		}
 
+		var delay = options.delay || 0;
 		if (debug) {
+			delay = Math.max(delay, DEBUG_DELAY);
+		}
+
+		if (delay) {
 			setTimeout(function() {
 				startMoving();
-			}, DEBUG_DELAY);
+			}, delay);
 		}
 		else {
 			startMoving();
@@ -161,7 +217,9 @@ function simulateDrag(self, targetNode, startPoint, dx, dy, moveCnt, duration, o
 	function tick() { // called one interval after start
 		moveIndex++;
 		updateCoords(); // update clientCoords before mousemove
-		self.simulateEvent(docNode, 'mousemove', clientCoords);
+
+		self.simulateEvent(docNode, isTouch ? 'touchmove' : 'mousemove', clientCoords);
+
 		if (moveIndex >= moveCnt) {
 			stopMoving();
 		}
@@ -188,11 +246,11 @@ function simulateDrag(self, targetNode, startPoint, dx, dy, moveCnt, duration, o
 		// otherwise, this means another drag has begun via onBeforeRelease.
 		if (dragId === dragStackCnt) {
 			if ($.contains(docNode, targetNode)) {
-				self.simulateEvent(targetNode, 'mouseup', clientCoords);
+				self.simulateEvent(targetNode, isTouch ? 'touchend' : 'mouseup', clientCoords);
 				self.simulateEvent(targetNode, 'click', clientCoords);
 			}
 			else {
-				self.simulateEvent(docNode, 'mouseup', clientCoords);
+				self.simulateEvent(docNode, isTouch ? 'touchend' : 'mouseup', clientCoords);
 			}
 		}
 

+ 128 - 0
tests/touch.html

@@ -0,0 +1,128 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset='utf-8' />
+<link href='../dist/fullcalendar.css' rel='stylesheet' />
+<link href='../dist/fullcalendar.print.css' rel='stylesheet' media='print' />
+<script src='../lib/moment/moment.js'></script>
+<script src='../lib/jquery/dist/jquery.js'></script>
+<script src='../dist/fullcalendar.js'></script>
+<script>
+
+	$(document).ready(function() {
+
+		$('#calendar').fullCalendar({
+			header: {
+				left: 'prev,next today',
+				center: 'title',
+				right: 'month,agendaWeek,basicWeek'
+			},
+			height: 500,
+			selectable: true,
+			selectHelper: true,
+			defaultView: 'agendaWeek',
+			defaultDate: '2016-01-12',
+			editable: true,
+			eventLimit: true, // allow "more" link when too many events
+			events: [
+				{
+					title: 'All Day Event',
+					start: '2016-01-01'
+				},
+				{
+					title: 'Long Event',
+					start: '2016-01-07',
+					end: '2016-01-10'
+				},
+				{
+					id: 999,
+					title: 'Repeating Event',
+					start: '2016-01-09T16:00:00'
+				},
+				{
+					id: 999,
+					title: 'Repeating Event',
+					start: '2016-01-16T16:00:00'
+				},
+				{
+					title: 'Conference',
+					start: '2016-01-11',
+					end: '2016-01-13'
+				},
+				{
+					title: 'Meeting',
+					start: '2016-01-12T10:30:00',
+					end: '2016-01-12T12:30:00'
+				},
+				{
+					title: 'Lunch',
+					start: '2016-01-12T12:00:00'
+				},
+				{
+					title: 'Meeting',
+					start: '2016-01-12T14:30:00'
+				},
+				{
+					title: 'Happy Hour',
+					start: '2016-01-12T17:30:00'
+				},
+				{
+					title: 'Dinner',
+					start: '2016-01-12T20:00:00'
+				},
+				{
+					title: 'Birthday Party',
+					start: '2016-01-13T07:00:00',
+					end: '2016-01-14T07:00:00'
+				},
+				{
+					title: 'Click for Google',
+					url: 'http://google.com/',
+					start: '2016-01-28'
+				}
+			]
+		});
+
+		$(document)
+			.on('touchstart', function() {
+				console.log('touchstart');
+			})
+			.on('touchend', function() {
+				console.log('touchend');
+			})
+			.on('mousemove', function() {
+				console.log('mousemove');
+			})
+			.on('mousedown', function() {
+				console.log('mousedown');
+			})
+			.on('mouseup', function() {
+				console.log('mouseup');
+			})
+			.on('click', function() {
+				console.log('click');
+			});
+
+	});
+
+</script>
+<style>
+
+	body {
+		margin: 0;
+		padding: 0;
+		font-family: "Lucida Grande",Helvetica,Arial,Verdana,sans-serif;
+		font-size: 13px;
+	}
+
+	#calendar {
+		width: 900px;
+		margin: 40px auto;
+	}
+
+</style>
+</head>
+<body>
+<div id='calendar'></div>
+</body>
+</html>