Browse Source

condense Calendar and DayGrid mixins

Adam Shaw 8 years ago
parent
commit
f5fceb8525
8 changed files with 1321 additions and 1341 deletions
  1. 0 5
      src.json
  2. 0 209
      src/Calendar.events-api.js
  3. 928 8
      src/Calendar.js
  4. 0 248
      src/Calendar.moment.js
  5. 0 374
      src/Calendar.render.js
  6. 0 100
      src/Calendar.toolbar.js
  7. 393 0
      src/basic/DayGrid.js
  8. 0 397
      src/basic/DayGrid.limit.js

+ 0 - 5
src.json

@@ -46,10 +46,6 @@
     "OptionsManager.js",
     "ViewSpecManager.js",
     "Calendar.js",
-    "Calendar.moment.js",
-    "Calendar.render.js",
-    "Calendar.toolbar.js",
-    "Calendar.events-api.js",
     "defaults.js",
     "locale.js",
     "Header.js",
@@ -84,7 +80,6 @@
     "basic/DayGridEventRenderer.js",
     "basic/DayGridHelperRenderer.js",
     "basic/DayGrid.js",
-    "basic/DayGrid.limit.js",
     "basic/BasicViewDateProfileGenerator.js",
     "basic/BasicView.js",
     "basic/MonthView.js",

+ 0 - 209
src/Calendar.events-api.js

@@ -1,209 +0,0 @@
-
-Calendar.mixin({
-
-	// Sources
-	// ------------------------------------------------------------------------------------
-
-
-	getEventSources: function() {
-		return this.eventManager.otherSources.slice(); // clone
-	},
-
-
-	getEventSourceById: function(id) {
-		return this.eventManager.getSourceById(
-			EventSource.normalizeId(id)
-		);
-	},
-
-
-	addEventSource: function(sourceInput) {
-		var source = EventSourceParser.parse(sourceInput, this);
-
-		if (source) {
-			this.eventManager.addSource(source);
-		}
-	},
-
-
-	removeEventSources: function(sourceMultiQuery) {
-		var eventManager = this.eventManager;
-		var sources;
-		var i;
-
-		if (sourceMultiQuery == null) {
-			this.eventManager.removeAllSources();
-		}
-		else {
-			sources = eventManager.multiQuerySources(sourceMultiQuery);
-
-			eventManager.freeze();
-
-			for (i = 0; i < sources.length; i++) {
-				eventManager.removeSource(sources[i]);
-			}
-
-			eventManager.thaw();
-		}
-	},
-
-
-	removeEventSource: function(sourceQuery) {
-		var eventManager = this.eventManager;
-		var sources = eventManager.querySources(sourceQuery);
-		var i;
-
-		eventManager.freeze();
-
-		for (i = 0; i < sources.length; i++) {
-			eventManager.removeSource(sources[i]);
-		}
-
-		eventManager.thaw();
-	},
-
-
-	refetchEventSources: function(sourceMultiQuery) {
-		var eventManager = this.eventManager;
-		var sources = eventManager.multiQuerySources(sourceMultiQuery);
-		var i;
-
-		eventManager.freeze();
-
-		for (i = 0; i < sources.length; i++) {
-			eventManager.refetchSource(sources[i]);
-		}
-
-		eventManager.thaw();
-	},
-
-
-	// Events
-	// ------------------------------------------------------------------------------------
-
-
-	refetchEvents: function() {
-		this.eventManager.refetchAllSources();
-	},
-
-
-	renderEvents: function(eventInputs, isSticky) {
-		this.eventManager.freeze();
-
-		for (var i = 0; i < eventInputs.length; i++) {
-			this.renderEvent(eventInputs[i], isSticky);
-		}
-
-		this.eventManager.thaw();
-	},
-
-
-	renderEvent: function(eventInput, isSticky) {
-		var eventManager = this.eventManager;
-		var eventDef = EventDefParser.parse(
-			eventInput,
-			eventInput.source || eventManager.stickySource
-		);
-
-		if (eventDef) {
-			eventManager.addEventDef(eventDef, isSticky);
-		}
-	},
-
-
-	// legacyQuery operates on legacy event instance objects
-	removeEvents: function(legacyQuery) {
-		var eventManager = this.eventManager;
-		var legacyInstances = [];
-		var idMap = {};
-		var eventDef;
-		var i;
-
-		if (legacyQuery == null) { // shortcut for removing all
-			eventManager.removeAllEventDefs(true); // persist=true
-		}
-		else {
-			eventManager.getEventInstances().forEach(function(eventInstance) {
-				legacyInstances.push(eventInstance.toLegacy());
-			});
-
-			legacyInstances = filterLegacyEventInstances(legacyInstances, legacyQuery);
-
-			// compute unique IDs
-			for (i = 0; i < legacyInstances.length; i++) {
-				eventDef = this.eventManager.getEventDefByUid(legacyInstances[i]._id);
-				idMap[eventDef.id] = true;
-			}
-
-			eventManager.freeze();
-
-			for (i in idMap) { // reuse `i` as an "id"
-				eventManager.removeEventDefsById(i, true); // persist=true
-			}
-
-			eventManager.thaw();
-		}
-	},
-
-
-	// legacyQuery operates on legacy event instance objects
-	clientEvents: function(legacyQuery) {
-		var legacyEventInstances = [];
-
-		this.eventManager.getEventInstances().forEach(function(eventInstance) {
-			legacyEventInstances.push(eventInstance.toLegacy());
-		});
-
-		return filterLegacyEventInstances(legacyEventInstances, legacyQuery);
-	},
-
-
-	updateEvents: function(eventPropsArray) {
-		this.eventManager.freeze();
-
-		for (var i = 0; i < eventPropsArray.length; i++) {
-			this.updateEvent(eventPropsArray[i]);
-		}
-
-		this.eventManager.thaw();
-	},
-
-
-	updateEvent: function(eventProps) {
-		var eventDef = this.eventManager.getEventDefByUid(eventProps._id);
-		var eventInstance;
-		var eventDefMutation;
-
-		if (eventDef instanceof SingleEventDef) {
-			eventInstance = eventDef.buildInstance();
-
-			eventDefMutation = EventDefMutation.createFromRawProps(
-				eventInstance,
-				eventProps, // raw props
-				null // largeUnit -- who uses it?
-			);
-
-			this.eventManager.mutateEventsWithId(eventDef.id, eventDefMutation); // will release
-		}
-	}
-
-});
-
-
-function filterLegacyEventInstances(legacyEventInstances, legacyQuery) {
-	if (legacyQuery == null) {
-		return legacyEventInstances;
-	}
-	else if ($.isFunction(legacyQuery)) {
-		return legacyEventInstances.filter(legacyQuery);
-	}
-	else { // an event ID
-		legacyQuery += ''; // normalize to string
-
-		return legacyEventInstances.filter(function(legacyEventInstance) {
-			// soft comparison because id not be normalized to string
-			return legacyEventInstance.id == legacyQuery ||
-				legacyEventInstance._id === legacyQuery; // can specify internal id, but must exactly match
-		});
-	}
-}

+ 928 - 8
src/Calendar.js

@@ -11,6 +11,21 @@ var Calendar = FC.Calendar = Class.extend(EmitterMixin, ListenerMixin, {
 	businessHourGenerator: null,
 	loadingLevel: 0, // number of simultaneous loading tasks
 
+	defaultAllDayEventDuration: null,
+	defaultTimedEventDuration: null,
+	localeData: null,
+
+	el: null,
+	contentEl: null,
+	suggestedViewHeight: null,
+	ignoreUpdateViewSize: 0,
+	freezeContentHeightDepth: 0,
+	windowResizeProxy: null,
+
+	header: null,
+	footer: null,
+	toolbarsManager: null,
+
 
 	constructor: function(el, overrides) {
 
@@ -37,10 +52,6 @@ var Calendar = FC.Calendar = Class.extend(EmitterMixin, ListenerMixin, {
 	},
 
 
-	// Public API
-	// -----------------------------------------------------------------------------------------------------------------
-
-
 	getView: function() {
 		return this.view;
 	},
@@ -256,6 +267,469 @@ var Calendar = FC.Calendar = Class.extend(EmitterMixin, ListenerMixin, {
 	},
 
 
+	// High-level Rendering
+	// -----------------------------------------------------------------------------------
+
+
+	render: function() {
+		if (!this.contentEl) {
+			this.initialRender();
+		}
+		else if (this.elementVisible()) {
+			// mainly for the public API
+			this.calcSize();
+			this.renderView();
+		}
+	},
+
+
+	initialRender: function() {
+		var _this = this;
+		var el = this.el;
+
+		el.addClass('fc');
+
+		// event delegation for nav links
+		el.on('click.fc', 'a[data-goto]', function(ev) {
+			var anchorEl = $(this);
+			var gotoOptions = anchorEl.data('goto'); // will automatically parse JSON
+			var date = _this.moment(gotoOptions.date);
+			var viewType = gotoOptions.type;
+
+			// property like "navLinkDayClick". might be a string or a function
+			var customAction = _this.view.opt('navLink' + capitaliseFirstLetter(viewType) + 'Click');
+
+			if (typeof customAction === 'function') {
+				customAction(date, ev);
+			}
+			else {
+				if (typeof customAction === 'string') {
+					viewType = customAction;
+				}
+				_this.zoomTo(date, viewType);
+			}
+		});
+
+		// called immediately, and upon option change
+		this.optionsManager.watch('settingTheme', [ '?theme', '?themeSystem' ], function(opts) {
+			var themeClass = ThemeRegistry.getThemeClass(opts.themeSystem || opts.theme);
+			var theme = new themeClass(_this.optionsManager);
+			var widgetClass = theme.getClass('widget');
+
+			_this.theme = theme;
+
+			if (widgetClass) {
+				el.addClass(widgetClass);
+			}
+		}, function() {
+			var widgetClass = _this.theme.getClass('widget');
+
+			_this.theme = null;
+
+			if (widgetClass) {
+				el.removeClass(widgetClass);
+			}
+		});
+
+		this.optionsManager.watch('settingBusinessHourGenerator', [ '?businessHours' ], function(deps) {
+			_this.businessHourGenerator = new BusinessHourGenerator(deps.businessHours, _this);
+
+			if (_this.view) {
+				_this.view.set('businessHourGenerator', _this.businessHourGenerator);
+			}
+		}, function() {
+			_this.businessHourGenerator = null;
+		});
+
+		// called immediately, and upon option change.
+		// HACK: locale often affects isRTL, so we explicitly listen to that too.
+		this.optionsManager.watch('applyingDirClasses', [ '?isRTL', '?locale' ], function(opts) {
+			el.toggleClass('fc-ltr', !opts.isRTL);
+			el.toggleClass('fc-rtl', opts.isRTL);
+		});
+
+		this.contentEl = $("<div class='fc-view-container'/>").prependTo(el);
+
+		this.initToolbars();
+		this.renderHeader();
+		this.renderFooter();
+		this.renderView(this.opt('defaultView'));
+
+		if (this.opt('handleWindowResize')) {
+			$(window).resize(
+				this.windowResizeProxy = debounce( // prevents rapid calls
+					this.windowResize.bind(this),
+					this.opt('windowResizeDelay')
+				)
+			);
+		}
+	},
+
+
+	destroy: function() {
+		if (this.view) {
+			this.clearView();
+		}
+
+		this.toolbarsManager.proxyCall('removeElement');
+		this.contentEl.remove();
+		this.el.removeClass('fc fc-ltr fc-rtl');
+
+		// removes theme-related root className
+		this.optionsManager.unwatch('settingTheme');
+		this.optionsManager.unwatch('settingBusinessHourGenerator');
+
+		this.el.off('.fc'); // unbind nav link handlers
+
+		if (this.windowResizeProxy) {
+			$(window).unbind('resize', this.windowResizeProxy);
+			this.windowResizeProxy = null;
+		}
+
+		GlobalEmitter.unneeded();
+	},
+
+
+	elementVisible: function() {
+		return this.el.is(':visible');
+	},
+
+
+	// Render Queue
+	// -----------------------------------------------------------------------------------------------------------------
+
+
+	bindViewHandlers: function(view) {
+		var _this = this;
+
+		view.watch('titleForCalendar', [ 'title' ], function(deps) { // TODO: better system
+			if (view === _this.view) { // hack
+				_this.setToolbarsTitle(deps.title);
+			}
+		});
+
+		view.watch('dateProfileForCalendar', [ 'dateProfile' ], function(deps) {
+			if (view === _this.view) { // hack
+				_this.currentDate = deps.dateProfile.date; // might have been constrained by view dates
+				_this.updateToolbarButtons(deps.dateProfile);
+			}
+		});
+	},
+
+
+	unbindViewHandlers: function(view) {
+		view.unwatch('titleForCalendar');
+		view.unwatch('dateProfileForCalendar');
+	},
+
+
+	// View Rendering
+	// -----------------------------------------------------------------------------------
+
+
+	// Renders a view because of a date change, view-type change, or for the first time.
+	// If not given a viewType, keep the current view but render different dates.
+	// Accepts an optional scroll state to restore to.
+	renderView: function(viewType) {
+		var oldView = this.view;
+		var newView;
+
+		this.freezeContentHeight();
+
+		if (oldView && viewType && oldView.type !== viewType) {
+			this.clearView();
+		}
+
+		// if viewType changed, or the view was never created, create a fresh view
+		if (!this.view && viewType) {
+			newView = this.view =
+				this.viewsByType[viewType] ||
+				(this.viewsByType[viewType] = this.instantiateView(viewType));
+
+			this.bindViewHandlers(newView);
+
+			newView.setElement(
+				$("<div class='fc-view fc-" + viewType + "-view' />").appendTo(this.contentEl)
+			);
+
+			this.toolbarsManager.proxyCall('activateButton', viewType);
+		}
+
+		if (this.view) {
+
+			// prevent unnecessary change firing
+			if (this.view.get('businessHourGenerator') !== this.businessHourGenerator) {
+				this.view.set('businessHourGenerator', this.businessHourGenerator);
+			}
+
+			this.view.setDate(this.currentDate);
+		}
+
+		this.thawContentHeight();
+	},
+
+
+	// Unrenders the current view and reflects this change in the Header.
+	// Unregsiters the `view`, but does not remove from viewByType hash.
+	clearView: function() {
+		var currentView = this.view;
+
+		this.toolbarsManager.proxyCall('deactivateButton', currentView.type);
+
+		this.unbindViewHandlers(currentView);
+
+		currentView.removeElement();
+		currentView.unsetDate(); // so bindViewHandlers doesn't fire with old values next time
+
+		this.view = null;
+	},
+
+
+	// Destroys the view, including the view object. Then, re-instantiates it and renders it.
+	// Maintains the same scroll state.
+	// TODO: maintain any other user-manipulated state.
+	reinitView: function() {
+		var oldView = this.view;
+		var scroll = oldView.queryScroll(); // wouldn't be so complicated if Calendar owned the scroll
+		this.freezeContentHeight();
+
+		this.clearView();
+		this.calcSize();
+		this.renderView(oldView.type); // needs the type to freshly render
+
+		this.view.applyScroll(scroll);
+		this.thawContentHeight();
+	},
+
+
+	// Resizing
+	// -----------------------------------------------------------------------------------
+
+
+	getSuggestedViewHeight: function() {
+		if (this.suggestedViewHeight === null) {
+			this.calcSize();
+		}
+		return this.suggestedViewHeight;
+	},
+
+
+	isHeightAuto: function() {
+		return this.opt('contentHeight') === 'auto' || this.opt('height') === 'auto';
+	},
+
+
+	updateViewSize: function(isResize) {
+		var view = this.view;
+		var scroll;
+
+		if (!this.ignoreUpdateViewSize && view) {
+
+			if (isResize) {
+				this.calcSize();
+				scroll = view.queryScroll();
+			}
+
+			this.ignoreUpdateViewSize++;
+
+			view.updateSize(
+				this.getSuggestedViewHeight(),
+				this.isHeightAuto(),
+				isResize
+			);
+
+			this.ignoreUpdateViewSize--;
+
+			if (isResize) {
+				view.applyScroll(scroll);
+			}
+
+			return true; // signal success
+		}
+	},
+
+
+	calcSize: function() {
+		if (this.elementVisible()) {
+			this._calcSize();
+		}
+	},
+
+
+	_calcSize: function() { // assumes elementVisible
+		var contentHeightInput = this.opt('contentHeight');
+		var heightInput = this.opt('height');
+
+		if (typeof contentHeightInput === 'number') { // exists and not 'auto'
+			this.suggestedViewHeight = contentHeightInput;
+		}
+		else if (typeof contentHeightInput === 'function') { // exists and is a function
+			this.suggestedViewHeight = contentHeightInput();
+		}
+		else if (typeof heightInput === 'number') { // exists and not 'auto'
+			this.suggestedViewHeight = heightInput - this.queryToolbarsHeight();
+		}
+		else if (typeof heightInput === 'function') { // exists and is a function
+			this.suggestedViewHeight = heightInput() - this.queryToolbarsHeight();
+		}
+		else if (heightInput === 'parent') { // set to height of parent element
+			this.suggestedViewHeight = this.el.parent().height() - this.queryToolbarsHeight();
+		}
+		else {
+			this.suggestedViewHeight = Math.round(
+				this.contentEl.width() /
+				Math.max(this.opt('aspectRatio'), .5)
+			);
+		}
+	},
+
+
+	windowResize: function(ev) {
+		if (
+			ev.target === window && // so we don't process jqui "resize" events that have bubbled up
+			this.view &&
+			this.view.isDatesRendered
+		) {
+			if (this.updateViewSize(true)) { // isResize=true, returns true on success
+				this.publiclyTrigger('windowResize', [ this.view ]);
+			}
+		}
+	},
+
+
+	/* Height "Freezing"
+	-----------------------------------------------------------------------------*/
+
+
+	freezeContentHeight: function() {
+		if (!(this.freezeContentHeightDepth++)) {
+			this.forceFreezeContentHeight();
+		}
+	},
+
+
+	forceFreezeContentHeight: function() {
+		this.contentEl.css({
+			width: '100%',
+			height: this.contentEl.height(),
+			overflow: 'hidden'
+		});
+	},
+
+
+	thawContentHeight: function() {
+		this.freezeContentHeightDepth--;
+
+		// always bring back to natural height
+		this.contentEl.css({
+			width: '',
+			height: '',
+			overflow: ''
+		});
+
+		// but if there are future thaws, re-freeze
+		if (this.freezeContentHeightDepth) {
+			this.forceFreezeContentHeight();
+		}
+	},
+
+
+	// Toolbar
+	// -----------------------------------------------------------------------------------------------------------------
+
+
+	initToolbars: function() {
+		this.header = new Toolbar(this, this.computeHeaderOptions());
+		this.footer = new Toolbar(this, this.computeFooterOptions());
+		this.toolbarsManager = new Iterator([ this.header, this.footer ]);
+	},
+
+
+	computeHeaderOptions: function() {
+		return {
+			extraClasses: 'fc-header-toolbar',
+			layout: this.opt('header')
+		};
+	},
+
+
+	computeFooterOptions: function() {
+		return {
+			extraClasses: 'fc-footer-toolbar',
+			layout: this.opt('footer')
+		};
+	},
+
+
+	// can be called repeatedly and Header will rerender
+	renderHeader: function() {
+		var header = this.header;
+
+		header.setToolbarOptions(this.computeHeaderOptions());
+		header.render();
+
+		if (header.el) {
+			this.el.prepend(header.el);
+		}
+	},
+
+
+	// can be called repeatedly and Footer will rerender
+	renderFooter: function() {
+		var footer = this.footer;
+
+		footer.setToolbarOptions(this.computeFooterOptions());
+		footer.render();
+
+		if (footer.el) {
+			this.el.append(footer.el);
+		}
+	},
+
+
+	setToolbarsTitle: function(title) {
+		this.toolbarsManager.proxyCall('updateTitle', title);
+	},
+
+
+	updateToolbarButtons: function(dateProfile) {
+		var now = this.getNow();
+		var view = this.view;
+		var todayInfo = view.dateProfileGenerator.build(now);
+		var prevInfo = view.dateProfileGenerator.buildPrev(view.get('dateProfile'));
+		var nextInfo = view.dateProfileGenerator.buildNext(view.get('dateProfile'));
+
+		this.toolbarsManager.proxyCall(
+			(todayInfo.isValid && !dateProfile.currentUnzonedRange.containsDate(now)) ?
+				'enableButton' :
+				'disableButton',
+			'today'
+		);
+
+		this.toolbarsManager.proxyCall(
+			prevInfo.isValid ?
+				'enableButton' :
+				'disableButton',
+			'prev'
+		);
+
+		this.toolbarsManager.proxyCall(
+			nextInfo.isValid ?
+				'enableButton' :
+				'disableButton',
+			'next'
+		);
+	},
+
+
+	queryToolbarsHeight: function() {
+		return this.toolbarsManager.items.reduce(function(accumulator, toolbar) {
+			var toolbarHeight = toolbar.el ? toolbar.el.outerHeight(true) : 0; // includes margin
+			return accumulator + toolbarHeight;
+		}, 0);
+	},
+
+
 	// Selection
 	// -----------------------------------------------------------------------------------------------------------------
 
@@ -297,10 +771,214 @@ var Calendar = FC.Calendar = Class.extend(EmitterMixin, ListenerMixin, {
 	},
 
 
-	// Misc
+	// Date Utils
 	// -----------------------------------------------------------------------------------------------------------------
 
 
+	initMomentInternals: function() {
+		var _this = this;
+
+		this.defaultAllDayEventDuration = moment.duration(this.opt('defaultAllDayEventDuration'));
+		this.defaultTimedEventDuration = moment.duration(this.opt('defaultTimedEventDuration'));
+
+		// Called immediately, and when any of the options change.
+		// Happens before any internal objects rebuild or rerender, because this is very core.
+		this.optionsManager.watch('buildingMomentLocale', [
+			'?locale', '?monthNames', '?monthNamesShort', '?dayNames', '?dayNamesShort',
+			'?firstDay', '?weekNumberCalculation'
+		], function(opts) {
+			var weekNumberCalculation = opts.weekNumberCalculation;
+			var firstDay = opts.firstDay;
+			var _week;
+
+			// normalize
+			if (weekNumberCalculation === 'iso') {
+				weekNumberCalculation = 'ISO'; // normalize
+			}
+
+			var localeData = Object.create( // make a cheap copy
+				getMomentLocaleData(opts.locale) // will fall back to en
+			);
+
+			if (opts.monthNames) {
+				localeData._months = opts.monthNames;
+			}
+			if (opts.monthNamesShort) {
+				localeData._monthsShort = opts.monthNamesShort;
+			}
+			if (opts.dayNames) {
+				localeData._weekdays = opts.dayNames;
+			}
+			if (opts.dayNamesShort) {
+				localeData._weekdaysShort = opts.dayNamesShort;
+			}
+
+			if (firstDay == null && weekNumberCalculation === 'ISO') {
+				firstDay = 1;
+			}
+			if (firstDay != null) {
+				_week = Object.create(localeData._week); // _week: { dow: # }
+				_week.dow = firstDay;
+				localeData._week = _week;
+			}
+
+			if ( // whitelist certain kinds of input
+				weekNumberCalculation === 'ISO' ||
+				weekNumberCalculation === 'local' ||
+				typeof weekNumberCalculation === 'function'
+			) {
+				localeData._fullCalendar_weekCalc = weekNumberCalculation; // moment-ext will know what to do with it
+			}
+
+			_this.localeData = localeData;
+
+			// If the internal current date object already exists, move to new locale.
+			// We do NOT need to do this technique for event dates, because this happens when converting to "segments".
+			if (_this.currentDate) {
+				_this.localizeMoment(_this.currentDate); // sets to localeData
+			}
+		});
+	},
+
+
+	// Builds a moment using the settings of the current calendar: timezone and locale.
+	// Accepts anything the vanilla moment() constructor accepts.
+	moment: function() {
+		var mom;
+
+		if (this.opt('timezone') === 'local') {
+			mom = FC.moment.apply(null, arguments);
+
+			// Force the moment to be local, because FC.moment doesn't guarantee it.
+			if (mom.hasTime()) { // don't give ambiguously-timed moments a local zone
+				mom.local();
+			}
+		}
+		else if (this.opt('timezone') === 'UTC') {
+			mom = FC.moment.utc.apply(null, arguments); // process as UTC
+		}
+		else {
+			mom = FC.moment.parseZone.apply(null, arguments); // let the input decide the zone
+		}
+
+		this.localizeMoment(mom); // TODO
+
+		return mom;
+	},
+
+
+	msToMoment: function(ms, forceAllDay) {
+		var mom = FC.moment.utc(ms); // TODO: optimize by using Date.UTC
+
+		if (forceAllDay) {
+			mom.stripTime();
+		}
+		else {
+			mom = this.applyTimezone(mom); // may or may not apply locale
+		}
+
+		this.localizeMoment(mom);
+
+		return mom;
+	},
+
+
+	msToUtcMoment: function(ms, forceAllDay) {
+		var mom = FC.moment.utc(ms); // TODO: optimize by using Date.UTC
+
+		if (forceAllDay) {
+			mom.stripTime();
+		}
+
+		this.localizeMoment(mom);
+
+		return mom;
+	},
+
+
+	// Updates the given moment's locale settings to the current calendar locale settings.
+	localizeMoment: function(mom) {
+		mom._locale = this.localeData;
+	},
+
+
+	// Returns a boolean about whether or not the calendar knows how to calculate
+	// the timezone offset of arbitrary dates in the current timezone.
+	getIsAmbigTimezone: function() {
+		return this.opt('timezone') !== 'local' && this.opt('timezone') !== 'UTC';
+	},
+
+
+	// Returns a copy of the given date in the current timezone. Has no effect on dates without times.
+	applyTimezone: function(date) {
+		if (!date.hasTime()) {
+			return date.clone();
+		}
+
+		var zonedDate = this.moment(date.toArray());
+		var timeAdjust = date.time() - zonedDate.time();
+		var adjustedZonedDate;
+
+		// Safari sometimes has problems with this coersion when near DST. Adjust if necessary. (bug #2396)
+		if (timeAdjust) { // is the time result different than expected?
+			adjustedZonedDate = zonedDate.clone().add(timeAdjust); // add milliseconds
+			if (date.time() - adjustedZonedDate.time() === 0) { // does it match perfectly now?
+				zonedDate = adjustedZonedDate;
+			}
+		}
+
+		return zonedDate;
+	},
+
+
+	/*
+	Assumes the footprint is non-open-ended.
+	*/
+	footprintToDateProfile: function(componentFootprint, ignoreEnd) {
+		var start = FC.moment.utc(componentFootprint.unzonedRange.startMs);
+		var end;
+
+		if (!ignoreEnd) {
+			end = FC.moment.utc(componentFootprint.unzonedRange.endMs);
+		}
+
+		if (componentFootprint.isAllDay) {
+			start.stripTime();
+
+			if (end) {
+				end.stripTime();
+			}
+		}
+		else {
+			start = this.applyTimezone(start);
+
+			if (end) {
+				end = this.applyTimezone(end);
+			}
+		}
+
+		return new EventDateProfile(start, end, this);
+	},
+
+
+	// Returns a moment for the current date, as defined by the client's computer or from the `now` option.
+	// Will return an moment with an ambiguous timezone.
+	getNow: function() {
+		var now = this.opt('now');
+		if (typeof now === 'function') {
+			now = now();
+		}
+		return this.moment(now).stripZone();
+	},
+
+
+	// Produces a human-readable string for the given duration.
+	// Side-effect: changes the locale of the given duration.
+	humanizeDuration: function(duration) {
+		return duration.locale(this.opt('locale')).humanize();
+	},
+
+
 	// will return `null` if invalid range
 	parseUnzonedRange: function(rangeInput) {
 		var start = null;
@@ -326,9 +1004,8 @@ var Calendar = FC.Calendar = Class.extend(EmitterMixin, ListenerMixin, {
 	},
 
 
-	rerenderEvents: function() { // API method. destroys old events if previously rendered.
-		this.view.flash('displayingEvents');
-	},
+	// Event-Date Utilities
+	// -----------------------------------------------------------------------------------------------------------------
 
 
 	initEventManager: function() {
@@ -368,6 +1045,249 @@ var Calendar = FC.Calendar = Class.extend(EmitterMixin, ListenerMixin, {
 			this.opt('timezone'),
 			!this.opt('lazyFetching')
 		);
+	},
+
+
+	// Get an event's normalized end date. If not present, calculate it from the defaults.
+	getEventEnd: function(event) {
+		if (event.end) {
+			return event.end.clone();
+		}
+		else {
+			return this.getDefaultEventEnd(event.allDay, event.start);
+		}
+	},
+
+
+	// Given an event's allDay status and start date, return what its fallback end date should be.
+	// TODO: rename to computeDefaultEventEnd
+	getDefaultEventEnd: function(allDay, zonedStart) {
+		var end = zonedStart.clone();
+
+		if (allDay) {
+			end.stripTime().add(this.defaultAllDayEventDuration);
+		}
+		else {
+			end.add(this.defaultTimedEventDuration);
+		}
+
+		if (this.getIsAmbigTimezone()) {
+			end.stripZone(); // we don't know what the tzo should be
+		}
+
+		return end;
+	},
+
+
+	// Public Events API
+	// -----------------------------------------------------------------------------------------------------------------
+
+
+	rerenderEvents: function() { // API method. destroys old events if previously rendered.
+		this.view.flash('displayingEvents');
+	},
+
+
+	refetchEvents: function() {
+		this.eventManager.refetchAllSources();
+	},
+
+
+	renderEvents: function(eventInputs, isSticky) {
+		this.eventManager.freeze();
+
+		for (var i = 0; i < eventInputs.length; i++) {
+			this.renderEvent(eventInputs[i], isSticky);
+		}
+
+		this.eventManager.thaw();
+	},
+
+
+	renderEvent: function(eventInput, isSticky) {
+		var eventManager = this.eventManager;
+		var eventDef = EventDefParser.parse(
+			eventInput,
+			eventInput.source || eventManager.stickySource
+		);
+
+		if (eventDef) {
+			eventManager.addEventDef(eventDef, isSticky);
+		}
+	},
+
+
+	// legacyQuery operates on legacy event instance objects
+	removeEvents: function(legacyQuery) {
+		var eventManager = this.eventManager;
+		var legacyInstances = [];
+		var idMap = {};
+		var eventDef;
+		var i;
+
+		if (legacyQuery == null) { // shortcut for removing all
+			eventManager.removeAllEventDefs(true); // persist=true
+		}
+		else {
+			eventManager.getEventInstances().forEach(function(eventInstance) {
+				legacyInstances.push(eventInstance.toLegacy());
+			});
+
+			legacyInstances = filterLegacyEventInstances(legacyInstances, legacyQuery);
+
+			// compute unique IDs
+			for (i = 0; i < legacyInstances.length; i++) {
+				eventDef = this.eventManager.getEventDefByUid(legacyInstances[i]._id);
+				idMap[eventDef.id] = true;
+			}
+
+			eventManager.freeze();
+
+			for (i in idMap) { // reuse `i` as an "id"
+				eventManager.removeEventDefsById(i, true); // persist=true
+			}
+
+			eventManager.thaw();
+		}
+	},
+
+
+	// legacyQuery operates on legacy event instance objects
+	clientEvents: function(legacyQuery) {
+		var legacyEventInstances = [];
+
+		this.eventManager.getEventInstances().forEach(function(eventInstance) {
+			legacyEventInstances.push(eventInstance.toLegacy());
+		});
+
+		return filterLegacyEventInstances(legacyEventInstances, legacyQuery);
+	},
+
+
+	updateEvents: function(eventPropsArray) {
+		this.eventManager.freeze();
+
+		for (var i = 0; i < eventPropsArray.length; i++) {
+			this.updateEvent(eventPropsArray[i]);
+		}
+
+		this.eventManager.thaw();
+	},
+
+
+	updateEvent: function(eventProps) {
+		var eventDef = this.eventManager.getEventDefByUid(eventProps._id);
+		var eventInstance;
+		var eventDefMutation;
+
+		if (eventDef instanceof SingleEventDef) {
+			eventInstance = eventDef.buildInstance();
+
+			eventDefMutation = EventDefMutation.createFromRawProps(
+				eventInstance,
+				eventProps, // raw props
+				null // largeUnit -- who uses it?
+			);
+
+			this.eventManager.mutateEventsWithId(eventDef.id, eventDefMutation); // will release
+		}
+	},
+
+
+	// Public Event Sources API
+	// ------------------------------------------------------------------------------------
+
+
+	getEventSources: function() {
+		return this.eventManager.otherSources.slice(); // clone
+	},
+
+
+	getEventSourceById: function(id) {
+		return this.eventManager.getSourceById(
+			EventSource.normalizeId(id)
+		);
+	},
+
+
+	addEventSource: function(sourceInput) {
+		var source = EventSourceParser.parse(sourceInput, this);
+
+		if (source) {
+			this.eventManager.addSource(source);
+		}
+	},
+
+
+	removeEventSources: function(sourceMultiQuery) {
+		var eventManager = this.eventManager;
+		var sources;
+		var i;
+
+		if (sourceMultiQuery == null) {
+			this.eventManager.removeAllSources();
+		}
+		else {
+			sources = eventManager.multiQuerySources(sourceMultiQuery);
+
+			eventManager.freeze();
+
+			for (i = 0; i < sources.length; i++) {
+				eventManager.removeSource(sources[i]);
+			}
+
+			eventManager.thaw();
+		}
+	},
+
+
+	removeEventSource: function(sourceQuery) {
+		var eventManager = this.eventManager;
+		var sources = eventManager.querySources(sourceQuery);
+		var i;
+
+		eventManager.freeze();
+
+		for (i = 0; i < sources.length; i++) {
+			eventManager.removeSource(sources[i]);
+		}
+
+		eventManager.thaw();
+	},
+
+
+	refetchEventSources: function(sourceMultiQuery) {
+		var eventManager = this.eventManager;
+		var sources = eventManager.multiQuerySources(sourceMultiQuery);
+		var i;
+
+		eventManager.freeze();
+
+		for (i = 0; i < sources.length; i++) {
+			eventManager.refetchSource(sources[i]);
+		}
+
+		eventManager.thaw();
 	}
 
+
 });
+
+
+function filterLegacyEventInstances(legacyEventInstances, legacyQuery) {
+	if (legacyQuery == null) {
+		return legacyEventInstances;
+	}
+	else if ($.isFunction(legacyQuery)) {
+		return legacyEventInstances.filter(legacyQuery);
+	}
+	else { // an event ID
+		legacyQuery += ''; // normalize to string
+
+		return legacyEventInstances.filter(function(legacyEventInstance) {
+			// soft comparison because id not be normalized to string
+			return legacyEventInstance.id == legacyQuery ||
+				legacyEventInstance._id === legacyQuery; // can specify internal id, but must exactly match
+		});
+	}
+}

+ 0 - 248
src/Calendar.moment.js

@@ -1,248 +0,0 @@
-
-Calendar.mixin({
-
-	defaultAllDayEventDuration: null,
-	defaultTimedEventDuration: null,
-	localeData: null,
-
-
-	initMomentInternals: function() {
-		var _this = this;
-
-		this.defaultAllDayEventDuration = moment.duration(this.opt('defaultAllDayEventDuration'));
-		this.defaultTimedEventDuration = moment.duration(this.opt('defaultTimedEventDuration'));
-
-		// Called immediately, and when any of the options change.
-		// Happens before any internal objects rebuild or rerender, because this is very core.
-		this.optionsManager.watch('buildingMomentLocale', [
-			'?locale', '?monthNames', '?monthNamesShort', '?dayNames', '?dayNamesShort',
-			'?firstDay', '?weekNumberCalculation'
-		], function(opts) {
-			var weekNumberCalculation = opts.weekNumberCalculation;
-			var firstDay = opts.firstDay;
-			var _week;
-
-			// normalize
-			if (weekNumberCalculation === 'iso') {
-				weekNumberCalculation = 'ISO'; // normalize
-			}
-
-			var localeData = Object.create( // make a cheap copy
-				getMomentLocaleData(opts.locale) // will fall back to en
-			);
-
-			if (opts.monthNames) {
-				localeData._months = opts.monthNames;
-			}
-			if (opts.monthNamesShort) {
-				localeData._monthsShort = opts.monthNamesShort;
-			}
-			if (opts.dayNames) {
-				localeData._weekdays = opts.dayNames;
-			}
-			if (opts.dayNamesShort) {
-				localeData._weekdaysShort = opts.dayNamesShort;
-			}
-
-			if (firstDay == null && weekNumberCalculation === 'ISO') {
-				firstDay = 1;
-			}
-			if (firstDay != null) {
-				_week = Object.create(localeData._week); // _week: { dow: # }
-				_week.dow = firstDay;
-				localeData._week = _week;
-			}
-
-			if ( // whitelist certain kinds of input
-				weekNumberCalculation === 'ISO' ||
-				weekNumberCalculation === 'local' ||
-				typeof weekNumberCalculation === 'function'
-			) {
-				localeData._fullCalendar_weekCalc = weekNumberCalculation; // moment-ext will know what to do with it
-			}
-
-			_this.localeData = localeData;
-
-			// If the internal current date object already exists, move to new locale.
-			// We do NOT need to do this technique for event dates, because this happens when converting to "segments".
-			if (_this.currentDate) {
-				_this.localizeMoment(_this.currentDate); // sets to localeData
-			}
-		});
-	},
-
-
-	// Builds a moment using the settings of the current calendar: timezone and locale.
-	// Accepts anything the vanilla moment() constructor accepts.
-	moment: function() {
-		var mom;
-
-		if (this.opt('timezone') === 'local') {
-			mom = FC.moment.apply(null, arguments);
-
-			// Force the moment to be local, because FC.moment doesn't guarantee it.
-			if (mom.hasTime()) { // don't give ambiguously-timed moments a local zone
-				mom.local();
-			}
-		}
-		else if (this.opt('timezone') === 'UTC') {
-			mom = FC.moment.utc.apply(null, arguments); // process as UTC
-		}
-		else {
-			mom = FC.moment.parseZone.apply(null, arguments); // let the input decide the zone
-		}
-
-		this.localizeMoment(mom); // TODO
-
-		return mom;
-	},
-
-
-	msToMoment: function(ms, forceAllDay) {
-		var mom = FC.moment.utc(ms); // TODO: optimize by using Date.UTC
-
-		if (forceAllDay) {
-			mom.stripTime();
-		}
-		else {
-			mom = this.applyTimezone(mom); // may or may not apply locale
-		}
-
-		this.localizeMoment(mom);
-
-		return mom;
-	},
-
-
-	msToUtcMoment: function(ms, forceAllDay) {
-		var mom = FC.moment.utc(ms); // TODO: optimize by using Date.UTC
-
-		if (forceAllDay) {
-			mom.stripTime();
-		}
-
-		this.localizeMoment(mom);
-
-		return mom;
-	},
-
-
-	// Updates the given moment's locale settings to the current calendar locale settings.
-	localizeMoment: function(mom) {
-		mom._locale = this.localeData;
-	},
-
-
-	// Returns a boolean about whether or not the calendar knows how to calculate
-	// the timezone offset of arbitrary dates in the current timezone.
-	getIsAmbigTimezone: function() {
-		return this.opt('timezone') !== 'local' && this.opt('timezone') !== 'UTC';
-	},
-
-
-	// Returns a copy of the given date in the current timezone. Has no effect on dates without times.
-	applyTimezone: function(date) {
-		if (!date.hasTime()) {
-			return date.clone();
-		}
-
-		var zonedDate = this.moment(date.toArray());
-		var timeAdjust = date.time() - zonedDate.time();
-		var adjustedZonedDate;
-
-		// Safari sometimes has problems with this coersion when near DST. Adjust if necessary. (bug #2396)
-		if (timeAdjust) { // is the time result different than expected?
-			adjustedZonedDate = zonedDate.clone().add(timeAdjust); // add milliseconds
-			if (date.time() - adjustedZonedDate.time() === 0) { // does it match perfectly now?
-				zonedDate = adjustedZonedDate;
-			}
-		}
-
-		return zonedDate;
-	},
-
-
-	/*
-	Assumes the footprint is non-open-ended.
-	*/
-	footprintToDateProfile: function(componentFootprint, ignoreEnd) {
-		var start = FC.moment.utc(componentFootprint.unzonedRange.startMs);
-		var end;
-
-		if (!ignoreEnd) {
-			end = FC.moment.utc(componentFootprint.unzonedRange.endMs);
-		}
-
-		if (componentFootprint.isAllDay) {
-			start.stripTime();
-
-			if (end) {
-				end.stripTime();
-			}
-		}
-		else {
-			start = this.applyTimezone(start);
-
-			if (end) {
-				end = this.applyTimezone(end);
-			}
-		}
-
-		return new EventDateProfile(start, end, this);
-	},
-
-
-	// Returns a moment for the current date, as defined by the client's computer or from the `now` option.
-	// Will return an moment with an ambiguous timezone.
-	getNow: function() {
-		var now = this.opt('now');
-		if (typeof now === 'function') {
-			now = now();
-		}
-		return this.moment(now).stripZone();
-	},
-
-
-	// Produces a human-readable string for the given duration.
-	// Side-effect: changes the locale of the given duration.
-	humanizeDuration: function(duration) {
-		return duration.locale(this.opt('locale')).humanize();
-	},
-
-
-
-	// Event-Specific Date Utilities. TODO: move
-	// -----------------------------------------------------------------------------------------------------------------
-
-
-	// Get an event's normalized end date. If not present, calculate it from the defaults.
-	getEventEnd: function(event) {
-		if (event.end) {
-			return event.end.clone();
-		}
-		else {
-			return this.getDefaultEventEnd(event.allDay, event.start);
-		}
-	},
-
-
-	// Given an event's allDay status and start date, return what its fallback end date should be.
-	// TODO: rename to computeDefaultEventEnd
-	getDefaultEventEnd: function(allDay, zonedStart) {
-		var end = zonedStart.clone();
-
-		if (allDay) {
-			end.stripTime().add(this.defaultAllDayEventDuration);
-		}
-		else {
-			end.add(this.defaultTimedEventDuration);
-		}
-
-		if (this.getIsAmbigTimezone()) {
-			end.stripZone(); // we don't know what the tzo should be
-		}
-
-		return end;
-	}
-
-});

+ 0 - 374
src/Calendar.render.js

@@ -1,374 +0,0 @@
-
-Calendar.mixin({
-
-	el: null,
-	contentEl: null,
-	suggestedViewHeight: null,
-	ignoreUpdateViewSize: 0,
-	freezeContentHeightDepth: 0,
-	windowResizeProxy: null,
-
-
-	render: function() {
-		if (!this.contentEl) {
-			this.initialRender();
-		}
-		else if (this.elementVisible()) {
-			// mainly for the public API
-			this.calcSize();
-			this.renderView();
-		}
-	},
-
-
-	initialRender: function() {
-		var _this = this;
-		var el = this.el;
-
-		el.addClass('fc');
-
-		// event delegation for nav links
-		el.on('click.fc', 'a[data-goto]', function(ev) {
-			var anchorEl = $(this);
-			var gotoOptions = anchorEl.data('goto'); // will automatically parse JSON
-			var date = _this.moment(gotoOptions.date);
-			var viewType = gotoOptions.type;
-
-			// property like "navLinkDayClick". might be a string or a function
-			var customAction = _this.view.opt('navLink' + capitaliseFirstLetter(viewType) + 'Click');
-
-			if (typeof customAction === 'function') {
-				customAction(date, ev);
-			}
-			else {
-				if (typeof customAction === 'string') {
-					viewType = customAction;
-				}
-				_this.zoomTo(date, viewType);
-			}
-		});
-
-		// called immediately, and upon option change
-		this.optionsManager.watch('settingTheme', [ '?theme', '?themeSystem' ], function(opts) {
-			var themeClass = ThemeRegistry.getThemeClass(opts.themeSystem || opts.theme);
-			var theme = new themeClass(_this.optionsManager);
-			var widgetClass = theme.getClass('widget');
-
-			_this.theme = theme;
-
-			if (widgetClass) {
-				el.addClass(widgetClass);
-			}
-		}, function() {
-			var widgetClass = _this.theme.getClass('widget');
-
-			_this.theme = null;
-
-			if (widgetClass) {
-				el.removeClass(widgetClass);
-			}
-		});
-
-		this.optionsManager.watch('settingBusinessHourGenerator', [ '?businessHours' ], function(deps) {
-			_this.businessHourGenerator = new BusinessHourGenerator(deps.businessHours, _this);
-
-			if (_this.view) {
-				_this.view.set('businessHourGenerator', _this.businessHourGenerator);
-			}
-		}, function() {
-			_this.businessHourGenerator = null;
-		});
-
-		// called immediately, and upon option change.
-		// HACK: locale often affects isRTL, so we explicitly listen to that too.
-		this.optionsManager.watch('applyingDirClasses', [ '?isRTL', '?locale' ], function(opts) {
-			el.toggleClass('fc-ltr', !opts.isRTL);
-			el.toggleClass('fc-rtl', opts.isRTL);
-		});
-
-		this.contentEl = $("<div class='fc-view-container'/>").prependTo(el);
-
-		this.initToolbars();
-		this.renderHeader();
-		this.renderFooter();
-		this.renderView(this.opt('defaultView'));
-
-		if (this.opt('handleWindowResize')) {
-			$(window).resize(
-				this.windowResizeProxy = debounce( // prevents rapid calls
-					this.windowResize.bind(this),
-					this.opt('windowResizeDelay')
-				)
-			);
-		}
-	},
-
-
-	destroy: function() {
-		if (this.view) {
-			this.clearView();
-		}
-
-		this.toolbarsManager.proxyCall('removeElement');
-		this.contentEl.remove();
-		this.el.removeClass('fc fc-ltr fc-rtl');
-
-		// removes theme-related root className
-		this.optionsManager.unwatch('settingTheme');
-		this.optionsManager.unwatch('settingBusinessHourGenerator');
-
-		this.el.off('.fc'); // unbind nav link handlers
-
-		if (this.windowResizeProxy) {
-			$(window).unbind('resize', this.windowResizeProxy);
-			this.windowResizeProxy = null;
-		}
-
-		GlobalEmitter.unneeded();
-	},
-
-
-	elementVisible: function() {
-		return this.el.is(':visible');
-	},
-
-
-	// Render Queue
-	// -----------------------------------------------------------------------------------------------------------------
-
-
-	bindViewHandlers: function(view) {
-		var _this = this;
-
-		view.watch('titleForCalendar', [ 'title' ], function(deps) { // TODO: better system
-			if (view === _this.view) { // hack
-				_this.setToolbarsTitle(deps.title);
-			}
-		});
-
-		view.watch('dateProfileForCalendar', [ 'dateProfile' ], function(deps) {
-			if (view === _this.view) { // hack
-				_this.currentDate = deps.dateProfile.date; // might have been constrained by view dates
-				_this.updateToolbarButtons(deps.dateProfile);
-			}
-		});
-	},
-
-
-	unbindViewHandlers: function(view) {
-		view.unwatch('titleForCalendar');
-		view.unwatch('dateProfileForCalendar');
-	},
-
-
-	// View Rendering
-	// -----------------------------------------------------------------------------------
-
-
-	// Renders a view because of a date change, view-type change, or for the first time.
-	// If not given a viewType, keep the current view but render different dates.
-	// Accepts an optional scroll state to restore to.
-	renderView: function(viewType) {
-		var oldView = this.view;
-		var newView;
-
-		this.freezeContentHeight();
-
-		if (oldView && viewType && oldView.type !== viewType) {
-			this.clearView();
-		}
-
-		// if viewType changed, or the view was never created, create a fresh view
-		if (!this.view && viewType) {
-			newView = this.view =
-				this.viewsByType[viewType] ||
-				(this.viewsByType[viewType] = this.instantiateView(viewType));
-
-			this.bindViewHandlers(newView);
-
-			newView.setElement(
-				$("<div class='fc-view fc-" + viewType + "-view' />").appendTo(this.contentEl)
-			);
-
-			this.toolbarsManager.proxyCall('activateButton', viewType);
-		}
-
-		if (this.view) {
-
-			// prevent unnecessary change firing
-			if (this.view.get('businessHourGenerator') !== this.businessHourGenerator) {
-				this.view.set('businessHourGenerator', this.businessHourGenerator);
-			}
-
-			this.view.setDate(this.currentDate);
-		}
-
-		this.thawContentHeight();
-	},
-
-
-	// Unrenders the current view and reflects this change in the Header.
-	// Unregsiters the `view`, but does not remove from viewByType hash.
-	clearView: function() {
-		var currentView = this.view;
-
-		this.toolbarsManager.proxyCall('deactivateButton', currentView.type);
-
-		this.unbindViewHandlers(currentView);
-
-		currentView.removeElement();
-		currentView.unsetDate(); // so bindViewHandlers doesn't fire with old values next time
-
-		this.view = null;
-	},
-
-
-	// Destroys the view, including the view object. Then, re-instantiates it and renders it.
-	// Maintains the same scroll state.
-	// TODO: maintain any other user-manipulated state.
-	reinitView: function() {
-		var oldView = this.view;
-		var scroll = oldView.queryScroll(); // wouldn't be so complicated if Calendar owned the scroll
-		this.freezeContentHeight();
-
-		this.clearView();
-		this.calcSize();
-		this.renderView(oldView.type); // needs the type to freshly render
-
-		this.view.applyScroll(scroll);
-		this.thawContentHeight();
-	},
-
-
-	// Resizing
-	// -----------------------------------------------------------------------------------
-
-
-	getSuggestedViewHeight: function() {
-		if (this.suggestedViewHeight === null) {
-			this.calcSize();
-		}
-		return this.suggestedViewHeight;
-	},
-
-
-	isHeightAuto: function() {
-		return this.opt('contentHeight') === 'auto' || this.opt('height') === 'auto';
-	},
-
-
-	updateViewSize: function(isResize) {
-		var view = this.view;
-		var scroll;
-
-		if (!this.ignoreUpdateViewSize && view) {
-
-			if (isResize) {
-				this.calcSize();
-				scroll = view.queryScroll();
-			}
-
-			this.ignoreUpdateViewSize++;
-
-			view.updateSize(
-				this.getSuggestedViewHeight(),
-				this.isHeightAuto(),
-				isResize
-			);
-
-			this.ignoreUpdateViewSize--;
-
-			if (isResize) {
-				view.applyScroll(scroll);
-			}
-
-			return true; // signal success
-		}
-	},
-
-
-	calcSize: function() {
-		if (this.elementVisible()) {
-			this._calcSize();
-		}
-	},
-
-
-	_calcSize: function() { // assumes elementVisible
-		var contentHeightInput = this.opt('contentHeight');
-		var heightInput = this.opt('height');
-
-		if (typeof contentHeightInput === 'number') { // exists and not 'auto'
-			this.suggestedViewHeight = contentHeightInput;
-		}
-		else if (typeof contentHeightInput === 'function') { // exists and is a function
-			this.suggestedViewHeight = contentHeightInput();
-		}
-		else if (typeof heightInput === 'number') { // exists and not 'auto'
-			this.suggestedViewHeight = heightInput - this.queryToolbarsHeight();
-		}
-		else if (typeof heightInput === 'function') { // exists and is a function
-			this.suggestedViewHeight = heightInput() - this.queryToolbarsHeight();
-		}
-		else if (heightInput === 'parent') { // set to height of parent element
-			this.suggestedViewHeight = this.el.parent().height() - this.queryToolbarsHeight();
-		}
-		else {
-			this.suggestedViewHeight = Math.round(
-				this.contentEl.width() /
-				Math.max(this.opt('aspectRatio'), .5)
-			);
-		}
-	},
-
-
-	windowResize: function(ev) {
-		if (
-			ev.target === window && // so we don't process jqui "resize" events that have bubbled up
-			this.view &&
-			this.view.isDatesRendered
-		) {
-			if (this.updateViewSize(true)) { // isResize=true, returns true on success
-				this.publiclyTrigger('windowResize', [ this.view ]);
-			}
-		}
-	},
-
-
-	/* Height "Freezing"
-	-----------------------------------------------------------------------------*/
-
-
-	freezeContentHeight: function() {
-		if (!(this.freezeContentHeightDepth++)) {
-			this.forceFreezeContentHeight();
-		}
-	},
-
-
-	forceFreezeContentHeight: function() {
-		this.contentEl.css({
-			width: '100%',
-			height: this.contentEl.height(),
-			overflow: 'hidden'
-		});
-	},
-
-
-	thawContentHeight: function() {
-		this.freezeContentHeightDepth--;
-
-		// always bring back to natural height
-		this.contentEl.css({
-			width: '',
-			height: '',
-			overflow: ''
-		});
-
-		// but if there are future thaws, re-freeze
-		if (this.freezeContentHeightDepth) {
-			this.forceFreezeContentHeight();
-		}
-	}
-
-});

+ 0 - 100
src/Calendar.toolbar.js

@@ -1,100 +0,0 @@
-
-Calendar.mixin({
-
-	header: null,
-	footer: null,
-	toolbarsManager: null,
-
-
-	initToolbars: function() {
-		this.header = new Toolbar(this, this.computeHeaderOptions());
-		this.footer = new Toolbar(this, this.computeFooterOptions());
-		this.toolbarsManager = new Iterator([ this.header, this.footer ]);
-	},
-
-
-	computeHeaderOptions: function() {
-		return {
-			extraClasses: 'fc-header-toolbar',
-			layout: this.opt('header')
-		};
-	},
-
-
-	computeFooterOptions: function() {
-		return {
-			extraClasses: 'fc-footer-toolbar',
-			layout: this.opt('footer')
-		};
-	},
-
-
-	// can be called repeatedly and Header will rerender
-	renderHeader: function() {
-		var header = this.header;
-
-		header.setToolbarOptions(this.computeHeaderOptions());
-		header.render();
-
-		if (header.el) {
-			this.el.prepend(header.el);
-		}
-	},
-
-
-	// can be called repeatedly and Footer will rerender
-	renderFooter: function() {
-		var footer = this.footer;
-
-		footer.setToolbarOptions(this.computeFooterOptions());
-		footer.render();
-
-		if (footer.el) {
-			this.el.append(footer.el);
-		}
-	},
-
-
-	setToolbarsTitle: function(title) {
-		this.toolbarsManager.proxyCall('updateTitle', title);
-	},
-
-
-	updateToolbarButtons: function(dateProfile) {
-		var now = this.getNow();
-		var view = this.view;
-		var todayInfo = view.dateProfileGenerator.build(now);
-		var prevInfo = view.dateProfileGenerator.buildPrev(view.get('dateProfile'));
-		var nextInfo = view.dateProfileGenerator.buildNext(view.get('dateProfile'));
-
-		this.toolbarsManager.proxyCall(
-			(todayInfo.isValid && !dateProfile.currentUnzonedRange.containsDate(now)) ?
-				'enableButton' :
-				'disableButton',
-			'today'
-		);
-
-		this.toolbarsManager.proxyCall(
-			prevInfo.isValid ?
-				'enableButton' :
-				'disableButton',
-			'prev'
-		);
-
-		this.toolbarsManager.proxyCall(
-			nextInfo.isValid ?
-				'enableButton' :
-				'disableButton',
-			'next'
-		);
-	},
-
-
-	queryToolbarsHeight: function() {
-		return this.toolbarsManager.items.reduce(function(accumulator, toolbar) {
-			var toolbarHeight = toolbar.el ? toolbar.el.outerHeight(true) : 0; // includes margin
-			return accumulator + toolbarHeight;
-		}, 0);
-	}
-
-});

+ 393 - 0
src/basic/DayGrid.js

@@ -29,6 +29,9 @@ var DayGrid = FC.DayGrid = InteractiveDateComponent.extend(StandardInteractionsM
 
 	hasAllDayBusinessHours: true,
 
+	segPopover: null, // the Popover that holds events that can't fit in a cell. null when not visible
+	popoverSegs: null, // an array of segment objects that the segPopover holds. null when not visible
+
 
 	constructor: function(view) {
 		this.view = view; // do first, for opt calls during initialization
@@ -390,6 +393,396 @@ var DayGrid = FC.DayGrid = InteractiveDateComponent.extend(StandardInteractionsM
 	unrenderEventResize: function(seg) {
 		this.unrenderHighlight();
 		this.helperRenderer.unrender();
+	},
+
+
+	/* More+ Link Popover
+	------------------------------------------------------------------------------------------------------------------*/
+
+
+	removeSegPopover: function() {
+		if (this.segPopover) {
+			this.segPopover.hide(); // in handler, will call segPopover's removeElement
+		}
+	},
+
+
+	// Limits the number of "levels" (vertically stacking layers of events) for each row of the grid.
+	// `levelLimit` can be false (don't limit), a number, or true (should be computed).
+	limitRows: function(levelLimit) {
+		var rowStructs = this.eventRenderer.rowStructs || [];
+		var row; // row #
+		var rowLevelLimit;
+
+		for (row = 0; row < rowStructs.length; row++) {
+			this.unlimitRow(row);
+
+			if (!levelLimit) {
+				rowLevelLimit = false;
+			}
+			else if (typeof levelLimit === 'number') {
+				rowLevelLimit = levelLimit;
+			}
+			else {
+				rowLevelLimit = this.computeRowLevelLimit(row);
+			}
+
+			if (rowLevelLimit !== false) {
+				this.limitRow(row, rowLevelLimit);
+			}
+		}
+	},
+
+
+	// Computes the number of levels a row will accomodate without going outside its bounds.
+	// Assumes the row is "rigid" (maintains a constant height regardless of what is inside).
+	// `row` is the row number.
+	computeRowLevelLimit: function(row) {
+		var rowEl = this.rowEls.eq(row); // the containing "fake" row div
+		var rowHeight = rowEl.height(); // TODO: cache somehow?
+		var trEls = this.eventRenderer.rowStructs[row].tbodyEl.children();
+		var i, trEl;
+		var trHeight;
+
+		function iterInnerHeights(i, childNode) {
+			trHeight = Math.max(trHeight, $(childNode).outerHeight());
+		}
+
+		// Reveal one level <tr> at a time and stop when we find one out of bounds
+		for (i = 0; i < trEls.length; i++) {
+			trEl = trEls.eq(i).removeClass('fc-limited'); // reset to original state (reveal)
+
+			// with rowspans>1 and IE8, trEl.outerHeight() would return the height of the largest cell,
+			// so instead, find the tallest inner content element.
+			trHeight = 0;
+			trEl.find('> td > :first-child').each(iterInnerHeights);
+
+			if (trEl.position().top + trHeight > rowHeight) {
+				return i;
+			}
+		}
+
+		return false; // should not limit at all
+	},
+
+
+	// Limits the given grid row to the maximum number of levels and injects "more" links if necessary.
+	// `row` is the row number.
+	// `levelLimit` is a number for the maximum (inclusive) number of levels allowed.
+	limitRow: function(row, levelLimit) {
+		var _this = this;
+		var rowStruct = this.eventRenderer.rowStructs[row];
+		var moreNodes = []; // array of "more" <a> links and <td> DOM nodes
+		var col = 0; // col #, left-to-right (not chronologically)
+		var levelSegs; // array of segment objects in the last allowable level, ordered left-to-right
+		var cellMatrix; // a matrix (by level, then column) of all <td> jQuery elements in the row
+		var limitedNodes; // array of temporarily hidden level <tr> and segment <td> DOM nodes
+		var i, seg;
+		var segsBelow; // array of segment objects below `seg` in the current `col`
+		var totalSegsBelow; // total number of segments below `seg` in any of the columns `seg` occupies
+		var colSegsBelow; // array of segment arrays, below seg, one for each column (offset from segs's first column)
+		var td, rowspan;
+		var segMoreNodes; // array of "more" <td> cells that will stand-in for the current seg's cell
+		var j;
+		var moreTd, moreWrap, moreLink;
+
+		// Iterates through empty level cells and places "more" links inside if need be
+		function emptyCellsUntil(endCol) { // goes from current `col` to `endCol`
+			while (col < endCol) {
+				segsBelow = _this.getCellSegs(row, col, levelLimit);
+				if (segsBelow.length) {
+					td = cellMatrix[levelLimit - 1][col];
+					moreLink = _this.renderMoreLink(row, col, segsBelow);
+					moreWrap = $('<div/>').append(moreLink);
+					td.append(moreWrap);
+					moreNodes.push(moreWrap[0]);
+				}
+				col++;
+			}
+		}
+
+		if (levelLimit && levelLimit < rowStruct.segLevels.length) { // is it actually over the limit?
+			levelSegs = rowStruct.segLevels[levelLimit - 1];
+			cellMatrix = rowStruct.cellMatrix;
+
+			limitedNodes = rowStruct.tbodyEl.children().slice(levelLimit) // get level <tr> elements past the limit
+				.addClass('fc-limited').get(); // hide elements and get a simple DOM-nodes array
+
+			// iterate though segments in the last allowable level
+			for (i = 0; i < levelSegs.length; i++) {
+				seg = levelSegs[i];
+				emptyCellsUntil(seg.leftCol); // process empty cells before the segment
+
+				// determine *all* segments below `seg` that occupy the same columns
+				colSegsBelow = [];
+				totalSegsBelow = 0;
+				while (col <= seg.rightCol) {
+					segsBelow = this.getCellSegs(row, col, levelLimit);
+					colSegsBelow.push(segsBelow);
+					totalSegsBelow += segsBelow.length;
+					col++;
+				}
+
+				if (totalSegsBelow) { // do we need to replace this segment with one or many "more" links?
+					td = cellMatrix[levelLimit - 1][seg.leftCol]; // the segment's parent cell
+					rowspan = td.attr('rowspan') || 1;
+					segMoreNodes = [];
+
+					// make a replacement <td> for each column the segment occupies. will be one for each colspan
+					for (j = 0; j < colSegsBelow.length; j++) {
+						moreTd = $('<td class="fc-more-cell"/>').attr('rowspan', rowspan);
+						segsBelow = colSegsBelow[j];
+						moreLink = this.renderMoreLink(
+							row,
+							seg.leftCol + j,
+							[ seg ].concat(segsBelow) // count seg as hidden too
+						);
+						moreWrap = $('<div/>').append(moreLink);
+						moreTd.append(moreWrap);
+						segMoreNodes.push(moreTd[0]);
+						moreNodes.push(moreTd[0]);
+					}
+
+					td.addClass('fc-limited').after($(segMoreNodes)); // hide original <td> and inject replacements
+					limitedNodes.push(td[0]);
+				}
+			}
+
+			emptyCellsUntil(this.colCnt); // finish off the level
+			rowStruct.moreEls = $(moreNodes); // for easy undoing later
+			rowStruct.limitedEls = $(limitedNodes); // for easy undoing later
+		}
+	},
+
+
+	// Reveals all levels and removes all "more"-related elements for a grid's row.
+	// `row` is a row number.
+	unlimitRow: function(row) {
+		var rowStruct = this.eventRenderer.rowStructs[row];
+
+		if (rowStruct.moreEls) {
+			rowStruct.moreEls.remove();
+			rowStruct.moreEls = null;
+		}
+
+		if (rowStruct.limitedEls) {
+			rowStruct.limitedEls.removeClass('fc-limited');
+			rowStruct.limitedEls = null;
+		}
+	},
+
+
+	// Renders an <a> element that represents hidden event element for a cell.
+	// Responsible for attaching click handler as well.
+	renderMoreLink: function(row, col, hiddenSegs) {
+		var _this = this;
+		var view = this.view;
+
+		return $('<a class="fc-more"/>')
+			.text(
+				this.getMoreLinkText(hiddenSegs.length)
+			)
+			.on('click', function(ev) {
+				var clickOption = _this.opt('eventLimitClick');
+				var date = _this.getCellDate(row, col);
+				var moreEl = $(this);
+				var dayEl = _this.getCellEl(row, col);
+				var allSegs = _this.getCellSegs(row, col);
+
+				// rescope the segments to be within the cell's date
+				var reslicedAllSegs = _this.resliceDaySegs(allSegs, date);
+				var reslicedHiddenSegs = _this.resliceDaySegs(hiddenSegs, date);
+
+				if (typeof clickOption === 'function') {
+					// the returned value can be an atomic option
+					clickOption = _this.publiclyTrigger('eventLimitClick', {
+						context: view,
+						args: [
+							{
+								date: date.clone(),
+								dayEl: dayEl,
+								moreEl: moreEl,
+								segs: reslicedAllSegs,
+								hiddenSegs: reslicedHiddenSegs
+							},
+							ev,
+							view
+						]
+					});
+				}
+
+				if (clickOption === 'popover') {
+					_this.showSegPopover(row, col, moreEl, reslicedAllSegs);
+				}
+				else if (typeof clickOption === 'string') { // a view name
+					view.calendar.zoomTo(date, clickOption);
+				}
+			});
+	},
+
+
+	// Reveals the popover that displays all events within a cell
+	showSegPopover: function(row, col, moreLink, segs) {
+		var _this = this;
+		var view = this.view;
+		var moreWrap = moreLink.parent(); // the <div> wrapper around the <a>
+		var topEl; // the element we want to match the top coordinate of
+		var options;
+
+		if (this.rowCnt == 1) {
+			topEl = view.el; // will cause the popover to cover any sort of header
+		}
+		else {
+			topEl = this.rowEls.eq(row); // will align with top of row
+		}
+
+		options = {
+			className: 'fc-more-popover ' + view.calendar.theme.getClass('popover'),
+			content: this.renderSegPopoverContent(row, col, segs),
+			parentEl: view.el, // attach to root of view. guarantees outside of scrollbars.
+			top: topEl.offset().top,
+			autoHide: true, // when the user clicks elsewhere, hide the popover
+			viewportConstrain: this.opt('popoverViewportConstrain'),
+			hide: function() {
+				// kill everything when the popover is hidden
+				// notify events to be removed
+				if (_this.popoverSegs) {
+					_this.triggerBeforeEventSegsDestroyed(_this.popoverSegs);
+				}
+				_this.segPopover.removeElement();
+				_this.segPopover = null;
+				_this.popoverSegs = null;
+			}
+		};
+
+		// Determine horizontal coordinate.
+		// We use the moreWrap instead of the <td> to avoid border confusion.
+		if (this.isRTL) {
+			options.right = moreWrap.offset().left + moreWrap.outerWidth() + 1; // +1 to be over cell border
+		}
+		else {
+			options.left = moreWrap.offset().left - 1; // -1 to be over cell border
+		}
+
+		this.segPopover = new Popover(options);
+		this.segPopover.show();
+
+		// the popover doesn't live within the grid's container element, and thus won't get the event
+		// delegated-handlers for free. attach event-related handlers to the popover.
+		this.bindAllSegHandlersToEl(this.segPopover.el);
+
+		this.triggerAfterEventSegsRendered(segs);
+	},
+
+
+	// Builds the inner DOM contents of the segment popover
+	renderSegPopoverContent: function(row, col, segs) {
+		var view = this.view;
+		var theme = view.calendar.theme;
+		var title = this.getCellDate(row, col).format(this.opt('dayPopoverFormat'));
+		var content = $(
+			'<div class="fc-header ' + theme.getClass('popoverHeader') + '">' +
+				'<span class="fc-close ' + theme.getIconClass('close') + '"></span>' +
+				'<span class="fc-title">' +
+					htmlEscape(title) +
+				'</span>' +
+				'<div class="fc-clear"/>' +
+			'</div>' +
+			'<div class="fc-body ' + theme.getClass('popoverContent') + '">' +
+				'<div class="fc-event-container"></div>' +
+			'</div>'
+		);
+		var segContainer = content.find('.fc-event-container');
+		var i;
+
+		// render each seg's `el` and only return the visible segs
+		segs = this.eventRenderer.renderFgSegEls(segs, true); // disableResizing=true
+		this.popoverSegs = segs;
+
+		for (i = 0; i < segs.length; i++) {
+
+			// because segments in the popover are not part of a grid coordinate system, provide a hint to any
+			// grids that want to do drag-n-drop about which cell it came from
+			this.hitsNeeded();
+			segs[i].hit = this.getCellHit(row, col);
+			this.hitsNotNeeded();
+
+			segContainer.append(segs[i].el);
+		}
+
+		return content;
+	},
+
+
+	// Given the events within an array of segment objects, reslice them to be in a single day
+	resliceDaySegs: function(segs, dayDate) {
+		var dayStart = dayDate.clone();
+		var dayEnd = dayStart.clone().add(1, 'days');
+		var dayRange = new UnzonedRange(dayStart, dayEnd);
+		var newSegs = [];
+		var i, seg;
+		var slicedRange;
+
+		for (i = 0; i < segs.length; i++) {
+			seg = segs[i];
+			slicedRange = seg.footprint.componentFootprint.unzonedRange.intersect(dayRange);
+
+			if (slicedRange) {
+				newSegs.push(
+					$.extend({}, seg, {
+						footprint: new EventFootprint(
+							new ComponentFootprint(
+								slicedRange,
+								seg.footprint.componentFootprint.isAllDay
+							),
+							seg.footprint.eventDef,
+							seg.footprint.eventInstance
+						),
+						isStart: seg.isStart && slicedRange.isStart,
+						isEnd: seg.isEnd && slicedRange.isEnd
+					})
+				);
+			}
+		}
+
+		// force an order because eventsToSegs doesn't guarantee one
+		// TODO: research if still needed
+		this.eventRenderer.sortEventSegs(newSegs);
+
+		return newSegs;
+	},
+
+
+	// Generates the text that should be inside a "more" link, given the number of events it represents
+	getMoreLinkText: function(num) {
+		var opt = this.opt('eventLimitText');
+
+		if (typeof opt === 'function') {
+			return opt(num);
+		}
+		else {
+			return '+' + num + ' ' + opt;
+		}
+	},
+
+
+	// Returns segments within a given cell.
+	// If `startLevel` is specified, returns only events including and below that level. Otherwise returns all segs.
+	getCellSegs: function(row, col, startLevel) {
+		var segMatrix = this.eventRenderer.rowStructs[row].segMatrix;
+		var level = startLevel || 0;
+		var segs = [];
+		var seg;
+
+		while (level < segMatrix.length) {
+			seg = segMatrix[level][col];
+			if (seg) {
+				segs.push(seg);
+			}
+			level++;
+		}
+
+		return segs;
 	}
 
 });

+ 0 - 397
src/basic/DayGrid.limit.js

@@ -1,397 +0,0 @@
-
-/* Methods relate to limiting the number events for a given day on a DayGrid
-----------------------------------------------------------------------------------------------------------------------*/
-// NOTE: all the segs being passed around in here are foreground segs
-
-DayGrid.mixin({
-
-	segPopover: null, // the Popover that holds events that can't fit in a cell. null when not visible
-	popoverSegs: null, // an array of segment objects that the segPopover holds. null when not visible
-
-
-	removeSegPopover: function() {
-		if (this.segPopover) {
-			this.segPopover.hide(); // in handler, will call segPopover's removeElement
-		}
-	},
-
-
-	// Limits the number of "levels" (vertically stacking layers of events) for each row of the grid.
-	// `levelLimit` can be false (don't limit), a number, or true (should be computed).
-	limitRows: function(levelLimit) {
-		var rowStructs = this.eventRenderer.rowStructs || [];
-		var row; // row #
-		var rowLevelLimit;
-
-		for (row = 0; row < rowStructs.length; row++) {
-			this.unlimitRow(row);
-
-			if (!levelLimit) {
-				rowLevelLimit = false;
-			}
-			else if (typeof levelLimit === 'number') {
-				rowLevelLimit = levelLimit;
-			}
-			else {
-				rowLevelLimit = this.computeRowLevelLimit(row);
-			}
-
-			if (rowLevelLimit !== false) {
-				this.limitRow(row, rowLevelLimit);
-			}
-		}
-	},
-
-
-	// Computes the number of levels a row will accomodate without going outside its bounds.
-	// Assumes the row is "rigid" (maintains a constant height regardless of what is inside).
-	// `row` is the row number.
-	computeRowLevelLimit: function(row) {
-		var rowEl = this.rowEls.eq(row); // the containing "fake" row div
-		var rowHeight = rowEl.height(); // TODO: cache somehow?
-		var trEls = this.eventRenderer.rowStructs[row].tbodyEl.children();
-		var i, trEl;
-		var trHeight;
-
-		function iterInnerHeights(i, childNode) {
-			trHeight = Math.max(trHeight, $(childNode).outerHeight());
-		}
-
-		// Reveal one level <tr> at a time and stop when we find one out of bounds
-		for (i = 0; i < trEls.length; i++) {
-			trEl = trEls.eq(i).removeClass('fc-limited'); // reset to original state (reveal)
-
-			// with rowspans>1 and IE8, trEl.outerHeight() would return the height of the largest cell,
-			// so instead, find the tallest inner content element.
-			trHeight = 0;
-			trEl.find('> td > :first-child').each(iterInnerHeights);
-
-			if (trEl.position().top + trHeight > rowHeight) {
-				return i;
-			}
-		}
-
-		return false; // should not limit at all
-	},
-
-
-	// Limits the given grid row to the maximum number of levels and injects "more" links if necessary.
-	// `row` is the row number.
-	// `levelLimit` is a number for the maximum (inclusive) number of levels allowed.
-	limitRow: function(row, levelLimit) {
-		var _this = this;
-		var rowStruct = this.eventRenderer.rowStructs[row];
-		var moreNodes = []; // array of "more" <a> links and <td> DOM nodes
-		var col = 0; // col #, left-to-right (not chronologically)
-		var levelSegs; // array of segment objects in the last allowable level, ordered left-to-right
-		var cellMatrix; // a matrix (by level, then column) of all <td> jQuery elements in the row
-		var limitedNodes; // array of temporarily hidden level <tr> and segment <td> DOM nodes
-		var i, seg;
-		var segsBelow; // array of segment objects below `seg` in the current `col`
-		var totalSegsBelow; // total number of segments below `seg` in any of the columns `seg` occupies
-		var colSegsBelow; // array of segment arrays, below seg, one for each column (offset from segs's first column)
-		var td, rowspan;
-		var segMoreNodes; // array of "more" <td> cells that will stand-in for the current seg's cell
-		var j;
-		var moreTd, moreWrap, moreLink;
-
-		// Iterates through empty level cells and places "more" links inside if need be
-		function emptyCellsUntil(endCol) { // goes from current `col` to `endCol`
-			while (col < endCol) {
-				segsBelow = _this.getCellSegs(row, col, levelLimit);
-				if (segsBelow.length) {
-					td = cellMatrix[levelLimit - 1][col];
-					moreLink = _this.renderMoreLink(row, col, segsBelow);
-					moreWrap = $('<div/>').append(moreLink);
-					td.append(moreWrap);
-					moreNodes.push(moreWrap[0]);
-				}
-				col++;
-			}
-		}
-
-		if (levelLimit && levelLimit < rowStruct.segLevels.length) { // is it actually over the limit?
-			levelSegs = rowStruct.segLevels[levelLimit - 1];
-			cellMatrix = rowStruct.cellMatrix;
-
-			limitedNodes = rowStruct.tbodyEl.children().slice(levelLimit) // get level <tr> elements past the limit
-				.addClass('fc-limited').get(); // hide elements and get a simple DOM-nodes array
-
-			// iterate though segments in the last allowable level
-			for (i = 0; i < levelSegs.length; i++) {
-				seg = levelSegs[i];
-				emptyCellsUntil(seg.leftCol); // process empty cells before the segment
-
-				// determine *all* segments below `seg` that occupy the same columns
-				colSegsBelow = [];
-				totalSegsBelow = 0;
-				while (col <= seg.rightCol) {
-					segsBelow = this.getCellSegs(row, col, levelLimit);
-					colSegsBelow.push(segsBelow);
-					totalSegsBelow += segsBelow.length;
-					col++;
-				}
-
-				if (totalSegsBelow) { // do we need to replace this segment with one or many "more" links?
-					td = cellMatrix[levelLimit - 1][seg.leftCol]; // the segment's parent cell
-					rowspan = td.attr('rowspan') || 1;
-					segMoreNodes = [];
-
-					// make a replacement <td> for each column the segment occupies. will be one for each colspan
-					for (j = 0; j < colSegsBelow.length; j++) {
-						moreTd = $('<td class="fc-more-cell"/>').attr('rowspan', rowspan);
-						segsBelow = colSegsBelow[j];
-						moreLink = this.renderMoreLink(
-							row,
-							seg.leftCol + j,
-							[ seg ].concat(segsBelow) // count seg as hidden too
-						);
-						moreWrap = $('<div/>').append(moreLink);
-						moreTd.append(moreWrap);
-						segMoreNodes.push(moreTd[0]);
-						moreNodes.push(moreTd[0]);
-					}
-
-					td.addClass('fc-limited').after($(segMoreNodes)); // hide original <td> and inject replacements
-					limitedNodes.push(td[0]);
-				}
-			}
-
-			emptyCellsUntil(this.colCnt); // finish off the level
-			rowStruct.moreEls = $(moreNodes); // for easy undoing later
-			rowStruct.limitedEls = $(limitedNodes); // for easy undoing later
-		}
-	},
-
-
-	// Reveals all levels and removes all "more"-related elements for a grid's row.
-	// `row` is a row number.
-	unlimitRow: function(row) {
-		var rowStruct = this.eventRenderer.rowStructs[row];
-
-		if (rowStruct.moreEls) {
-			rowStruct.moreEls.remove();
-			rowStruct.moreEls = null;
-		}
-
-		if (rowStruct.limitedEls) {
-			rowStruct.limitedEls.removeClass('fc-limited');
-			rowStruct.limitedEls = null;
-		}
-	},
-
-
-	// Renders an <a> element that represents hidden event element for a cell.
-	// Responsible for attaching click handler as well.
-	renderMoreLink: function(row, col, hiddenSegs) {
-		var _this = this;
-		var view = this.view;
-
-		return $('<a class="fc-more"/>')
-			.text(
-				this.getMoreLinkText(hiddenSegs.length)
-			)
-			.on('click', function(ev) {
-				var clickOption = _this.opt('eventLimitClick');
-				var date = _this.getCellDate(row, col);
-				var moreEl = $(this);
-				var dayEl = _this.getCellEl(row, col);
-				var allSegs = _this.getCellSegs(row, col);
-
-				// rescope the segments to be within the cell's date
-				var reslicedAllSegs = _this.resliceDaySegs(allSegs, date);
-				var reslicedHiddenSegs = _this.resliceDaySegs(hiddenSegs, date);
-
-				if (typeof clickOption === 'function') {
-					// the returned value can be an atomic option
-					clickOption = _this.publiclyTrigger('eventLimitClick', {
-						context: view,
-						args: [
-							{
-								date: date.clone(),
-								dayEl: dayEl,
-								moreEl: moreEl,
-								segs: reslicedAllSegs,
-								hiddenSegs: reslicedHiddenSegs
-							},
-							ev,
-							view
-						]
-					});
-				}
-
-				if (clickOption === 'popover') {
-					_this.showSegPopover(row, col, moreEl, reslicedAllSegs);
-				}
-				else if (typeof clickOption === 'string') { // a view name
-					view.calendar.zoomTo(date, clickOption);
-				}
-			});
-	},
-
-
-	// Reveals the popover that displays all events within a cell
-	showSegPopover: function(row, col, moreLink, segs) {
-		var _this = this;
-		var view = this.view;
-		var moreWrap = moreLink.parent(); // the <div> wrapper around the <a>
-		var topEl; // the element we want to match the top coordinate of
-		var options;
-
-		if (this.rowCnt == 1) {
-			topEl = view.el; // will cause the popover to cover any sort of header
-		}
-		else {
-			topEl = this.rowEls.eq(row); // will align with top of row
-		}
-
-		options = {
-			className: 'fc-more-popover ' + view.calendar.theme.getClass('popover'),
-			content: this.renderSegPopoverContent(row, col, segs),
-			parentEl: view.el, // attach to root of view. guarantees outside of scrollbars.
-			top: topEl.offset().top,
-			autoHide: true, // when the user clicks elsewhere, hide the popover
-			viewportConstrain: this.opt('popoverViewportConstrain'),
-			hide: function() {
-				// kill everything when the popover is hidden
-				// notify events to be removed
-				if (_this.popoverSegs) {
-					_this.triggerBeforeEventSegsDestroyed(_this.popoverSegs);
-				}
-				_this.segPopover.removeElement();
-				_this.segPopover = null;
-				_this.popoverSegs = null;
-			}
-		};
-
-		// Determine horizontal coordinate.
-		// We use the moreWrap instead of the <td> to avoid border confusion.
-		if (this.isRTL) {
-			options.right = moreWrap.offset().left + moreWrap.outerWidth() + 1; // +1 to be over cell border
-		}
-		else {
-			options.left = moreWrap.offset().left - 1; // -1 to be over cell border
-		}
-
-		this.segPopover = new Popover(options);
-		this.segPopover.show();
-
-		// the popover doesn't live within the grid's container element, and thus won't get the event
-		// delegated-handlers for free. attach event-related handlers to the popover.
-		this.bindAllSegHandlersToEl(this.segPopover.el);
-
-		this.triggerAfterEventSegsRendered(segs);
-	},
-
-
-	// Builds the inner DOM contents of the segment popover
-	renderSegPopoverContent: function(row, col, segs) {
-		var view = this.view;
-		var theme = view.calendar.theme;
-		var title = this.getCellDate(row, col).format(this.opt('dayPopoverFormat'));
-		var content = $(
-			'<div class="fc-header ' + theme.getClass('popoverHeader') + '">' +
-				'<span class="fc-close ' + theme.getIconClass('close') + '"></span>' +
-				'<span class="fc-title">' +
-					htmlEscape(title) +
-				'</span>' +
-				'<div class="fc-clear"/>' +
-			'</div>' +
-			'<div class="fc-body ' + theme.getClass('popoverContent') + '">' +
-				'<div class="fc-event-container"></div>' +
-			'</div>'
-		);
-		var segContainer = content.find('.fc-event-container');
-		var i;
-
-		// render each seg's `el` and only return the visible segs
-		segs = this.eventRenderer.renderFgSegEls(segs, true); // disableResizing=true
-		this.popoverSegs = segs;
-
-		for (i = 0; i < segs.length; i++) {
-
-			// because segments in the popover are not part of a grid coordinate system, provide a hint to any
-			// grids that want to do drag-n-drop about which cell it came from
-			this.hitsNeeded();
-			segs[i].hit = this.getCellHit(row, col);
-			this.hitsNotNeeded();
-
-			segContainer.append(segs[i].el);
-		}
-
-		return content;
-	},
-
-
-	// Given the events within an array of segment objects, reslice them to be in a single day
-	resliceDaySegs: function(segs, dayDate) {
-		var dayStart = dayDate.clone();
-		var dayEnd = dayStart.clone().add(1, 'days');
-		var dayRange = new UnzonedRange(dayStart, dayEnd);
-		var newSegs = [];
-		var i, seg;
-		var slicedRange;
-
-		for (i = 0; i < segs.length; i++) {
-			seg = segs[i];
-			slicedRange = seg.footprint.componentFootprint.unzonedRange.intersect(dayRange);
-
-			if (slicedRange) {
-				newSegs.push(
-					$.extend({}, seg, {
-						footprint: new EventFootprint(
-							new ComponentFootprint(
-								slicedRange,
-								seg.footprint.componentFootprint.isAllDay
-							),
-							seg.footprint.eventDef,
-							seg.footprint.eventInstance
-						),
-						isStart: seg.isStart && slicedRange.isStart,
-						isEnd: seg.isEnd && slicedRange.isEnd
-					})
-				);
-			}
-		}
-
-		// force an order because eventsToSegs doesn't guarantee one
-		// TODO: research if still needed
-		this.eventRenderer.sortEventSegs(newSegs);
-
-		return newSegs;
-	},
-
-
-	// Generates the text that should be inside a "more" link, given the number of events it represents
-	getMoreLinkText: function(num) {
-		var opt = this.opt('eventLimitText');
-
-		if (typeof opt === 'function') {
-			return opt(num);
-		}
-		else {
-			return '+' + num + ' ' + opt;
-		}
-	},
-
-
-	// Returns segments within a given cell.
-	// If `startLevel` is specified, returns only events including and below that level. Otherwise returns all segs.
-	getCellSegs: function(row, col, startLevel) {
-		var segMatrix = this.eventRenderer.rowStructs[row].segMatrix;
-		var level = startLevel || 0;
-		var segs = [];
-		var seg;
-
-		while (level < segMatrix.length) {
-			seg = segMatrix[level][col];
-			if (seg) {
-				segs.push(seg);
-			}
-			level++;
-		}
-
-		return segs;
-	}
-
-});