Passed
Push — master ( e95921...ddbd1b )
by Dimas
13:28 queued 05:15
created

assets/mdb-dashboard/js/modules/fullcalendar-3.4.0/fullcalendar.js   F

Complexity

Total Complexity 2161
Complexity/F 2.01

Size

Lines of Code 15585
Function Count 1074

Duplication

Duplicated Lines 125
Ratio 0.8 %

Importance

Changes 0
Metric Value
eloc 7498
dl 125
loc 15585
rs 0.8
c 0
b 0
f 0
wmc 2161
mnd 1087
bc 1087
fnc 1074
bpm 1.0121
cpm 2.0121
noi 72

How to fix   Duplicated Code    Complexity   

Duplicated Code

Duplicate code is one of the most pungent code smells. A rule that is often used is to re-structure code once it is duplicated in three or more places.

Common duplication problems, and corresponding solutions are:

Complexity

 Tip:   Before tackling complexity, make sure that you eliminate any duplication first. This often can reduce the size of classes significantly.

Complex classes like assets/mdb-dashboard/js/modules/fullcalendar-3.4.0/fullcalendar.js often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

1
/*!
2
 * FullCalendar v3.4.0
3
 * Docs & License: https://fullcalendar.io/
4
 * (c) 2017 Adam Shaw
5
 */
6
7
(function(factory) {
8
	if (typeof define === 'function' && define.amd) {
9
		define([ 'jquery', 'moment' ], factory);
10
	}
11
	else if (typeof exports === 'object') { // Node/CommonJS
12
		module.exports = factory(require('jquery'), require('moment'));
13
	}
14
	else {
15
		factory(jQuery, moment);
16
	}
17
})(function($, moment) {
18
19
;;
20
21
var FC = $.fullCalendar = {
22
	version: "3.4.0",
23
	// When introducing internal API incompatibilities (where fullcalendar plugins would break),
24
	// the minor version of the calendar should be upped (ex: 2.7.2 -> 2.8.0)
25
	// and the below integer should be incremented.
26
	internalApiVersion: 9
27
};
28
var fcViews = FC.views = {};
29
30
31
$.fn.fullCalendar = function(options) {
32
	var args = Array.prototype.slice.call(arguments, 1); // for a possible method call
33
	var res = this; // what this function will return (this jQuery object by default)
34
35
	this.each(function(i, _element) { // loop each DOM element involved
36
		var element = $(_element);
37
		var calendar = element.data('fullCalendar'); // get the existing calendar object (if any)
38
		var singleRes; // the returned value of this single method call
39
40
		// a method call
41
		if (typeof options === 'string') {
42
			if (calendar && $.isFunction(calendar[options])) {
43
				singleRes = calendar[options].apply(calendar, args);
44
				if (!i) {
45
					res = singleRes; // record the first method call result
46
				}
47
				if (options === 'destroy') { // for the destroy method, must remove Calendar object data
48
					element.removeData('fullCalendar');
49
				}
50
			}
51
		}
52
		// a new calendar initialization
53
		else if (!calendar) { // don't initialize twice
54
			calendar = new Calendar(element, options);
55
			element.data('fullCalendar', calendar);
56
			calendar.render();
57
		}
58
	});
59
60
	return res;
61
};
62
63
64
var complexOptions = [ // names of options that are objects whose properties should be combined
65
	'header',
66
	'footer',
67
	'buttonText',
68
	'buttonIcons',
69
	'themeButtonIcons'
70
];
71
72
73
// Merges an array of option objects into a single object
74
function mergeOptions(optionObjs) {
75
	return mergeProps(optionObjs, complexOptions);
76
}
77
78
;;
79
80
// exports
81
FC.intersectRanges = intersectRanges;
82
FC.applyAll = applyAll;
83
FC.debounce = debounce;
84
FC.isInt = isInt;
85
FC.htmlEscape = htmlEscape;
86
FC.cssToStr = cssToStr;
87
FC.proxy = proxy;
88
FC.capitaliseFirstLetter = capitaliseFirstLetter;
89
90
91
/* FullCalendar-specific DOM Utilities
92
----------------------------------------------------------------------------------------------------------------------*/
93
94
95
// Given the scrollbar widths of some other container, create borders/margins on rowEls in order to match the left
96
// and right space that was offset by the scrollbars. A 1-pixel border first, then margin beyond that.
97
function compensateScroll(rowEls, scrollbarWidths) {
98
	if (scrollbarWidths.left) {
99
		rowEls.css({
100
			'border-left-width': 1,
101
			'margin-left': scrollbarWidths.left - 1
102
		});
103
	}
104
	if (scrollbarWidths.right) {
105
		rowEls.css({
106
			'border-right-width': 1,
107
			'margin-right': scrollbarWidths.right - 1
108
		});
109
	}
110
}
111
112
113
// Undoes compensateScroll and restores all borders/margins
114
function uncompensateScroll(rowEls) {
115
	rowEls.css({
116
		'margin-left': '',
117
		'margin-right': '',
118
		'border-left-width': '',
119
		'border-right-width': ''
120
	});
121
}
122
123
124
// Make the mouse cursor express that an event is not allowed in the current area
125
function disableCursor() {
126
	$('body').addClass('fc-not-allowed');
127
}
128
129
130
// Returns the mouse cursor to its original look
131
function enableCursor() {
132
	$('body').removeClass('fc-not-allowed');
133
}
134
135
136
// Given a total available height to fill, have `els` (essentially child rows) expand to accomodate.
137
// By default, all elements that are shorter than the recommended height are expanded uniformly, not considering
138
// any other els that are already too tall. if `shouldRedistribute` is on, it considers these tall rows and 
139
// reduces the available height.
140
function distributeHeight(els, availableHeight, shouldRedistribute) {
141
142
	// *FLOORING NOTE*: we floor in certain places because zoom can give inaccurate floating-point dimensions,
143
	// and it is better to be shorter than taller, to avoid creating unnecessary scrollbars.
144
145
	var minOffset1 = Math.floor(availableHeight / els.length); // for non-last element
146
	var minOffset2 = Math.floor(availableHeight - minOffset1 * (els.length - 1)); // for last element *FLOORING NOTE*
147
	var flexEls = []; // elements that are allowed to expand. array of DOM nodes
148
	var flexOffsets = []; // amount of vertical space it takes up
149
	var flexHeights = []; // actual css height
150
	var usedHeight = 0;
151
152
	undistributeHeight(els); // give all elements their natural height
153
154
	// find elements that are below the recommended height (expandable).
155
	// important to query for heights in a single first pass (to avoid reflow oscillation).
156
	els.each(function(i, el) {
157
		var minOffset = i === els.length - 1 ? minOffset2 : minOffset1;
158
		var naturalOffset = $(el).outerHeight(true);
159
160
		if (naturalOffset < minOffset) {
161
			flexEls.push(el);
162
			flexOffsets.push(naturalOffset);
163
			flexHeights.push($(el).height());
164
		}
165
		else {
166
			// this element stretches past recommended height (non-expandable). mark the space as occupied.
167
			usedHeight += naturalOffset;
168
		}
169
	});
170
171
	// readjust the recommended height to only consider the height available to non-maxed-out rows.
172
	if (shouldRedistribute) {
173
		availableHeight -= usedHeight;
174
		minOffset1 = Math.floor(availableHeight / flexEls.length);
175
		minOffset2 = Math.floor(availableHeight - minOffset1 * (flexEls.length - 1)); // *FLOORING NOTE*
176
	}
177
178
	// assign heights to all expandable elements
179
	$(flexEls).each(function(i, el) {
180
		var minOffset = i === flexEls.length - 1 ? minOffset2 : minOffset1;
181
		var naturalOffset = flexOffsets[i];
182
		var naturalHeight = flexHeights[i];
183
		var newHeight = minOffset - (naturalOffset - naturalHeight); // subtract the margin/padding
184
185
		if (naturalOffset < minOffset) { // we check this again because redistribution might have changed things
186
			$(el).height(newHeight);
187
		}
188
	});
189
}
190
191
192
// Undoes distrubuteHeight, restoring all els to their natural height
193
function undistributeHeight(els) {
194
	els.height('');
195
}
196
197
198
// Given `els`, a jQuery set of <td> cells, find the cell with the largest natural width and set the widths of all the
199
// cells to be that width.
200
// PREREQUISITE: if you want a cell to take up width, it needs to have a single inner element w/ display:inline
201
function matchCellWidths(els) {
202
	var maxInnerWidth = 0;
203
204
	els.find('> *').each(function(i, innerEl) {
205
		var innerWidth = $(innerEl).outerWidth();
206
		if (innerWidth > maxInnerWidth) {
207
			maxInnerWidth = innerWidth;
208
		}
209
	});
210
211
	maxInnerWidth++; // sometimes not accurate of width the text needs to stay on one line. insurance
212
213
	els.width(maxInnerWidth);
214
215
	return maxInnerWidth;
216
}
217
218
219
// Given one element that resides inside another,
220
// Subtracts the height of the inner element from the outer element.
221
function subtractInnerElHeight(outerEl, innerEl) {
222
	var both = outerEl.add(innerEl);
223
	var diff;
224
225
	// effin' IE8/9/10/11 sometimes returns 0 for dimensions. this weird hack was the only thing that worked
226
	both.css({
227
		position: 'relative', // cause a reflow, which will force fresh dimension recalculation
228
		left: -1 // ensure reflow in case the el was already relative. negative is less likely to cause new scroll
229
	});
230
	diff = outerEl.outerHeight() - innerEl.outerHeight(); // grab the dimensions
231
	both.css({ position: '', left: '' }); // undo hack
232
233
	return diff;
234
}
235
236
237
/* Element Geom Utilities
238
----------------------------------------------------------------------------------------------------------------------*/
239
240
FC.getOuterRect = getOuterRect;
241
FC.getClientRect = getClientRect;
242
FC.getContentRect = getContentRect;
243
FC.getScrollbarWidths = getScrollbarWidths;
244
245
246
// borrowed from https://github.com/jquery/jquery-ui/blob/1.11.0/ui/core.js#L51
247
function getScrollParent(el) {
248
	var position = el.css('position'),
249
		scrollParent = el.parents().filter(function() {
250
			var parent = $(this);
251
			return (/(auto|scroll)/).test(
252
				parent.css('overflow') + parent.css('overflow-y') + parent.css('overflow-x')
253
			);
254
		}).eq(0);
255
256
	return position === 'fixed' || !scrollParent.length ? $(el[0].ownerDocument || document) : scrollParent;
257
}
258
259
260
// Queries the outer bounding area of a jQuery element.
261
// Returns a rectangle with absolute coordinates: left, right (exclusive), top, bottom (exclusive).
262
// Origin is optional.
263
function getOuterRect(el, origin) {
264
	var offset = el.offset();
265
	var left = offset.left - (origin ? origin.left : 0);
266
	var top = offset.top - (origin ? origin.top : 0);
267
268
	return {
269
		left: left,
270
		right: left + el.outerWidth(),
271
		top: top,
272
		bottom: top + el.outerHeight()
273
	};
274
}
275
276
277
// Queries the area within the margin/border/scrollbars of a jQuery element. Does not go within the padding.
278
// Returns a rectangle with absolute coordinates: left, right (exclusive), top, bottom (exclusive).
279
// Origin is optional.
280
// WARNING: given element can't have borders
281
// NOTE: should use clientLeft/clientTop, but very unreliable cross-browser.
282
function getClientRect(el, origin) {
283
	var offset = el.offset();
284
	var scrollbarWidths = getScrollbarWidths(el);
285
	var left = offset.left + getCssFloat(el, 'border-left-width') + scrollbarWidths.left - (origin ? origin.left : 0);
286
	var top = offset.top + getCssFloat(el, 'border-top-width') + scrollbarWidths.top - (origin ? origin.top : 0);
287
288
	return {
289
		left: left,
290
		right: left + el[0].clientWidth, // clientWidth includes padding but NOT scrollbars
291
		top: top,
292
		bottom: top + el[0].clientHeight // clientHeight includes padding but NOT scrollbars
293
	};
294
}
295
296
297
// Queries the area within the margin/border/padding of a jQuery element. Assumed not to have scrollbars.
298
// Returns a rectangle with absolute coordinates: left, right (exclusive), top, bottom (exclusive).
299
// Origin is optional.
300
function getContentRect(el, origin) {
301
	var offset = el.offset(); // just outside of border, margin not included
302
	var left = offset.left + getCssFloat(el, 'border-left-width') + getCssFloat(el, 'padding-left') -
303
		(origin ? origin.left : 0);
304
	var top = offset.top + getCssFloat(el, 'border-top-width') + getCssFloat(el, 'padding-top') -
305
		(origin ? origin.top : 0);
306
307
	return {
308
		left: left,
309
		right: left + el.width(),
310
		top: top,
311
		bottom: top + el.height()
312
	};
313
}
314
315
316
// Returns the computed left/right/top/bottom scrollbar widths for the given jQuery element.
317
// WARNING: given element can't have borders (which will cause offsetWidth/offsetHeight to be larger).
318
// NOTE: should use clientLeft/clientTop, but very unreliable cross-browser.
319
function getScrollbarWidths(el) {
320
	var leftRightWidth = el[0].offsetWidth - el[0].clientWidth;
321
	var bottomWidth = el[0].offsetHeight - el[0].clientHeight;
322
	var widths;
323
324
	leftRightWidth = sanitizeScrollbarWidth(leftRightWidth);
325
	bottomWidth = sanitizeScrollbarWidth(bottomWidth);
326
327
	widths = { left: 0, right: 0, top: 0, bottom: bottomWidth };
328
329
	if (getIsLeftRtlScrollbars() && el.css('direction') == 'rtl') { // is the scrollbar on the left side?
330
		widths.left = leftRightWidth;
331
	}
332
	else {
333
		widths.right = leftRightWidth;
334
	}
335
336
	return widths;
337
}
338
339
340
// The scrollbar width computations in getScrollbarWidths are sometimes flawed when it comes to
341
// retina displays, rounding, and IE11. Massage them into a usable value.
342
function sanitizeScrollbarWidth(width) {
343
	width = Math.max(0, width); // no negatives
344
	width = Math.round(width);
345
	return width;
346
}
347
348
349
// Logic for determining if, when the element is right-to-left, the scrollbar appears on the left side
350
351
var _isLeftRtlScrollbars = null;
352
353
function getIsLeftRtlScrollbars() { // responsible for caching the computation
354
	if (_isLeftRtlScrollbars === null) {
355
		_isLeftRtlScrollbars = computeIsLeftRtlScrollbars();
356
	}
357
	return _isLeftRtlScrollbars;
358
}
359
360
function computeIsLeftRtlScrollbars() { // creates an offscreen test element, then removes it
361
	var el = $('<div><div/></div>')
362
		.css({
363
			position: 'absolute',
364
			top: -1000,
365
			left: 0,
366
			border: 0,
367
			padding: 0,
368
			overflow: 'scroll',
369
			direction: 'rtl'
370
		})
371
		.appendTo('body');
372
	var innerEl = el.children();
373
	var res = innerEl.offset().left > el.offset().left; // is the inner div shifted to accommodate a left scrollbar?
374
	el.remove();
375
	return res;
376
}
377
378
379
// Retrieves a jQuery element's computed CSS value as a floating-point number.
380
// If the queried value is non-numeric (ex: IE can return "medium" for border width), will just return zero.
381
function getCssFloat(el, prop) {
382
	return parseFloat(el.css(prop)) || 0;
383
}
384
385
386
/* Mouse / Touch Utilities
387
----------------------------------------------------------------------------------------------------------------------*/
388
389
FC.preventDefault = preventDefault;
390
391
392
// Returns a boolean whether this was a left mouse click and no ctrl key (which means right click on Mac)
393
function isPrimaryMouseButton(ev) {
394
	return ev.which == 1 && !ev.ctrlKey;
395
}
396
397
398
function getEvX(ev) {
399
	var touches = ev.originalEvent.touches;
400
401
	// on mobile FF, pageX for touch events is present, but incorrect,
402
	// so, look at touch coordinates first.
403
	if (touches && touches.length) {
404
		return touches[0].pageX;
405
	}
406
407
	return ev.pageX;
408
}
409
410
411
function getEvY(ev) {
412
	var touches = ev.originalEvent.touches;
413
414
	// on mobile FF, pageX for touch events is present, but incorrect,
415
	// so, look at touch coordinates first.
416
	if (touches && touches.length) {
417
		return touches[0].pageY;
418
	}
419
420
	return ev.pageY;
421
}
422
423
424
function getEvIsTouch(ev) {
425
	return /^touch/.test(ev.type);
426
}
427
428
429
function preventSelection(el) {
430
	el.addClass('fc-unselectable')
431
		.on('selectstart', preventDefault);
432
}
433
434
435
function allowSelection(el) {
436
	el.removeClass('fc-unselectable')
437
		.off('selectstart', preventDefault);
438
}
439
440
441
// Stops a mouse/touch event from doing it's native browser action
442
function preventDefault(ev) {
443
	ev.preventDefault();
444
}
445
446
447
/* General Geometry Utils
448
----------------------------------------------------------------------------------------------------------------------*/
449
450
FC.intersectRects = intersectRects;
451
452
// Returns a new rectangle that is the intersection of the two rectangles. If they don't intersect, returns false
453
function intersectRects(rect1, rect2) {
454
	var res = {
455
		left: Math.max(rect1.left, rect2.left),
456
		right: Math.min(rect1.right, rect2.right),
457
		top: Math.max(rect1.top, rect2.top),
458
		bottom: Math.min(rect1.bottom, rect2.bottom)
459
	};
460
461
	if (res.left < res.right && res.top < res.bottom) {
462
		return res;
463
	}
464
	return false;
465
}
466
467
468
// Returns a new point that will have been moved to reside within the given rectangle
469
function constrainPoint(point, rect) {
470
	return {
471
		left: Math.min(Math.max(point.left, rect.left), rect.right),
472
		top: Math.min(Math.max(point.top, rect.top), rect.bottom)
473
	};
474
}
475
476
477
// Returns a point that is the center of the given rectangle
478
function getRectCenter(rect) {
479
	return {
480
		left: (rect.left + rect.right) / 2,
481
		top: (rect.top + rect.bottom) / 2
482
	};
483
}
484
485
486
// Subtracts point2's coordinates from point1's coordinates, returning a delta
487
function diffPoints(point1, point2) {
488
	return {
489
		left: point1.left - point2.left,
490
		top: point1.top - point2.top
491
	};
492
}
493
494
495
/* Object Ordering by Field
496
----------------------------------------------------------------------------------------------------------------------*/
497
498
FC.parseFieldSpecs = parseFieldSpecs;
499
FC.compareByFieldSpecs = compareByFieldSpecs;
500
FC.compareByFieldSpec = compareByFieldSpec;
501
FC.flexibleCompare = flexibleCompare;
502
503
504
function parseFieldSpecs(input) {
505
	var specs = [];
506
	var tokens = [];
507
	var i, token;
508
509
	if (typeof input === 'string') {
510
		tokens = input.split(/\s*,\s*/);
511
	}
512
	else if (typeof input === 'function') {
513
		tokens = [ input ];
514
	}
515
	else if ($.isArray(input)) {
516
		tokens = input;
517
	}
518
519
	for (i = 0; i < tokens.length; i++) {
520
		token = tokens[i];
521
522
		if (typeof token === 'string') {
523
			specs.push(
524
				token.charAt(0) == '-' ?
525
					{ field: token.substring(1), order: -1 } :
526
					{ field: token, order: 1 }
527
			);
528
		}
529
		else if (typeof token === 'function') {
530
			specs.push({ func: token });
531
		}
532
	}
533
534
	return specs;
535
}
536
537
538
function compareByFieldSpecs(obj1, obj2, fieldSpecs) {
539
	var i;
540
	var cmp;
541
542
	for (i = 0; i < fieldSpecs.length; i++) {
543
		cmp = compareByFieldSpec(obj1, obj2, fieldSpecs[i]);
544
		if (cmp) {
545
			return cmp;
546
		}
547
	}
548
549
	return 0;
550
}
551
552
553
function compareByFieldSpec(obj1, obj2, fieldSpec) {
554
	if (fieldSpec.func) {
555
		return fieldSpec.func(obj1, obj2);
556
	}
557
	return flexibleCompare(obj1[fieldSpec.field], obj2[fieldSpec.field]) *
558
		(fieldSpec.order || 1);
559
}
560
561
562
function flexibleCompare(a, b) {
563
	if (!a && !b) {
564
		return 0;
565
	}
566
	if (b == null) {
567
		return -1;
568
	}
569
	if (a == null) {
570
		return 1;
571
	}
572
	if ($.type(a) === 'string' || $.type(b) === 'string') {
573
		return String(a).localeCompare(String(b));
574
	}
575
	return a - b;
576
}
577
578
579
/* FullCalendar-specific Misc Utilities
580
----------------------------------------------------------------------------------------------------------------------*/
581
582
583
// Computes the intersection of the two ranges. Will return fresh date clones in a range.
584
// Returns undefined if no intersection.
585
// Expects all dates to be normalized to the same timezone beforehand.
586
// TODO: move to date section?
587
function intersectRanges(subjectRange, constraintRange) {
588
	var subjectStart = subjectRange.start;
589
	var subjectEnd = subjectRange.end;
590
	var constraintStart = constraintRange.start;
591
	var constraintEnd = constraintRange.end;
592
	var segStart, segEnd;
593
	var isStart, isEnd;
594
595
	if (subjectEnd > constraintStart && subjectStart < constraintEnd) { // in bounds at all?
596
597
		if (subjectStart >= constraintStart) {
598
			segStart = subjectStart.clone();
599
			isStart = true;
600
		}
601
		else {
602
			segStart = constraintStart.clone();
603
			isStart =  false;
604
		}
605
606
		if (subjectEnd <= constraintEnd) {
607
			segEnd = subjectEnd.clone();
608
			isEnd = true;
609
		}
610
		else {
611
			segEnd = constraintEnd.clone();
612
			isEnd = false;
613
		}
614
615
		return {
616
			start: segStart,
617
			end: segEnd,
618
			isStart: isStart,
619
			isEnd: isEnd
620
		};
621
	}
622
}
623
624
625
/* Date Utilities
626
----------------------------------------------------------------------------------------------------------------------*/
627
628
FC.computeGreatestUnit = computeGreatestUnit;
629
FC.divideRangeByDuration = divideRangeByDuration;
630
FC.divideDurationByDuration = divideDurationByDuration;
631
FC.multiplyDuration = multiplyDuration;
632
FC.durationHasTime = durationHasTime;
633
634
var dayIDs = [ 'sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat' ];
635
var unitsDesc = [ 'year', 'month', 'week', 'day', 'hour', 'minute', 'second', 'millisecond' ]; // descending
636
637
638
// Diffs the two moments into a Duration where full-days are recorded first, then the remaining time.
639
// Moments will have their timezones normalized.
640
function diffDayTime(a, b) {
641
	return moment.duration({
642
		days: a.clone().stripTime().diff(b.clone().stripTime(), 'days'),
643
		ms: a.time() - b.time() // time-of-day from day start. disregards timezone
644
	});
645
}
646
647
648
// Diffs the two moments via their start-of-day (regardless of timezone). Produces whole-day durations.
649
function diffDay(a, b) {
650
	return moment.duration({
651
		days: a.clone().stripTime().diff(b.clone().stripTime(), 'days')
652
	});
653
}
654
655
656
// Diffs two moments, producing a duration, made of a whole-unit-increment of the given unit. Uses rounding.
657
function diffByUnit(a, b, unit) {
658
	return moment.duration(
659
		Math.round(a.diff(b, unit, true)), // returnFloat=true
660
		unit
661
	);
662
}
663
664
665
// Computes the unit name of the largest whole-unit period of time.
666
// For example, 48 hours will be "days" whereas 49 hours will be "hours".
667
// Accepts start/end, a range object, or an original duration object.
668
function computeGreatestUnit(start, end) {
669
	var i, unit;
670
	var val;
671
672
	for (i = 0; i < unitsDesc.length; i++) {
673
		unit = unitsDesc[i];
674
		val = computeRangeAs(unit, start, end);
675
676
		if (val >= 1 && isInt(val)) {
677
			break;
678
		}
679
	}
680
681
	return unit; // will be "milliseconds" if nothing else matches
682
}
683
684
685
// like computeGreatestUnit, but has special abilities to interpret the source input for clues
686
function computeDurationGreatestUnit(duration, durationInput) {
687
	var unit = computeGreatestUnit(duration);
688
689
	// prevent days:7 from being interpreted as a week
690
	if (unit === 'week' && typeof durationInput === 'object' && durationInput.days) {
691
		unit = 'day';
692
	}
693
694
	return unit;
695
}
696
697
698
// Computes the number of units (like "hours") in the given range.
699
// Range can be a {start,end} object, separate start/end args, or a Duration.
700
// Results are based on Moment's .as() and .diff() methods, so results can depend on internal handling
701
// of month-diffing logic (which tends to vary from version to version).
702
function computeRangeAs(unit, start, end) {
703
704
	if (end != null) { // given start, end
705
		return end.diff(start, unit, true);
706
	}
707
	else if (moment.isDuration(start)) { // given duration
708
		return start.as(unit);
709
	}
710
	else { // given { start, end } range object
711
		return start.end.diff(start.start, unit, true);
712
	}
713
}
714
715
716
// Intelligently divides a range (specified by a start/end params) by a duration
717
function divideRangeByDuration(start, end, dur) {
718
	var months;
719
720
	if (durationHasTime(dur)) {
721
		return (end - start) / dur;
722
	}
723
	months = dur.asMonths();
724
	if (Math.abs(months) >= 1 && isInt(months)) {
725
		return end.diff(start, 'months', true) / months;
726
	}
727
	return end.diff(start, 'days', true) / dur.asDays();
728
}
729
730
731
// Intelligently divides one duration by another
732
function divideDurationByDuration(dur1, dur2) {
733
	var months1, months2;
734
735
	if (durationHasTime(dur1) || durationHasTime(dur2)) {
736
		return dur1 / dur2;
737
	}
738
	months1 = dur1.asMonths();
739
	months2 = dur2.asMonths();
740
	if (
741
		Math.abs(months1) >= 1 && isInt(months1) &&
742
		Math.abs(months2) >= 1 && isInt(months2)
743
	) {
744
		return months1 / months2;
745
	}
746
	return dur1.asDays() / dur2.asDays();
747
}
748
749
750
// Intelligently multiplies a duration by a number
751
function multiplyDuration(dur, n) {
752
	var months;
753
754
	if (durationHasTime(dur)) {
755
		return moment.duration(dur * n);
756
	}
757
	months = dur.asMonths();
758
	if (Math.abs(months) >= 1 && isInt(months)) {
759
		return moment.duration({ months: months * n });
760
	}
761
	return moment.duration({ days: dur.asDays() * n });
762
}
763
764
765
function cloneRange(range) {
766
	return {
767
		start: range.start.clone(),
768
		end: range.end.clone()
769
	};
770
}
771
772
773
// Trims the beginning and end of inner range to be completely within outerRange.
774
// Returns a new range object.
775
function constrainRange(innerRange, outerRange) {
776
	innerRange = cloneRange(innerRange);
777
778
	if (outerRange.start) {
779
		// needs to be inclusively before outerRange's end
780
		innerRange.start = constrainDate(innerRange.start, outerRange);
781
	}
782
783
	if (outerRange.end) {
784
		innerRange.end = minMoment(innerRange.end, outerRange.end);
785
	}
786
787
	return innerRange;
788
}
789
790
791
// If the given date is not within the given range, move it inside.
792
// (If it's past the end, make it one millisecond before the end).
793
// Always returns a new moment.
794
function constrainDate(date, range) {
795
	date = date.clone();
796
797
	if (range.start) {
798
		date = maxMoment(date, range.start);
799
	}
800
801
	if (range.end && date >= range.end) {
802
		date = range.end.clone().subtract(1);
803
	}
804
805
	return date;
806
}
807
808
809
function isDateWithinRange(date, range) {
810
	return (!range.start || date >= range.start) &&
811
		(!range.end || date < range.end);
812
}
813
814
815
// TODO: deal with repeat code in intersectRanges
816
// constraintRange can have unspecified start/end, an open-ended range.
817
function doRangesIntersect(subjectRange, constraintRange) {
818
	return (!constraintRange.start || subjectRange.end >= constraintRange.start) &&
819
		(!constraintRange.end || subjectRange.start < constraintRange.end);
820
}
821
822
823
function isRangeWithinRange(innerRange, outerRange) {
824
	return (!outerRange.start || innerRange.start >= outerRange.start) &&
825
		(!outerRange.end || innerRange.end <= outerRange.end);
826
}
827
828
829
function isRangesEqual(range0, range1) {
830
	return ((range0.start && range1.start && range0.start.isSame(range1.start)) || (!range0.start && !range1.start)) &&
831
		((range0.end && range1.end && range0.end.isSame(range1.end)) || (!range0.end && !range1.end));
832
}
833
834
835
// Returns the moment that's earlier in time. Always a copy.
836
function minMoment(mom1, mom2) {
837
	return (mom1.isBefore(mom2) ? mom1 : mom2).clone();
838
}
839
840
841
// Returns the moment that's later in time. Always a copy.
842
function maxMoment(mom1, mom2) {
843
	return (mom1.isAfter(mom2) ? mom1 : mom2).clone();
844
}
845
846
847
// Returns a boolean about whether the given duration has any time parts (hours/minutes/seconds/ms)
848
function durationHasTime(dur) {
849
	return Boolean(dur.hours() || dur.minutes() || dur.seconds() || dur.milliseconds());
850
}
851
852
853
function isNativeDate(input) {
854
	return  Object.prototype.toString.call(input) === '[object Date]' || input instanceof Date;
855
}
856
857
858
// Returns a boolean about whether the given input is a time string, like "06:40:00" or "06:00"
859
function isTimeString(str) {
860
	return /^\d+\:\d+(?:\:\d+\.?(?:\d{3})?)?$/.test(str);
861
}
862
863
864
/* Logging and Debug
865
----------------------------------------------------------------------------------------------------------------------*/
866
867
FC.log = function() {
868
	var console = window.console;
869
870
	if (console && console.log) {
871
		return console.log.apply(console, arguments);
872
	}
873
};
874
875
FC.warn = function() {
876
	var console = window.console;
877
878
	if (console && console.warn) {
879
		return console.warn.apply(console, arguments);
880
	}
881
	else {
882
		return FC.log.apply(FC, arguments);
883
	}
884
};
885
886
887
/* General Utilities
888
----------------------------------------------------------------------------------------------------------------------*/
889
890
var hasOwnPropMethod = {}.hasOwnProperty;
891
892
893
// Merges an array of objects into a single object.
894
// The second argument allows for an array of property names who's object values will be merged together.
895
function mergeProps(propObjs, complexProps) {
896
	var dest = {};
897
	var i, name;
898
	var complexObjs;
899
	var j, val;
900
	var props;
901
902
	if (complexProps) {
903
		for (i = 0; i < complexProps.length; i++) {
904
			name = complexProps[i];
905
			complexObjs = [];
906
907
			// collect the trailing object values, stopping when a non-object is discovered
908
			for (j = propObjs.length - 1; j >= 0; j--) {
909
				val = propObjs[j][name];
910
911
				if (typeof val === 'object') {
912
					complexObjs.unshift(val);
913
				}
914
				else if (val !== undefined) {
915
					dest[name] = val; // if there were no objects, this value will be used
916
					break;
917
				}
918
			}
919
920
			// if the trailing values were objects, use the merged value
921
			if (complexObjs.length) {
922
				dest[name] = mergeProps(complexObjs);
923
			}
924
		}
925
	}
926
927
	// copy values into the destination, going from last to first
928
	for (i = propObjs.length - 1; i >= 0; i--) {
929
		props = propObjs[i];
930
931
		for (name in props) {
932
			if (!(name in dest)) { // if already assigned by previous props or complex props, don't reassign
933
				dest[name] = props[name];
934
			}
935
		}
936
	}
937
938
	return dest;
939
}
940
941
942
// Create an object that has the given prototype. Just like Object.create
943
function createObject(proto) {
944
	var f = function() {};
945
	f.prototype = proto;
946
	return new f();
947
}
948
FC.createObject = createObject;
949
950
951
function copyOwnProps(src, dest) {
952
	for (var name in src) {
953
		if (hasOwnProp(src, name)) {
954
			dest[name] = src[name];
955
		}
956
	}
957
}
958
959
960
function hasOwnProp(obj, name) {
961
	return hasOwnPropMethod.call(obj, name);
962
}
963
964
965
// Is the given value a non-object non-function value?
966
function isAtomic(val) {
967
	return /undefined|null|boolean|number|string/.test($.type(val));
968
}
969
970
971
function applyAll(functions, thisObj, args) {
972
	if ($.isFunction(functions)) {
973
		functions = [ functions ];
974
	}
975
	if (functions) {
976
		var i;
977
		var ret;
978
		for (i=0; i<functions.length; i++) {
979
			ret = functions[i].apply(thisObj, args) || ret;
980
		}
981
		return ret;
982
	}
983
}
984
985
986
function firstDefined() {
987
	for (var i=0; i<arguments.length; i++) {
988
		if (arguments[i] !== undefined) {
989
			return arguments[i];
990
		}
991
	}
992
}
993
994
995
function htmlEscape(s) {
996
	return (s + '').replace(/&/g, '&amp;')
997
		.replace(/</g, '&lt;')
998
		.replace(/>/g, '&gt;')
999
		.replace(/'/g, '&#039;')
1000
		.replace(/"/g, '&quot;')
1001
		.replace(/\n/g, '<br />');
1002
}
1003
1004
1005
function stripHtmlEntities(text) {
1006
	return text.replace(/&.*?;/g, '');
1007
}
1008
1009
1010
// Given a hash of CSS properties, returns a string of CSS.
1011
// Uses property names as-is (no camel-case conversion). Will not make statements for null/undefined values.
1012
function cssToStr(cssProps) {
1013
	var statements = [];
1014
1015
	$.each(cssProps, function(name, val) {
1016
		if (val != null) {
1017
			statements.push(name + ':' + val);
1018
		}
1019
	});
1020
1021
	return statements.join(';');
1022
}
1023
1024
1025
// Given an object hash of HTML attribute names to values,
1026
// generates a string that can be injected between < > in HTML
1027
function attrsToStr(attrs) {
1028
	var parts = [];
1029
1030
	$.each(attrs, function(name, val) {
1031
		if (val != null) {
1032
			parts.push(name + '="' + htmlEscape(val) + '"');
1033
		}
1034
	});
1035
1036
	return parts.join(' ');
1037
}
1038
1039
1040
function capitaliseFirstLetter(str) {
1041
	return str.charAt(0).toUpperCase() + str.slice(1);
1042
}
1043
1044
1045
function compareNumbers(a, b) { // for .sort()
1046
	return a - b;
1047
}
1048
1049
1050
function isInt(n) {
1051
	return n % 1 === 0;
1052
}
1053
1054
1055
// Returns a method bound to the given object context.
1056
// Just like one of the jQuery.proxy signatures, but without the undesired behavior of treating the same method with
1057
// different contexts as identical when binding/unbinding events.
1058
function proxy(obj, methodName) {
1059
	var method = obj[methodName];
1060
1061
	return function() {
1062
		return method.apply(obj, arguments);
1063
	};
1064
}
1065
1066
1067
// Returns a function, that, as long as it continues to be invoked, will not
1068
// be triggered. The function will be called after it stops being called for
1069
// N milliseconds. If `immediate` is passed, trigger the function on the
1070
// leading edge, instead of the trailing.
1071
// https://github.com/jashkenas/underscore/blob/1.6.0/underscore.js#L714
1072
function debounce(func, wait, immediate) {
1073
	var timeout, args, context, timestamp, result;
1074
1075
	var later = function() {
1076
		var last = +new Date() - timestamp;
1077
		if (last < wait) {
1078
			timeout = setTimeout(later, wait - last);
1079
		}
1080
		else {
1081
			timeout = null;
1082
			if (!immediate) {
1083
				result = func.apply(context, args);
1084
				context = args = null;
1085
			}
1086
		}
1087
	};
1088
1089
	return function() {
1090
		context = this;
1091
		args = arguments;
1092
		timestamp = +new Date();
1093
		var callNow = immediate && !timeout;
1094
		if (!timeout) {
1095
			timeout = setTimeout(later, wait);
1096
		}
1097
		if (callNow) {
1098
			result = func.apply(context, args);
1099
			context = args = null;
1100
		}
1101
		return result;
1102
	};
1103
}
1104
1105
;;
1106
1107
/*
1108
GENERAL NOTE on moments throughout the *entire rest* of the codebase:
1109
All moments are assumed to be ambiguously-zoned unless otherwise noted,
1110
with the NOTABLE EXCEOPTION of start/end dates that live on *Event Objects*.
1111
Ambiguously-TIMED moments are assumed to be ambiguously-zoned by nature.
1112
*/
1113
1114
var ambigDateOfMonthRegex = /^\s*\d{4}-\d\d$/;
1115
var ambigTimeOrZoneRegex =
1116
	/^\s*\d{4}-(?:(\d\d-\d\d)|(W\d\d$)|(W\d\d-\d)|(\d\d\d))((T| )(\d\d(:\d\d(:\d\d(\.\d+)?)?)?)?)?$/;
1117
var newMomentProto = moment.fn; // where we will attach our new methods
1118
var oldMomentProto = $.extend({}, newMomentProto); // copy of original moment methods
1119
1120
// tell momentjs to transfer these properties upon clone
1121
var momentProperties = moment.momentProperties;
1122
momentProperties.push('_fullCalendar');
1123
momentProperties.push('_ambigTime');
1124
momentProperties.push('_ambigZone');
1125
1126
1127
// Creating
1128
// -------------------------------------------------------------------------------------------------
1129
1130
// Creates a new moment, similar to the vanilla moment(...) constructor, but with
1131
// extra features (ambiguous time, enhanced formatting). When given an existing moment,
1132
// it will function as a clone (and retain the zone of the moment). Anything else will
1133
// result in a moment in the local zone.
1134
FC.moment = function() {
1135
	return makeMoment(arguments);
1136
};
1137
1138
// Sames as FC.moment, but forces the resulting moment to be in the UTC timezone.
1139
FC.moment.utc = function() {
1140
	var mom = makeMoment(arguments, true);
1141
1142
	// Force it into UTC because makeMoment doesn't guarantee it
1143
	// (if given a pre-existing moment for example)
1144
	if (mom.hasTime()) { // don't give ambiguously-timed moments a UTC zone
1145
		mom.utc();
1146
	}
1147
1148
	return mom;
1149
};
1150
1151
// Same as FC.moment, but when given an ISO8601 string, the timezone offset is preserved.
1152
// ISO8601 strings with no timezone offset will become ambiguously zoned.
1153
FC.moment.parseZone = function() {
1154
	return makeMoment(arguments, true, true);
1155
};
1156
1157
// Builds an enhanced moment from args. When given an existing moment, it clones. When given a
1158
// native Date, or called with no arguments (the current time), the resulting moment will be local.
1159
// Anything else needs to be "parsed" (a string or an array), and will be affected by:
1160
//    parseAsUTC - if there is no zone information, should we parse the input in UTC?
1161
//    parseZone - if there is zone information, should we force the zone of the moment?
1162
function makeMoment(args, parseAsUTC, parseZone) {
1163
	var input = args[0];
1164
	var isSingleString = args.length == 1 && typeof input === 'string';
1165
	var isAmbigTime;
1166
	var isAmbigZone;
1167
	var ambigMatch;
1168
	var mom;
1169
1170
	if (moment.isMoment(input) || isNativeDate(input) || input === undefined) {
1171
		mom = moment.apply(null, args);
1172
	}
1173
	else { // "parsing" is required
1174
		isAmbigTime = false;
1175
		isAmbigZone = false;
1176
1177
		if (isSingleString) {
1178
			if (ambigDateOfMonthRegex.test(input)) {
1179
				// accept strings like '2014-05', but convert to the first of the month
1180
				input += '-01';
1181
				args = [ input ]; // for when we pass it on to moment's constructor
1182
				isAmbigTime = true;
1183
				isAmbigZone = true;
1184
			}
1185
			else if ((ambigMatch = ambigTimeOrZoneRegex.exec(input))) {
1186
				isAmbigTime = !ambigMatch[5]; // no time part?
1187
				isAmbigZone = true;
1188
			}
1189
		}
1190
		else if ($.isArray(input)) {
1191
			// arrays have no timezone information, so assume ambiguous zone
1192
			isAmbigZone = true;
1193
		}
1194
		// otherwise, probably a string with a format
1195
1196
		if (parseAsUTC || isAmbigTime) {
1197
			mom = moment.utc.apply(moment, args);
1198
		}
1199
		else {
1200
			mom = moment.apply(null, args);
1201
		}
1202
1203
		if (isAmbigTime) {
1204
			mom._ambigTime = true;
1205
			mom._ambigZone = true; // ambiguous time always means ambiguous zone
1206
		}
1207
		else if (parseZone) { // let's record the inputted zone somehow
1208
			if (isAmbigZone) {
1209
				mom._ambigZone = true;
1210
			}
1211
			else if (isSingleString) {
1212
				mom.utcOffset(input); // if not a valid zone, will assign UTC
1213
			}
1214
		}
1215
	}
1216
1217
	mom._fullCalendar = true; // flag for extended functionality
1218
1219
	return mom;
1220
}
1221
1222
1223
// Week Number
1224
// -------------------------------------------------------------------------------------------------
1225
1226
1227
// Returns the week number, considering the locale's custom week number calcuation
1228
// `weeks` is an alias for `week`
1229
newMomentProto.week = newMomentProto.weeks = function(input) {
1230
	var weekCalc = this._locale._fullCalendar_weekCalc;
1231
1232
	if (input == null && typeof weekCalc === 'function') { // custom function only works for getter
1233
		return weekCalc(this);
1234
	}
1235
	else if (weekCalc === 'ISO') {
1236
		return oldMomentProto.isoWeek.apply(this, arguments); // ISO getter/setter
1237
	}
1238
1239
	return oldMomentProto.week.apply(this, arguments); // local getter/setter
1240
};
1241
1242
1243
// Time-of-day
1244
// -------------------------------------------------------------------------------------------------
1245
1246
// GETTER
1247
// Returns a Duration with the hours/minutes/seconds/ms values of the moment.
1248
// If the moment has an ambiguous time, a duration of 00:00 will be returned.
1249
//
1250
// SETTER
1251
// You can supply a Duration, a Moment, or a Duration-like argument.
1252
// When setting the time, and the moment has an ambiguous time, it then becomes unambiguous.
1253
newMomentProto.time = function(time) {
1254
1255
	// Fallback to the original method (if there is one) if this moment wasn't created via FullCalendar.
1256
	// `time` is a generic enough method name where this precaution is necessary to avoid collisions w/ other plugins.
1257
	if (!this._fullCalendar) {
1258
		return oldMomentProto.time.apply(this, arguments);
1259
	}
1260
1261
	if (time == null) { // getter
1262
		return moment.duration({
1263
			hours: this.hours(),
1264
			minutes: this.minutes(),
1265
			seconds: this.seconds(),
1266
			milliseconds: this.milliseconds()
1267
		});
1268
	}
1269
	else { // setter
1270
1271
		this._ambigTime = false; // mark that the moment now has a time
1272
1273
		if (!moment.isDuration(time) && !moment.isMoment(time)) {
1274
			time = moment.duration(time);
1275
		}
1276
1277
		// The day value should cause overflow (so 24 hours becomes 00:00:00 of next day).
1278
		// Only for Duration times, not Moment times.
1279
		var dayHours = 0;
1280
		if (moment.isDuration(time)) {
1281
			dayHours = Math.floor(time.asDays()) * 24;
1282
		}
1283
1284
		// We need to set the individual fields.
1285
		// Can't use startOf('day') then add duration. In case of DST at start of day.
1286
		return this.hours(dayHours + time.hours())
1287
			.minutes(time.minutes())
1288
			.seconds(time.seconds())
1289
			.milliseconds(time.milliseconds());
1290
	}
1291
};
1292
1293
// Converts the moment to UTC, stripping out its time-of-day and timezone offset,
1294
// but preserving its YMD. A moment with a stripped time will display no time
1295
// nor timezone offset when .format() is called.
1296
newMomentProto.stripTime = function() {
1297
1298
	if (!this._ambigTime) {
1299
1300
		this.utc(true); // keepLocalTime=true (for keeping *date* value)
1301
1302
		// set time to zero
1303
		this.set({
1304
			hours: 0,
1305
			minutes: 0,
1306
			seconds: 0,
1307
			ms: 0
1308
		});
1309
1310
		// Mark the time as ambiguous. This needs to happen after the .utc() call, which might call .utcOffset(),
1311
		// which clears all ambig flags.
1312
		this._ambigTime = true;
1313
		this._ambigZone = true; // if ambiguous time, also ambiguous timezone offset
1314
	}
1315
1316
	return this; // for chaining
1317
};
1318
1319
// Returns if the moment has a non-ambiguous time (boolean)
1320
newMomentProto.hasTime = function() {
1321
	return !this._ambigTime;
1322
};
1323
1324
1325
// Timezone
1326
// -------------------------------------------------------------------------------------------------
1327
1328
// Converts the moment to UTC, stripping out its timezone offset, but preserving its
1329
// YMD and time-of-day. A moment with a stripped timezone offset will display no
1330
// timezone offset when .format() is called.
1331
newMomentProto.stripZone = function() {
1332
	var wasAmbigTime;
1333
1334
	if (!this._ambigZone) {
1335
1336
		wasAmbigTime = this._ambigTime;
1337
1338
		this.utc(true); // keepLocalTime=true (for keeping date and time values)
1339
1340
		// the above call to .utc()/.utcOffset() unfortunately might clear the ambig flags, so restore
1341
		this._ambigTime = wasAmbigTime || false;
1342
1343
		// Mark the zone as ambiguous. This needs to happen after the .utc() call, which might call .utcOffset(),
1344
		// which clears the ambig flags.
1345
		this._ambigZone = true;
1346
	}
1347
1348
	return this; // for chaining
1349
};
1350
1351
// Returns of the moment has a non-ambiguous timezone offset (boolean)
1352
newMomentProto.hasZone = function() {
1353
	return !this._ambigZone;
1354
};
1355
1356
1357
// implicitly marks a zone
1358
newMomentProto.local = function(keepLocalTime) {
1359
1360
	// for when converting from ambiguously-zoned to local,
1361
	// keep the time values when converting from UTC -> local
1362
	oldMomentProto.local.call(this, this._ambigZone || keepLocalTime);
1363
1364
	// ensure non-ambiguous
1365
	// this probably already happened via local() -> utcOffset(), but don't rely on Moment's internals
1366
	this._ambigTime = false;
1367
	this._ambigZone = false;
1368
1369
	return this; // for chaining
1370
};
1371
1372
1373
// implicitly marks a zone
1374
newMomentProto.utc = function(keepLocalTime) {
1375
1376
	oldMomentProto.utc.call(this, keepLocalTime);
1377
1378
	// ensure non-ambiguous
1379
	// this probably already happened via utc() -> utcOffset(), but don't rely on Moment's internals
1380
	this._ambigTime = false;
1381
	this._ambigZone = false;
1382
1383
	return this;
1384
};
1385
1386
1387
// implicitly marks a zone (will probably get called upon .utc() and .local())
1388
newMomentProto.utcOffset = function(tzo) {
1389
1390
	if (tzo != null) { // setter
1391
		// these assignments needs to happen before the original zone method is called.
1392
		// I forget why, something to do with a browser crash.
1393
		this._ambigTime = false;
1394
		this._ambigZone = false;
1395
	}
1396
1397
	return oldMomentProto.utcOffset.apply(this, arguments);
1398
};
1399
1400
1401
// Formatting
1402
// -------------------------------------------------------------------------------------------------
1403
1404
newMomentProto.format = function() {
1405
1406
	if (this._fullCalendar && arguments[0]) { // an enhanced moment? and a format string provided?
1407
		return formatDate(this, arguments[0]); // our extended formatting
1408
	}
1409
	if (this._ambigTime) {
1410
		return oldMomentFormat(englishMoment(this), 'YYYY-MM-DD');
1411
	}
1412
	if (this._ambigZone) {
1413
		return oldMomentFormat(englishMoment(this), 'YYYY-MM-DD[T]HH:mm:ss');
1414
	}
1415
	if (this._fullCalendar) { // enhanced non-ambig moment?
1416
		// moment.format() doesn't ensure english, but we want to.
1417
		return oldMomentFormat(englishMoment(this));
1418
	}
1419
1420
	return oldMomentProto.format.apply(this, arguments);
1421
};
1422
1423
newMomentProto.toISOString = function() {
1424
1425
	if (this._ambigTime) {
1426
		return oldMomentFormat(englishMoment(this), 'YYYY-MM-DD');
1427
	}
1428
	if (this._ambigZone) {
1429
		return oldMomentFormat(englishMoment(this), 'YYYY-MM-DD[T]HH:mm:ss');
1430
	}
1431
	if (this._fullCalendar) { // enhanced non-ambig moment?
1432
		// depending on browser, moment might not output english. ensure english.
1433
		// https://github.com/moment/moment/blob/2.18.1/src/lib/moment/format.js#L22
1434
		return oldMomentProto.toISOString.apply(englishMoment(this), arguments);
1435
	}
1436
1437
	return oldMomentProto.toISOString.apply(this, arguments);
1438
};
1439
1440
function englishMoment(mom) {
1441
	if (mom.locale() !== 'en') {
1442
		return mom.clone().locale('en');
1443
	}
1444
	return mom;
1445
}
1446
1447
;;
1448
(function() {
1449
1450
// exports
1451
FC.formatDate = formatDate;
1452
FC.formatRange = formatRange;
1453
FC.oldMomentFormat = oldMomentFormat;
1454
FC.queryMostGranularFormatUnit = queryMostGranularFormatUnit;
1455
1456
1457
// Config
1458
// ---------------------------------------------------------------------------------------------------------------------
1459
1460
/*
1461
Inserted between chunks in the fake ("intermediate") formatting string.
1462
Important that it passes as whitespace (\s) because moment often identifies non-standalone months
1463
via a regexp with an \s.
1464
*/
1465
var PART_SEPARATOR = '\u000b'; // vertical tab
1466
1467
/*
1468
Inserted as the first character of a literal-text chunk to indicate that the literal text is not actually literal text,
1469
but rather, a "special" token that has custom rendering (see specialTokens map).
1470
*/
1471
var SPECIAL_TOKEN_MARKER = '\u001f'; // information separator 1
1472
1473
/*
1474
Inserted at the beginning and end of a span of text that must have non-zero numeric characters.
1475
Handling of these markers is done in a post-processing step at the very end of text rendering.
1476
*/
1477
var MAYBE_MARKER = '\u001e'; // information separator 2
1478
var MAYBE_REGEXP = new RegExp(MAYBE_MARKER + '([^' + MAYBE_MARKER + ']*)' + MAYBE_MARKER, 'g'); // must be global
1479
1480
/*
1481
Addition formatting tokens we want recognized
1482
*/
1483
var specialTokens = {
1484
	t: function(date) { // "a" or "p"
1485
		return oldMomentFormat(date, 'a').charAt(0);
1486
	},
1487
	T: function(date) { // "A" or "P"
1488
		return oldMomentFormat(date, 'A').charAt(0);
1489
	}
1490
};
1491
1492
/*
1493
The first characters of formatting tokens for units that are 1 day or larger.
1494
`value` is for ranking relative size (lower means bigger).
1495
`unit` is a normalized unit, used for comparing moments.
1496
*/
1497
var largeTokenMap = {
1498
	Y: { value: 1, unit: 'year' },
1499
	M: { value: 2, unit: 'month' },
1500
	W: { value: 3, unit: 'week' }, // ISO week
1501
	w: { value: 3, unit: 'week' }, // local week
1502
	D: { value: 4, unit: 'day' }, // day of month
1503
	d: { value: 4, unit: 'day' } // day of week
1504
};
1505
1506
1507
// Single Date Formatting
1508
// ---------------------------------------------------------------------------------------------------------------------
1509
1510
/*
1511
Formats `date` with a Moment formatting string, but allow our non-zero areas and special token
1512
*/
1513
function formatDate(date, formatStr) {
1514
	return renderFakeFormatString(
1515
		getParsedFormatString(formatStr).fakeFormatString,
1516
		date
1517
	);
1518
}
1519
1520
/*
1521
Call this if you want Moment's original format method to be used
1522
*/
1523
function oldMomentFormat(mom, formatStr) {
1524
	return oldMomentProto.format.call(mom, formatStr); // oldMomentProto defined in moment-ext.js
1525
}
1526
1527
1528
// Date Range Formatting
1529
// -------------------------------------------------------------------------------------------------
1530
// TODO: make it work with timezone offset
1531
1532
/*
1533
Using a formatting string meant for a single date, generate a range string, like
1534
"Sep 2 - 9 2013", that intelligently inserts a separator where the dates differ.
1535
If the dates are the same as far as the format string is concerned, just return a single
1536
rendering of one date, without any separator.
1537
*/
1538
function formatRange(date1, date2, formatStr, separator, isRTL) {
1539
	var localeData;
1540
1541
	date1 = FC.moment.parseZone(date1);
1542
	date2 = FC.moment.parseZone(date2);
1543
1544
	localeData = date1.localeData();
1545
1546
	// Expand localized format strings, like "LL" -> "MMMM D YYYY".
1547
	// BTW, this is not important for `formatDate` because it is impossible to put custom tokens
1548
	// or non-zero areas in Moment's localized format strings.
1549
	formatStr = localeData.longDateFormat(formatStr) || formatStr;
1550
1551
	return renderParsedFormat(
1552
		getParsedFormatString(formatStr),
1553
		date1,
1554
		date2,
1555
		separator || ' - ',
1556
		isRTL
1557
	);
1558
}
1559
1560
/*
1561
Renders a range with an already-parsed format string.
1562
*/
1563
function renderParsedFormat(parsedFormat, date1, date2, separator, isRTL) {
1564
	var sameUnits = parsedFormat.sameUnits;
1565
	var unzonedDate1 = date1.clone().stripZone(); // for same-unit comparisons
1566
	var unzonedDate2 = date2.clone().stripZone(); // "
1567
1568
	var renderedParts1 = renderFakeFormatStringParts(parsedFormat.fakeFormatString, date1);
1569
	var renderedParts2 = renderFakeFormatStringParts(parsedFormat.fakeFormatString, date2);
1570
1571
	var leftI;
1572
	var leftStr = '';
1573
	var rightI;
1574
	var rightStr = '';
1575
	var middleI;
1576
	var middleStr1 = '';
1577
	var middleStr2 = '';
1578
	var middleStr = '';
1579
1580
	// Start at the leftmost side of the formatting string and continue until you hit a token
1581
	// that is not the same between dates.
1582
	for (
1583
		leftI = 0;
1584
		leftI < sameUnits.length && (!sameUnits[leftI] || unzonedDate1.isSame(unzonedDate2, sameUnits[leftI]));
1585
		leftI++
1586
	) {
1587
		leftStr += renderedParts1[leftI];
1588
	}
1589
1590
	// Similarly, start at the rightmost side of the formatting string and move left
1591
	for (
1592
		rightI = sameUnits.length - 1;
1593
		rightI > leftI && (!sameUnits[rightI] || unzonedDate1.isSame(unzonedDate2, sameUnits[rightI]));
1594
		rightI--
1595
	) {
1596
		// If current chunk is on the boundary of unique date-content, and is a special-case
1597
		// date-formatting postfix character, then don't consume it. Consider it unique date-content.
1598
		// TODO: make configurable
1599
		if (rightI - 1 === leftI && renderedParts1[rightI] === '.') {
1600
			break;
1601
		}
1602
1603
		rightStr = renderedParts1[rightI] + rightStr;
1604
	}
1605
1606
	// The area in the middle is different for both of the dates.
1607
	// Collect them distinctly so we can jam them together later.
1608
	for (middleI = leftI; middleI <= rightI; middleI++) {
1609
		middleStr1 += renderedParts1[middleI];
1610
		middleStr2 += renderedParts2[middleI];
1611
	}
1612
1613
	if (middleStr1 || middleStr2) {
1614
		if (isRTL) {
1615
			middleStr = middleStr2 + separator + middleStr1;
1616
		}
1617
		else {
1618
			middleStr = middleStr1 + separator + middleStr2;
1619
		}
1620
	}
1621
1622
	return processMaybeMarkers(
1623
		leftStr + middleStr + rightStr
1624
	);
1625
}
1626
1627
1628
// Format String Parsing
1629
// ---------------------------------------------------------------------------------------------------------------------
1630
1631
var parsedFormatStrCache = {};
1632
1633
/*
1634
Returns a parsed format string, leveraging a cache.
1635
*/
1636
function getParsedFormatString(formatStr) {
1637
	return parsedFormatStrCache[formatStr] ||
1638
		(parsedFormatStrCache[formatStr] = parseFormatString(formatStr));
1639
}
1640
1641
/*
1642
Parses a format string into the following:
1643
- fakeFormatString: a momentJS formatting string, littered with special control characters that get post-processed.
1644
- sameUnits: for every part in fakeFormatString, if the part is a token, the value will be a unit string (like "day"),
1645
  that indicates how similar a range's start & end must be in order to share the same formatted text.
1646
  If not a token, then the value is null.
1647
  Always a flat array (not nested liked "chunks").
1648
*/
1649
function parseFormatString(formatStr) {
1650
	var chunks = chunkFormatString(formatStr);
1651
	
1652
	return {
1653
		fakeFormatString: buildFakeFormatString(chunks),
1654
		sameUnits: buildSameUnits(chunks)
1655
	};
1656
}
1657
1658
/*
1659
Break the formatting string into an array of chunks.
1660
A 'maybe' chunk will have nested chunks.
1661
*/
1662
function chunkFormatString(formatStr) {
1663
	var chunks = [];
1664
	var match;
1665
1666
	// TODO: more descrimination
1667
	// \4 is a backreference to the first character of a multi-character set.
1668
	var chunker = /\[([^\]]*)\]|\(([^\)]*)\)|(LTS|LT|(\w)\4*o?)|([^\w\[\(]+)/g;
1669
1670
	while ((match = chunker.exec(formatStr))) {
1671
		if (match[1]) { // a literal string inside [ ... ]
1672
			chunks.push.apply(chunks, // append
1673
				splitStringLiteral(match[1])
1674
			);
1675
		}
1676
		else if (match[2]) { // non-zero formatting inside ( ... )
1677
			chunks.push({ maybe: chunkFormatString(match[2]) });
1678
		}
1679
		else if (match[3]) { // a formatting token
1680
			chunks.push({ token: match[3] });
1681
		}
1682
		else if (match[5]) { // an unenclosed literal string
1683
			chunks.push.apply(chunks, // append
1684
				splitStringLiteral(match[5])
1685
			);
1686
		}
1687
	}
1688
1689
	return chunks;
1690
}
1691
1692
/*
1693
Potentially splits a literal-text string into multiple parts. For special cases.
1694
*/
1695
function splitStringLiteral(s) {
1696
	if (s === '. ') {
1697
		return [ '.', ' ' ]; // for locales with periods bound to the end of each year/month/date
1698
	}
1699
	else {
1700
		return [ s ];
1701
	}
1702
}
1703
1704
/*
1705
Given chunks parsed from a real format string, generate a fake (aka "intermediate") format string with special control
1706
characters that will eventually be given to moment for formatting, and then post-processed.
1707
*/
1708
function buildFakeFormatString(chunks) {
1709
	var parts = [];
1710
	var i, chunk;
1711
1712
	for (i = 0; i < chunks.length; i++) {
1713
		chunk = chunks[i];
1714
1715
		if (typeof chunk === 'string') {
1716
			parts.push('[' + chunk + ']');
1717
		}
1718
		else if (chunk.token) {
1719
			if (chunk.token in specialTokens) {
1720
				parts.push(
1721
					SPECIAL_TOKEN_MARKER + // useful during post-processing
1722
					'[' + chunk.token + ']' // preserve as literal text
1723
				);
1724
			}
1725
			else {
1726
				parts.push(chunk.token); // unprotected text implies a format string
1727
			}
1728
		}
1729
		else if (chunk.maybe) {
1730
			parts.push(
1731
				MAYBE_MARKER + // useful during post-processing
1732
				buildFakeFormatString(chunk.maybe) +
1733
				MAYBE_MARKER
1734
			);
1735
		}
1736
	}
1737
1738
	return parts.join(PART_SEPARATOR);
1739
}
1740
1741
/*
1742
Given parsed chunks from a real formatting string, generates an array of unit strings (like "day") that indicate
1743
in which regard two dates must be similar in order to share range formatting text.
1744
The `chunks` can be nested (because of "maybe" chunks), however, the returned array will be flat.
1745
*/
1746
function buildSameUnits(chunks) {
1747
	var units = [];
1748
	var i, chunk;
1749
	var tokenInfo;
1750
1751
	for (i = 0; i < chunks.length; i++) {
1752
		chunk = chunks[i];
1753
1754
		if (chunk.token) {
1755
			tokenInfo = largeTokenMap[chunk.token.charAt(0)];
1756
			units.push(tokenInfo ? tokenInfo.unit : 'second'); // default to a very strict same-second
1757
		}
1758
		else if (chunk.maybe) {
1759
			units.push.apply(units, // append
1760
				buildSameUnits(chunk.maybe)
1761
			);
1762
		}
1763
		else {
1764
			units.push(null);
1765
		}
1766
	}
1767
1768
	return units;
1769
}
1770
1771
1772
// Rendering to text
1773
// ---------------------------------------------------------------------------------------------------------------------
1774
1775
/*
1776
Formats a date with a fake format string, post-processes the control characters, then returns.
1777
*/
1778
function renderFakeFormatString(fakeFormatString, date) {
1779
	return processMaybeMarkers(
1780
		renderFakeFormatStringParts(fakeFormatString, date).join('')
1781
	);
1782
}
1783
1784
/*
1785
Formats a date into parts that will have been post-processed, EXCEPT for the "maybe" markers.
1786
*/
1787
function renderFakeFormatStringParts(fakeFormatString, date) {
1788
	var parts = [];
1789
	var fakeRender = oldMomentFormat(date, fakeFormatString);
1790
	var fakeParts = fakeRender.split(PART_SEPARATOR);
1791
	var i, fakePart;
1792
1793
	for (i = 0; i < fakeParts.length; i++) {
1794
		fakePart = fakeParts[i];
1795
1796
		if (fakePart.charAt(0) === SPECIAL_TOKEN_MARKER) {
1797
			parts.push(
1798
				// the literal string IS the token's name.
1799
				// call special token's registered function.
1800
				specialTokens[fakePart.substring(1)](date)
1801
			);
1802
		}
1803
		else {
1804
			parts.push(fakePart);
1805
		}
1806
	}
1807
1808
	return parts;
1809
}
1810
1811
/*
1812
Accepts an almost-finally-formatted string and processes the "maybe" control characters, returning a new string.
1813
*/
1814
function processMaybeMarkers(s) {
1815
	return s.replace(MAYBE_REGEXP, function(m0, m1) { // regex assumed to have 'g' flag
1816
		if (m1.match(/[1-9]/)) { // any non-zero numeric characters?
1817
			return m1;
1818
		}
1819
		else {
1820
			return '';
1821
		}
1822
	});
1823
}
1824
1825
1826
// Misc Utils
1827
// -------------------------------------------------------------------------------------------------
1828
1829
/*
1830
Returns a unit string, either 'year', 'month', 'day', or null for the most granular formatting token in the string.
1831
*/
1832
function queryMostGranularFormatUnit(formatStr) {
1833
	var chunks = chunkFormatString(formatStr);
1834
	var i, chunk;
1835
	var candidate;
1836
	var best;
1837
1838
	for (i = 0; i < chunks.length; i++) {
1839
		chunk = chunks[i];
1840
1841
		if (chunk.token) {
1842
			candidate = largeTokenMap[chunk.token.charAt(0)];
1843
			if (candidate) {
1844
				if (!best || candidate.value > best.value) {
1845
					best = candidate;
1846
				}
1847
			}
1848
		}
1849
	}
1850
1851
	if (best) {
1852
		return best.unit;
1853
	}
1854
1855
	return null;
1856
};
1857
1858
})();
1859
1860
// quick local references
1861
var formatDate = FC.formatDate;
1862
var formatRange = FC.formatRange;
1863
var oldMomentFormat = FC.oldMomentFormat;
1864
1865
;;
1866
1867
FC.Class = Class; // export
1868
1869
// Class that all other classes will inherit from
1870
function Class() { }
1871
1872
1873
// Called on a class to create a subclass.
1874
// Last argument contains instance methods. Any argument before the last are considered mixins.
1875
Class.extend = function() {
1876
	var len = arguments.length;
1877
	var i;
1878
	var members;
1879
1880
	for (i = 0; i < len; i++) {
1881
		members = arguments[i];
1882
		if (i < len - 1) { // not the last argument?
1883
			mixIntoClass(this, members);
1884
		}
1885
	}
1886
1887
	return extendClass(this, members || {}); // members will be undefined if no arguments
1888
};
1889
1890
1891
// Adds new member variables/methods to the class's prototype.
1892
// Can be called with another class, or a plain object hash containing new members.
1893
Class.mixin = function(members) {
1894
	mixIntoClass(this, members);
1895
};
1896
1897
1898
function extendClass(superClass, members) {
1899
	var subClass;
1900
1901
	// ensure a constructor for the subclass, forwarding all arguments to the super-constructor if it doesn't exist
1902
	if (hasOwnProp(members, 'constructor')) {
1903
		subClass = members.constructor;
1904
	}
1905
	if (typeof subClass !== 'function') {
1906
		subClass = members.constructor = function() {
1907
			superClass.apply(this, arguments);
1908
		};
1909
	}
1910
1911
	// build the base prototype for the subclass, which is an new object chained to the superclass's prototype
1912
	subClass.prototype = createObject(superClass.prototype);
1913
1914
	// copy each member variable/method onto the the subclass's prototype
1915
	copyOwnProps(members, subClass.prototype);
1916
1917
	// copy over all class variables/methods to the subclass, such as `extend` and `mixin`
1918
	copyOwnProps(superClass, subClass);
1919
1920
	return subClass;
1921
}
1922
1923
1924
function mixIntoClass(theClass, members) {
1925
	copyOwnProps(members, theClass.prototype);
1926
}
1927
;;
1928
1929
var Model = Class.extend(EmitterMixin, ListenerMixin, {
1930
1931
	_props: null,
1932
	_watchers: null,
1933
	_globalWatchArgs: null,
1934
1935
	constructor: function() {
1936
		this._watchers = {};
1937
		this._props = {};
1938
		this.applyGlobalWatchers();
1939
	},
1940
1941
	applyGlobalWatchers: function() {
1942
		var argSets = this._globalWatchArgs || [];
1943
		var i;
1944
1945
		for (i = 0; i < argSets.length; i++) {
1946
			this.watch.apply(this, argSets[i]);
1947
		}
1948
	},
1949
1950
	has: function(name) {
1951
		return name in this._props;
1952
	},
1953
1954
	get: function(name) {
1955
		if (name === undefined) {
1956
			return this._props;
1957
		}
1958
1959
		return this._props[name];
1960
	},
1961
1962
	set: function(name, val) {
1963
		var newProps;
1964
1965
		if (typeof name === 'string') {
1966
			newProps = {};
1967
			newProps[name] = val === undefined ? null : val;
1968
		}
1969
		else {
1970
			newProps = name;
1971
		}
1972
1973
		this.setProps(newProps);
1974
	},
1975
1976
	reset: function(newProps) {
1977
		var oldProps = this._props;
1978
		var changeset = {}; // will have undefined's to signal unsets
1979
		var name;
1980
1981
		for (name in oldProps) {
1982
			changeset[name] = undefined;
1983
		}
1984
1985
		for (name in newProps) {
1986
			changeset[name] = newProps[name];
1987
		}
1988
1989
		this.setProps(changeset);
1990
	},
1991
1992
	unset: function(name) { // accepts a string or array of strings
1993
		var newProps = {};
1994
		var names;
1995
		var i;
1996
1997
		if (typeof name === 'string') {
1998
			names = [ name ];
1999
		}
2000
		else {
2001
			names = name;
2002
		}
2003
2004
		for (i = 0; i < names.length; i++) {
2005
			newProps[names[i]] = undefined;
2006
		}
2007
2008
		this.setProps(newProps);
2009
	},
2010
2011
	setProps: function(newProps) {
2012
		var changedProps = {};
2013
		var changedCnt = 0;
2014
		var name, val;
2015
2016
		for (name in newProps) {
2017
			val = newProps[name];
2018
2019
			// a change in value?
2020
			// if an object, don't check equality, because might have been mutated internally.
2021
			// TODO: eventually enforce immutability.
2022
			if (
2023
				typeof val === 'object' ||
2024
				val !== this._props[name]
2025
			) {
2026
				changedProps[name] = val;
2027
				changedCnt++;
2028
			}
2029
		}
2030
2031
		if (changedCnt) {
2032
2033
			this.trigger('before:batchChange', changedProps);
2034
2035
			for (name in changedProps) {
2036
				val = changedProps[name];
2037
2038
				this.trigger('before:change', name, val);
2039
				this.trigger('before:change:' + name, val);
2040
			}
2041
2042
			for (name in changedProps) {
2043
				val = changedProps[name];
2044
2045
				if (val === undefined) {
2046
					delete this._props[name];
2047
				}
2048
				else {
2049
					this._props[name] = val;
2050
				}
2051
2052
				this.trigger('change:' + name, val);
2053
				this.trigger('change', name, val);
2054
			}
2055
2056
			this.trigger('batchChange', changedProps);
2057
		}
2058
	},
2059
2060
	watch: function(name, depList, startFunc, stopFunc) {
2061
		var _this = this;
2062
2063
		this.unwatch(name);
2064
2065
		this._watchers[name] = this._watchDeps(depList, function(deps) {
2066
			var res = startFunc.call(_this, deps);
2067
2068
			if (res && res.then) {
2069
				_this.unset(name); // put in an unset state while resolving
2070
				res.then(function(val) {
2071
					_this.set(name, val);
2072
				});
2073
			}
2074
			else {
2075
				_this.set(name, res);
2076
			}
2077
		}, function() {
2078
			_this.unset(name);
2079
2080
			if (stopFunc) {
2081
				stopFunc.call(_this);
2082
			}
2083
		});
2084
	},
2085
2086
	unwatch: function(name) {
2087
		var watcher = this._watchers[name];
2088
2089
		if (watcher) {
2090
			delete this._watchers[name];
2091
			watcher.teardown();
2092
		}
2093
	},
2094
2095
	_watchDeps: function(depList, startFunc, stopFunc) {
2096
		var _this = this;
2097
		var queuedChangeCnt = 0;
2098
		var depCnt = depList.length;
2099
		var satisfyCnt = 0;
2100
		var values = {}; // what's passed as the `deps` arguments
2101
		var bindTuples = []; // array of [ eventName, handlerFunc ] arrays
2102
		var isCallingStop = false;
2103
2104
		function onBeforeDepChange(depName, val, isOptional) {
2105
			queuedChangeCnt++;
2106
			if (queuedChangeCnt === 1) { // first change to cause a "stop" ?
2107
				if (satisfyCnt === depCnt) { // all deps previously satisfied?
2108
					isCallingStop = true;
2109
					stopFunc();
2110
					isCallingStop = false;
2111
				}
2112
			}
2113
		}
2114
2115
		function onDepChange(depName, val, isOptional) {
2116
2117
			if (val === undefined) { // unsetting a value?
2118
2119
				// required dependency that was previously set?
2120
				if (!isOptional && values[depName] !== undefined) {
2121
					satisfyCnt--;
2122
				}
2123
2124
				delete values[depName];
2125
			}
2126
			else { // setting a value?
2127
2128
				// required dependency that was previously unset?
2129
				if (!isOptional && values[depName] === undefined) {
2130
					satisfyCnt++;
2131
				}
2132
2133
				values[depName] = val;
2134
			}
2135
2136
			queuedChangeCnt--;
2137
			if (!queuedChangeCnt) { // last change to cause a "start"?
2138
2139
				// now finally satisfied or satisfied all along?
2140
				if (satisfyCnt === depCnt) {
2141
2142
					// if the stopFunc initiated another value change, ignore it.
2143
					// it will be processed by another change event anyway.
2144
					if (!isCallingStop) {
2145
						startFunc(values);
2146
					}
2147
				}
2148
			}
2149
		}
2150
2151
		// intercept for .on() that remembers handlers
2152
		function bind(eventName, handler) {
2153
			_this.on(eventName, handler);
2154
			bindTuples.push([ eventName, handler ]);
2155
		}
2156
2157
		// listen to dependency changes
2158
		depList.forEach(function(depName) {
2159
			var isOptional = false;
2160
2161
			if (depName.charAt(0) === '?') { // TODO: more DRY
2162
				depName = depName.substring(1);
2163
				isOptional = true;
2164
			}
2165
2166
			bind('before:change:' + depName, function(val) {
2167
				onBeforeDepChange(depName, val, isOptional);
2168
			});
2169
2170
			bind('change:' + depName, function(val) {
2171
				onDepChange(depName, val, isOptional);
2172
			});
2173
		});
2174
2175
		// process current dependency values
2176
		depList.forEach(function(depName) {
2177
			var isOptional = false;
2178
2179
			if (depName.charAt(0) === '?') { // TODO: more DRY
2180
				depName = depName.substring(1);
2181
				isOptional = true;
2182
			}
2183
2184
			if (_this.has(depName)) {
2185
				values[depName] = _this.get(depName);
2186
				satisfyCnt++;
2187
			}
2188
			else if (isOptional) {
2189
				satisfyCnt++;
2190
			}
2191
		});
2192
2193
		// initially satisfied
2194
		if (satisfyCnt === depCnt) {
2195
			startFunc(values);
2196
		}
2197
2198
		return {
2199
			teardown: function() {
2200
				// remove all handlers
2201
				for (var i = 0; i < bindTuples.length; i++) {
2202
					_this.off(bindTuples[i][0], bindTuples[i][1]);
2203
				}
2204
				bindTuples = null;
2205
2206
				// was satisfied, so call stopFunc
2207
				if (satisfyCnt === depCnt) {
2208
					stopFunc();
2209
				}
2210
			},
2211
			flash: function() {
2212
				if (satisfyCnt === depCnt) {
2213
					stopFunc();
2214
					startFunc(values);
2215
				}
2216
			}
2217
		};
2218
	},
2219
2220
	flash: function(name) {
2221
		var watcher = this._watchers[name];
2222
2223
		if (watcher) {
2224
			watcher.flash();
2225
		}
2226
	}
2227
2228
});
2229
2230
2231
Model.watch = function(/* same arguments as this.watch() */) {
2232
	var proto = this.prototype;
2233
2234
	if (!proto._globalWatchArgs) {
2235
		proto._globalWatchArgs = [];
2236
	}
2237
2238
	proto._globalWatchArgs.push(arguments);
2239
};
2240
2241
2242
FC.Model = Model;
2243
2244
2245
;;
2246
2247
var Promise = {
2248
2249
	construct: function(executor) {
2250
		var deferred = $.Deferred();
2251
		var promise = deferred.promise();
2252
2253
		if (typeof executor === 'function') {
2254
			executor(
2255
				function(val) { // resolve
2256
					deferred.resolve(val);
2257
					attachImmediatelyResolvingThen(promise, val);
2258
				},
2259
				function() { // reject
2260
					deferred.reject();
2261
					attachImmediatelyRejectingThen(promise);
2262
				}
2263
			);
2264
		}
2265
2266
		return promise;
2267
	},
2268
2269
	resolve: function(val) {
2270
		var deferred = $.Deferred().resolve(val);
2271
		var promise = deferred.promise();
2272
2273
		attachImmediatelyResolvingThen(promise, val);
2274
2275
		return promise;
2276
	},
2277
2278
	reject: function() {
2279
		var deferred = $.Deferred().reject();
2280
		var promise = deferred.promise();
2281
2282
		attachImmediatelyRejectingThen(promise);
2283
2284
		return promise;
2285
	}
2286
2287
};
2288
2289
2290
function attachImmediatelyResolvingThen(promise, val) {
2291
	promise.then = function(onResolve) {
2292
		if (typeof onResolve === 'function') {
2293
			onResolve(val);
2294
		}
2295
		return promise; // for chaining
2296
	};
2297
}
2298
2299
2300
function attachImmediatelyRejectingThen(promise) {
2301
	promise.then = function(onResolve, onReject) {
2302
		if (typeof onReject === 'function') {
2303
			onReject();
2304
		}
2305
		return promise; // for chaining
2306
	};
2307
}
2308
2309
2310
FC.Promise = Promise;
2311
2312
;;
2313
2314
var TaskQueue = Class.extend(EmitterMixin, {
2315
2316
	q: null,
2317
	isPaused: false,
2318
	isRunning: false,
2319
2320
2321
	constructor: function() {
2322
		this.q = [];
2323
	},
2324
2325
2326
	queue: function(/* taskFunc, taskFunc... */) {
2327
		this.q.push.apply(this.q, arguments); // append
2328
		this.tryStart();
2329
	},
2330
2331
2332
	pause: function() {
2333
		this.isPaused = true;
2334
	},
2335
2336
2337
	resume: function() {
2338
		this.isPaused = false;
2339
		this.tryStart();
2340
	},
2341
2342
2343
	tryStart: function() {
2344
		if (!this.isRunning && this.canRunNext()) {
2345
			this.isRunning = true;
2346
			this.trigger('start');
2347
			this.runNext();
2348
		}
2349
	},
2350
2351
2352
	canRunNext: function() {
2353
		return !this.isPaused && this.q.length;
2354
	},
2355
2356
2357
	runNext: function() { // does not check canRunNext
2358
		this.runTask(this.q.shift());
2359
	},
2360
2361
2362
	runTask: function(task) {
2363
		this.runTaskFunc(task);
2364
	},
2365
2366
2367
	runTaskFunc: function(taskFunc) {
2368
		var _this = this;
2369
		var res = taskFunc();
2370
2371
		if (res && res.then) {
2372
			res.then(done);
2373
		}
2374
		else {
2375
			done();
2376
		}
2377
2378
		function done() {
2379
			if (_this.canRunNext()) {
2380
				_this.runNext();
2381
			}
2382
			else {
2383
				_this.isRunning = false;
2384
				_this.trigger('stop');
2385
			}
2386
		}
2387
	}
2388
2389
});
2390
2391
FC.TaskQueue = TaskQueue;
2392
2393
;;
2394
2395
var RenderQueue = TaskQueue.extend({
2396
2397
	waitsByNamespace: null,
2398
	waitNamespace: null,
2399
	waitId: null,
2400
2401
2402
	constructor: function(waitsByNamespace) {
2403
		TaskQueue.call(this); // super-constructor
2404
2405
		this.waitsByNamespace = waitsByNamespace || {};
2406
	},
2407
2408
2409
	queue: function(taskFunc, namespace, type) {
2410
		var task = {
2411
			func: taskFunc,
2412
			namespace: namespace,
2413
			type: type
2414
		};
2415
		var waitMs;
2416
2417
		if (namespace) {
2418
			waitMs = this.waitsByNamespace[namespace];
2419
		}
2420
2421
		if (this.waitNamespace) {
2422
			if (namespace === this.waitNamespace && waitMs != null) {
2423
				this.delayWait(waitMs);
2424
			}
2425
			else {
2426
				this.clearWait();
2427
				this.tryStart();
2428
			}
2429
		}
2430
2431
		if (this.compoundTask(task)) { // appended to queue?
2432
2433
			if (!this.waitNamespace && waitMs != null) {
2434
				this.startWait(namespace, waitMs);
2435
			}
2436
			else {
2437
				this.tryStart();
2438
			}
2439
		}
2440
	},
2441
2442
2443
	startWait: function(namespace, waitMs) {
2444
		this.waitNamespace = namespace;
2445
		this.spawnWait(waitMs);
2446
	},
2447
2448
2449
	delayWait: function(waitMs) {
2450
		clearTimeout(this.waitId);
2451
		this.spawnWait(waitMs);
2452
	},
2453
2454
2455
	spawnWait: function(waitMs) {
2456
		var _this = this;
2457
2458
		this.waitId = setTimeout(function() {
2459
			_this.waitNamespace = null;
2460
			_this.tryStart();
2461
		}, waitMs);
2462
	},
2463
2464
2465
	clearWait: function() {
2466
		if (this.waitNamespace) {
2467
			clearTimeout(this.waitId);
2468
			this.waitId = null;
2469
			this.waitNamespace = null;
2470
		}
2471
	},
2472
2473
2474
	canRunNext: function() {
2475
		if (!TaskQueue.prototype.canRunNext.apply(this, arguments)) {
2476
			return false;
2477
		}
2478
2479
		// waiting for a certain namespace to stop receiving tasks?
2480
		if (this.waitNamespace) {
2481
2482
			// if there was a different namespace task in the meantime,
2483
			// that forces all previously-waiting tasks to suddenly execute.
2484
			// TODO: find a way to do this in constant time.
2485
			for (var q = this.q, i = 0; i < q.length; i++) {
2486
				if (q[i].namespace !== this.waitNamespace) {
2487
					return true; // allow execution
2488
				}
2489
			}
2490
2491
			return false;
2492
		}
2493
2494
		return true;
2495
	},
2496
2497
2498
	runTask: function(task) {
2499
		this.runTaskFunc(task.func);
2500
	},
2501
2502
2503
	compoundTask: function(newTask) {
2504
		var q = this.q;
2505
		var shouldAppend = true;
2506
		var i, task;
2507
2508
		if (newTask.namespace) {
2509
2510
			if (newTask.type === 'destroy' || newTask.type === 'init') {
2511
2512
				// remove all add/remove ops with same namespace, regardless of order
2513
				for (i = q.length - 1; i >= 0; i--) {
2514
					task = q[i];
2515
2516
					if (
2517
						task.namespace === newTask.namespace &&
2518
						(task.type === 'add' || task.type === 'remove')
2519
					) {
2520
						q.splice(i, 1); // remove task
2521
					}
2522
				}
2523
2524
				if (newTask.type === 'destroy') {
2525
					// eat away final init/destroy operation
2526
					if (q.length) {
2527
						task = q[q.length - 1]; // last task
2528
2529
						if (task.namespace === newTask.namespace) {
2530
2531
							// the init and our destroy cancel each other out
2532
							if (task.type === 'init') {
2533
								shouldAppend = false;
2534
								q.pop();
2535
							}
2536
							// prefer to use the destroy operation that's already present
2537
							else if (task.type === 'destroy') {
2538
								shouldAppend = false;
2539
							}
2540
						}
2541
					}
2542
				}
2543
				else if (newTask.type === 'init') {
2544
					// eat away final init operation
2545
					if (q.length) {
2546
						task = q[q.length - 1]; // last task
2547
2548
						if (
2549
							task.namespace === newTask.namespace &&
2550
							task.type === 'init'
2551
						) {
2552
							// our init operation takes precedence
2553
							q.pop();
2554
						}
2555
					}
2556
				}
2557
			}
2558
		}
2559
2560
		if (shouldAppend) {
2561
			q.push(newTask);
2562
		}
2563
2564
		return shouldAppend;
2565
	}
2566
2567
});
2568
2569
FC.RenderQueue = RenderQueue;
2570
2571
;;
2572
2573
var EmitterMixin = FC.EmitterMixin = {
2574
2575
	// jQuery-ification via $(this) allows a non-DOM object to have
2576
	// the same event handling capabilities (including namespaces).
2577
2578
2579
	on: function(types, handler) {
2580
		$(this).on(types, this._prepareIntercept(handler));
2581
		return this; // for chaining
2582
	},
2583
2584
2585
	one: function(types, handler) {
2586
		$(this).one(types, this._prepareIntercept(handler));
2587
		return this; // for chaining
2588
	},
2589
2590
2591
	_prepareIntercept: function(handler) {
2592
		// handlers are always called with an "event" object as their first param.
2593
		// sneak the `this` context and arguments into the extra parameter object
2594
		// and forward them on to the original handler.
2595
		var intercept = function(ev, extra) {
2596
			return handler.apply(
2597
				extra.context || this,
2598
				extra.args || []
2599
			);
2600
		};
2601
2602
		// mimick jQuery's internal "proxy" system (risky, I know)
2603
		// causing all functions with the same .guid to appear to be the same.
2604
		// https://github.com/jquery/jquery/blob/2.2.4/src/core.js#L448
2605
		// this is needed for calling .off with the original non-intercept handler.
2606
		if (!handler.guid) {
2607
			handler.guid = $.guid++;
2608
		}
2609
		intercept.guid = handler.guid;
2610
2611
		return intercept;
2612
	},
2613
2614
2615
	off: function(types, handler) {
2616
		$(this).off(types, handler);
2617
2618
		return this; // for chaining
2619
	},
2620
2621
2622
	trigger: function(types) {
2623
		var args = Array.prototype.slice.call(arguments, 1); // arguments after the first
2624
2625
		// pass in "extra" info to the intercept
2626
		$(this).triggerHandler(types, { args: args });
2627
2628
		return this; // for chaining
2629
	},
2630
2631
2632
	triggerWith: function(types, context, args) {
2633
2634
		// `triggerHandler` is less reliant on the DOM compared to `trigger`.
2635
		// pass in "extra" info to the intercept.
2636
		$(this).triggerHandler(types, { context: context, args: args });
2637
2638
		return this; // for chaining
2639
	}
2640
2641
};
2642
2643
;;
2644
2645
/*
2646
Utility methods for easily listening to events on another object,
2647
and more importantly, easily unlistening from them.
2648
*/
2649
var ListenerMixin = FC.ListenerMixin = (function() {
2650
	var guid = 0;
2651
	var ListenerMixin = {
2652
2653
		listenerId: null,
2654
2655
		/*
2656
		Given an `other` object that has on/off methods, bind the given `callback` to an event by the given name.
2657
		The `callback` will be called with the `this` context of the object that .listenTo is being called on.
2658
		Can be called:
2659
			.listenTo(other, eventName, callback)
2660
		OR
2661
			.listenTo(other, {
2662
				eventName1: callback1,
2663
				eventName2: callback2
2664
			})
2665
		*/
2666
		listenTo: function(other, arg, callback) {
2667
			if (typeof arg === 'object') { // given dictionary of callbacks
2668
				for (var eventName in arg) {
2669
					if (arg.hasOwnProperty(eventName)) {
2670
						this.listenTo(other, eventName, arg[eventName]);
2671
					}
2672
				}
2673
			}
2674
			else if (typeof arg === 'string') {
2675
				other.on(
2676
					arg + '.' + this.getListenerNamespace(), // use event namespacing to identify this object
2677
					$.proxy(callback, this) // always use `this` context
2678
						// the usually-undesired jQuery guid behavior doesn't matter,
2679
						// because we always unbind via namespace
2680
				);
2681
			}
2682
		},
2683
2684
		/*
2685
		Causes the current object to stop listening to events on the `other` object.
2686
		`eventName` is optional. If omitted, will stop listening to ALL events on `other`.
2687
		*/
2688
		stopListeningTo: function(other, eventName) {
2689
			other.off((eventName || '') + '.' + this.getListenerNamespace());
2690
		},
2691
2692
		/*
2693
		Returns a string, unique to this object, to be used for event namespacing
2694
		*/
2695
		getListenerNamespace: function() {
2696
			if (this.listenerId == null) {
2697
				this.listenerId = guid++;
2698
			}
2699
			return '_listener' + this.listenerId;
2700
		}
2701
2702
	};
2703
	return ListenerMixin;
2704
})();
2705
;;
2706
2707
/* A rectangular panel that is absolutely positioned over other content
2708
------------------------------------------------------------------------------------------------------------------------
2709
Options:
2710
	- className (string)
2711
	- content (HTML string or jQuery element set)
2712
	- parentEl
2713
	- top
2714
	- left
2715
	- right (the x coord of where the right edge should be. not a "CSS" right)
2716
	- autoHide (boolean)
2717
	- show (callback)
2718
	- hide (callback)
2719
*/
2720
2721
var Popover = Class.extend(ListenerMixin, {
2722
2723
	isHidden: true,
2724
	options: null,
2725
	el: null, // the container element for the popover. generated by this object
2726
	margin: 10, // the space required between the popover and the edges of the scroll container
2727
2728
2729
	constructor: function(options) {
2730
		this.options = options || {};
2731
	},
2732
2733
2734
	// Shows the popover on the specified position. Renders it if not already
2735
	show: function() {
2736
		if (this.isHidden) {
2737
			if (!this.el) {
2738
				this.render();
2739
			}
2740
			this.el.show();
2741
			this.position();
2742
			this.isHidden = false;
2743
			this.trigger('show');
2744
		}
2745
	},
2746
2747
2748
	// Hides the popover, through CSS, but does not remove it from the DOM
2749
	hide: function() {
2750
		if (!this.isHidden) {
2751
			this.el.hide();
2752
			this.isHidden = true;
2753
			this.trigger('hide');
2754
		}
2755
	},
2756
2757
2758
	// Creates `this.el` and renders content inside of it
2759
	render: function() {
2760
		var _this = this;
2761
		var options = this.options;
2762
2763
		this.el = $('<div class="fc-popover"/>')
2764
			.addClass(options.className || '')
2765
			.css({
2766
				// position initially to the top left to avoid creating scrollbars
2767
				top: 0,
2768
				left: 0
2769
			})
2770
			.append(options.content)
2771
			.appendTo(options.parentEl);
2772
2773
		// when a click happens on anything inside with a 'fc-close' className, hide the popover
2774
		this.el.on('click', '.fc-close', function() {
2775
			_this.hide();
2776
		});
2777
2778
		if (options.autoHide) {
2779
			this.listenTo($(document), 'mousedown', this.documentMousedown);
2780
		}
2781
	},
2782
2783
2784
	// Triggered when the user clicks *anywhere* in the document, for the autoHide feature
2785
	documentMousedown: function(ev) {
2786
		// only hide the popover if the click happened outside the popover
2787
		if (this.el && !$(ev.target).closest(this.el).length) {
2788
			this.hide();
2789
		}
2790
	},
2791
2792
2793
	// Hides and unregisters any handlers
2794
	removeElement: function() {
2795
		this.hide();
2796
2797
		if (this.el) {
2798
			this.el.remove();
2799
			this.el = null;
2800
		}
2801
2802
		this.stopListeningTo($(document), 'mousedown');
2803
	},
2804
2805
2806
	// Positions the popover optimally, using the top/left/right options
2807
	position: function() {
2808
		var options = this.options;
2809
		var origin = this.el.offsetParent().offset();
2810
		var width = this.el.outerWidth();
2811
		var height = this.el.outerHeight();
2812
		var windowEl = $(window);
2813
		var viewportEl = getScrollParent(this.el);
2814
		var viewportTop;
2815
		var viewportLeft;
2816
		var viewportOffset;
2817
		var top; // the "position" (not "offset") values for the popover
2818
		var left; //
2819
2820
		// compute top and left
2821
		top = options.top || 0;
2822
		if (options.left !== undefined) {
2823
			left = options.left;
2824
		}
2825
		else if (options.right !== undefined) {
2826
			left = options.right - width; // derive the left value from the right value
2827
		}
2828
		else {
2829
			left = 0;
2830
		}
2831
2832
		if (viewportEl.is(window) || viewportEl.is(document)) { // normalize getScrollParent's result
2833
			viewportEl = windowEl;
2834
			viewportTop = 0; // the window is always at the top left
2835
			viewportLeft = 0; // (and .offset() won't work if called here)
2836
		}
2837
		else {
2838
			viewportOffset = viewportEl.offset();
2839
			viewportTop = viewportOffset.top;
2840
			viewportLeft = viewportOffset.left;
2841
		}
2842
2843
		// if the window is scrolled, it causes the visible area to be further down
2844
		viewportTop += windowEl.scrollTop();
2845
		viewportLeft += windowEl.scrollLeft();
2846
2847
		// constrain to the view port. if constrained by two edges, give precedence to top/left
2848
		if (options.viewportConstrain !== false) {
2849
			top = Math.min(top, viewportTop + viewportEl.outerHeight() - height - this.margin);
2850
			top = Math.max(top, viewportTop + this.margin);
2851
			left = Math.min(left, viewportLeft + viewportEl.outerWidth() - width - this.margin);
2852
			left = Math.max(left, viewportLeft + this.margin);
2853
		}
2854
2855
		this.el.css({
2856
			top: top - origin.top,
2857
			left: left - origin.left
2858
		});
2859
	},
2860
2861
2862
	// Triggers a callback. Calls a function in the option hash of the same name.
2863
	// Arguments beyond the first `name` are forwarded on.
2864
	// TODO: better code reuse for this. Repeat code
2865
	trigger: function(name) {
2866
		if (this.options[name]) {
2867
			this.options[name].apply(this, Array.prototype.slice.call(arguments, 1));
2868
		}
2869
	}
2870
2871
});
2872
2873
;;
2874
2875
/*
2876
A cache for the left/right/top/bottom/width/height values for one or more elements.
2877
Works with both offset (from topleft document) and position (from offsetParent).
2878
2879
options:
2880
- els
2881
- isHorizontal
2882
- isVertical
2883
*/
2884
var CoordCache = FC.CoordCache = Class.extend({
2885
2886
	els: null, // jQuery set (assumed to be siblings)
2887
	forcedOffsetParentEl: null, // options can override the natural offsetParent
2888
	origin: null, // {left,top} position of offsetParent of els
2889
	boundingRect: null, // constrain cordinates to this rectangle. {left,right,top,bottom} or null
2890
	isHorizontal: false, // whether to query for left/right/width
2891
	isVertical: false, // whether to query for top/bottom/height
2892
2893
	// arrays of coordinates (offsets from topleft of document)
2894
	lefts: null,
2895
	rights: null,
2896
	tops: null,
2897
	bottoms: null,
2898
2899
2900
	constructor: function(options) {
2901
		this.els = $(options.els);
2902
		this.isHorizontal = options.isHorizontal;
2903
		this.isVertical = options.isVertical;
2904
		this.forcedOffsetParentEl = options.offsetParent ? $(options.offsetParent) : null;
2905
	},
2906
2907
2908
	// Queries the els for coordinates and stores them.
2909
	// Call this method before using and of the get* methods below.
2910
	build: function() {
2911
		var offsetParentEl = this.forcedOffsetParentEl;
2912
		if (!offsetParentEl && this.els.length > 0) {
2913
			offsetParentEl = this.els.eq(0).offsetParent();
2914
		}
2915
2916
		this.origin = offsetParentEl ?
2917
			offsetParentEl.offset() :
2918
			null;
2919
2920
		this.boundingRect = this.queryBoundingRect();
2921
2922
		if (this.isHorizontal) {
2923
			this.buildElHorizontals();
2924
		}
2925
		if (this.isVertical) {
2926
			this.buildElVerticals();
2927
		}
2928
	},
2929
2930
2931
	// Destroys all internal data about coordinates, freeing memory
2932
	clear: function() {
2933
		this.origin = null;
2934
		this.boundingRect = null;
2935
		this.lefts = null;
2936
		this.rights = null;
2937
		this.tops = null;
2938
		this.bottoms = null;
2939
	},
2940
2941
2942
	// When called, if coord caches aren't built, builds them
2943
	ensureBuilt: function() {
2944
		if (!this.origin) {
2945
			this.build();
2946
		}
2947
	},
2948
2949
2950
	// Populates the left/right internal coordinate arrays
2951
	buildElHorizontals: function() {
2952
		var lefts = [];
2953
		var rights = [];
2954
2955
		this.els.each(function(i, node) {
2956
			var el = $(node);
2957
			var left = el.offset().left;
2958
			var width = el.outerWidth();
2959
2960
			lefts.push(left);
2961
			rights.push(left + width);
2962
		});
2963
2964
		this.lefts = lefts;
2965
		this.rights = rights;
2966
	},
2967
2968
2969
	// Populates the top/bottom internal coordinate arrays
2970
	buildElVerticals: function() {
2971
		var tops = [];
2972
		var bottoms = [];
2973
2974
		this.els.each(function(i, node) {
2975
			var el = $(node);
2976
			var top = el.offset().top;
2977
			var height = el.outerHeight();
2978
2979
			tops.push(top);
2980
			bottoms.push(top + height);
2981
		});
2982
2983
		this.tops = tops;
2984
		this.bottoms = bottoms;
2985
	},
2986
2987
2988
	// Given a left offset (from document left), returns the index of the el that it horizontally intersects.
2989
	// If no intersection is made, returns undefined.
2990
	getHorizontalIndex: function(leftOffset) {
2991
		this.ensureBuilt();
2992
2993
		var lefts = this.lefts;
2994
		var rights = this.rights;
2995
		var len = lefts.length;
2996
		var i;
2997
2998
		for (i = 0; i < len; i++) {
2999
			if (leftOffset >= lefts[i] && leftOffset < rights[i]) {
3000
				return i;
3001
			}
3002
		}
3003
	},
3004
3005
3006
	// Given a top offset (from document top), returns the index of the el that it vertically intersects.
3007
	// If no intersection is made, returns undefined.
3008
	getVerticalIndex: function(topOffset) {
3009
		this.ensureBuilt();
3010
3011
		var tops = this.tops;
3012
		var bottoms = this.bottoms;
3013
		var len = tops.length;
3014
		var i;
3015
3016
		for (i = 0; i < len; i++) {
3017
			if (topOffset >= tops[i] && topOffset < bottoms[i]) {
3018
				return i;
3019
			}
3020
		}
3021
	},
3022
3023
3024
	// Gets the left offset (from document left) of the element at the given index
3025
	getLeftOffset: function(leftIndex) {
3026
		this.ensureBuilt();
3027
		return this.lefts[leftIndex];
3028
	},
3029
3030
3031
	// Gets the left position (from offsetParent left) of the element at the given index
3032
	getLeftPosition: function(leftIndex) {
3033
		this.ensureBuilt();
3034
		return this.lefts[leftIndex] - this.origin.left;
3035
	},
3036
3037
3038
	// Gets the right offset (from document left) of the element at the given index.
3039
	// This value is NOT relative to the document's right edge, like the CSS concept of "right" would be.
3040
	getRightOffset: function(leftIndex) {
3041
		this.ensureBuilt();
3042
		return this.rights[leftIndex];
3043
	},
3044
3045
3046
	// Gets the right position (from offsetParent left) of the element at the given index.
3047
	// This value is NOT relative to the offsetParent's right edge, like the CSS concept of "right" would be.
3048
	getRightPosition: function(leftIndex) {
3049
		this.ensureBuilt();
3050
		return this.rights[leftIndex] - this.origin.left;
3051
	},
3052
3053
3054
	// Gets the width of the element at the given index
3055
	getWidth: function(leftIndex) {
3056
		this.ensureBuilt();
3057
		return this.rights[leftIndex] - this.lefts[leftIndex];
3058
	},
3059
3060
3061
	// Gets the top offset (from document top) of the element at the given index
3062
	getTopOffset: function(topIndex) {
3063
		this.ensureBuilt();
3064
		return this.tops[topIndex];
3065
	},
3066
3067
3068
	// Gets the top position (from offsetParent top) of the element at the given position
3069
	getTopPosition: function(topIndex) {
3070
		this.ensureBuilt();
3071
		return this.tops[topIndex] - this.origin.top;
3072
	},
3073
3074
	// Gets the bottom offset (from the document top) of the element at the given index.
3075
	// This value is NOT relative to the offsetParent's bottom edge, like the CSS concept of "bottom" would be.
3076
	getBottomOffset: function(topIndex) {
3077
		this.ensureBuilt();
3078
		return this.bottoms[topIndex];
3079
	},
3080
3081
3082
	// Gets the bottom position (from the offsetParent top) of the element at the given index.
3083
	// This value is NOT relative to the offsetParent's bottom edge, like the CSS concept of "bottom" would be.
3084
	getBottomPosition: function(topIndex) {
3085
		this.ensureBuilt();
3086
		return this.bottoms[topIndex] - this.origin.top;
3087
	},
3088
3089
3090
	// Gets the height of the element at the given index
3091
	getHeight: function(topIndex) {
3092
		this.ensureBuilt();
3093
		return this.bottoms[topIndex] - this.tops[topIndex];
3094
	},
3095
3096
3097
	// Bounding Rect
3098
	// TODO: decouple this from CoordCache
3099
3100
	// Compute and return what the elements' bounding rectangle is, from the user's perspective.
3101
	// Right now, only returns a rectangle if constrained by an overflow:scroll element.
3102
	// Returns null if there are no elements
3103
	queryBoundingRect: function() {
3104
		var scrollParentEl;
3105
3106
		if (this.els.length > 0) {
3107
			scrollParentEl = getScrollParent(this.els.eq(0));
3108
3109
			if (!scrollParentEl.is(document)) {
3110
				return getClientRect(scrollParentEl);
3111
			}
3112
		}
3113
3114
		return null;
3115
	},
3116
3117
	isPointInBounds: function(leftOffset, topOffset) {
3118
		return this.isLeftInBounds(leftOffset) && this.isTopInBounds(topOffset);
3119
	},
3120
3121
	isLeftInBounds: function(leftOffset) {
3122
		return !this.boundingRect || (leftOffset >= this.boundingRect.left && leftOffset < this.boundingRect.right);
3123
	},
3124
3125
	isTopInBounds: function(topOffset) {
3126
		return !this.boundingRect || (topOffset >= this.boundingRect.top && topOffset < this.boundingRect.bottom);
3127
	}
3128
3129
});
3130
3131
;;
3132
3133
/* Tracks a drag's mouse movement, firing various handlers
3134
----------------------------------------------------------------------------------------------------------------------*/
3135
// TODO: use Emitter
3136
3137
var DragListener = FC.DragListener = Class.extend(ListenerMixin, {
3138
3139
	options: null,
3140
	subjectEl: null,
3141
3142
	// coordinates of the initial mousedown
3143
	originX: null,
3144
	originY: null,
3145
3146
	// the wrapping element that scrolls, or MIGHT scroll if there's overflow.
3147
	// TODO: do this for wrappers that have overflow:hidden as well.
3148
	scrollEl: null,
3149
3150
	isInteracting: false,
3151
	isDistanceSurpassed: false,
3152
	isDelayEnded: false,
3153
	isDragging: false,
3154
	isTouch: false,
3155
	isGeneric: false, // initiated by 'dragstart' (jqui)
3156
3157
	delay: null,
3158
	delayTimeoutId: null,
3159
	minDistance: null,
3160
3161
	shouldCancelTouchScroll: true,
3162
	scrollAlwaysKills: false,
3163
3164
3165
	constructor: function(options) {
3166
		this.options = options || {};
3167
	},
3168
3169
3170
	// Interaction (high-level)
3171
	// -----------------------------------------------------------------------------------------------------------------
3172
3173
3174
	startInteraction: function(ev, extraOptions) {
3175
3176
		if (ev.type === 'mousedown') {
3177
			if (GlobalEmitter.get().shouldIgnoreMouse()) {
3178
				return;
3179
			}
3180
			else if (!isPrimaryMouseButton(ev)) {
3181
				return;
3182
			}
3183
			else {
3184
				ev.preventDefault(); // prevents native selection in most browsers
3185
			}
3186
		}
3187
3188
		if (!this.isInteracting) {
3189
3190
			// process options
3191
			extraOptions = extraOptions || {};
3192
			this.delay = firstDefined(extraOptions.delay, this.options.delay, 0);
3193
			this.minDistance = firstDefined(extraOptions.distance, this.options.distance, 0);
3194
			this.subjectEl = this.options.subjectEl;
3195
3196
			preventSelection($('body'));
3197
3198
			this.isInteracting = true;
3199
			this.isTouch = getEvIsTouch(ev);
3200
			this.isGeneric = ev.type === 'dragstart';
3201
			this.isDelayEnded = false;
3202
			this.isDistanceSurpassed = false;
3203
3204
			this.originX = getEvX(ev);
3205
			this.originY = getEvY(ev);
3206
			this.scrollEl = getScrollParent($(ev.target));
3207
3208
			this.bindHandlers();
3209
			this.initAutoScroll();
3210
			this.handleInteractionStart(ev);
3211
			this.startDelay(ev);
3212
3213
			if (!this.minDistance) {
3214
				this.handleDistanceSurpassed(ev);
3215
			}
3216
		}
3217
	},
3218
3219
3220
	handleInteractionStart: function(ev) {
3221
		this.trigger('interactionStart', ev);
3222
	},
3223
3224
3225
	endInteraction: function(ev, isCancelled) {
3226
		if (this.isInteracting) {
3227
			this.endDrag(ev);
3228
3229
			if (this.delayTimeoutId) {
3230
				clearTimeout(this.delayTimeoutId);
3231
				this.delayTimeoutId = null;
3232
			}
3233
3234
			this.destroyAutoScroll();
3235
			this.unbindHandlers();
3236
3237
			this.isInteracting = false;
3238
			this.handleInteractionEnd(ev, isCancelled);
3239
3240
			allowSelection($('body'));
3241
		}
3242
	},
3243
3244
3245
	handleInteractionEnd: function(ev, isCancelled) {
3246
		this.trigger('interactionEnd', ev, isCancelled || false);
3247
	},
3248
3249
3250
	// Binding To DOM
3251
	// -----------------------------------------------------------------------------------------------------------------
3252
3253
3254
	bindHandlers: function() {
3255
		// some browsers (Safari in iOS 10) don't allow preventDefault on touch events that are bound after touchstart,
3256
		// so listen to the GlobalEmitter singleton, which is always bound, instead of the document directly.
3257
		var globalEmitter = GlobalEmitter.get();
3258
3259
		if (this.isGeneric) {
3260
			this.listenTo($(document), { // might only work on iOS because of GlobalEmitter's bind :(
3261
				drag: this.handleMove,
3262
				dragstop: this.endInteraction
3263
			});
3264
		}
3265
		else if (this.isTouch) {
3266
			this.listenTo(globalEmitter, {
3267
				touchmove: this.handleTouchMove,
3268
				touchend: this.endInteraction,
3269
				scroll: this.handleTouchScroll
3270
			});
3271
		}
3272
		else {
3273
			this.listenTo(globalEmitter, {
3274
				mousemove: this.handleMouseMove,
3275
				mouseup: this.endInteraction
3276
			});
3277
		}
3278
3279
		this.listenTo(globalEmitter, {
3280
			selectstart: preventDefault, // don't allow selection while dragging
3281
			contextmenu: preventDefault // long taps would open menu on Chrome dev tools
3282
		});
3283
	},
3284
3285
3286
	unbindHandlers: function() {
3287
		this.stopListeningTo(GlobalEmitter.get());
3288
		this.stopListeningTo($(document)); // for isGeneric
3289
	},
3290
3291
3292
	// Drag (high-level)
3293
	// -----------------------------------------------------------------------------------------------------------------
3294
3295
3296
	// extraOptions ignored if drag already started
3297
	startDrag: function(ev, extraOptions) {
3298
		this.startInteraction(ev, extraOptions); // ensure interaction began
3299
3300
		if (!this.isDragging) {
3301
			this.isDragging = true;
3302
			this.handleDragStart(ev);
3303
		}
3304
	},
3305
3306
3307
	handleDragStart: function(ev) {
3308
		this.trigger('dragStart', ev);
3309
	},
3310
3311
3312
	handleMove: function(ev) {
3313
		var dx = getEvX(ev) - this.originX;
3314
		var dy = getEvY(ev) - this.originY;
3315
		var minDistance = this.minDistance;
3316
		var distanceSq; // current distance from the origin, squared
3317
3318
		if (!this.isDistanceSurpassed) {
3319
			distanceSq = dx * dx + dy * dy;
3320
			if (distanceSq >= minDistance * minDistance) { // use pythagorean theorem
3321
				this.handleDistanceSurpassed(ev);
3322
			}
3323
		}
3324
3325
		if (this.isDragging) {
3326
			this.handleDrag(dx, dy, ev);
3327
		}
3328
	},
3329
3330
3331
	// Called while the mouse is being moved and when we know a legitimate drag is taking place
3332
	handleDrag: function(dx, dy, ev) {
3333
		this.trigger('drag', dx, dy, ev);
3334
		this.updateAutoScroll(ev); // will possibly cause scrolling
3335
	},
3336
3337
3338
	endDrag: function(ev) {
3339
		if (this.isDragging) {
3340
			this.isDragging = false;
3341
			this.handleDragEnd(ev);
3342
		}
3343
	},
3344
3345
3346
	handleDragEnd: function(ev) {
3347
		this.trigger('dragEnd', ev);
3348
	},
3349
3350
3351
	// Delay
3352
	// -----------------------------------------------------------------------------------------------------------------
3353
3354
3355
	startDelay: function(initialEv) {
3356
		var _this = this;
3357
3358
		if (this.delay) {
3359
			this.delayTimeoutId = setTimeout(function() {
3360
				_this.handleDelayEnd(initialEv);
3361
			}, this.delay);
3362
		}
3363
		else {
3364
			this.handleDelayEnd(initialEv);
3365
		}
3366
	},
3367
3368
3369
	handleDelayEnd: function(initialEv) {
3370
		this.isDelayEnded = true;
3371
3372
		if (this.isDistanceSurpassed) {
3373
			this.startDrag(initialEv);
3374
		}
3375
	},
3376
3377
3378
	// Distance
3379
	// -----------------------------------------------------------------------------------------------------------------
3380
3381
3382
	handleDistanceSurpassed: function(ev) {
3383
		this.isDistanceSurpassed = true;
3384
3385
		if (this.isDelayEnded) {
3386
			this.startDrag(ev);
3387
		}
3388
	},
3389
3390
3391
	// Mouse / Touch
3392
	// -----------------------------------------------------------------------------------------------------------------
3393
3394
3395
	handleTouchMove: function(ev) {
3396
3397
		// prevent inertia and touchmove-scrolling while dragging
3398
		if (this.isDragging && this.shouldCancelTouchScroll) {
3399
			ev.preventDefault();
3400
		}
3401
3402
		this.handleMove(ev);
3403
	},
3404
3405
3406
	handleMouseMove: function(ev) {
3407
		this.handleMove(ev);
3408
	},
3409
3410
3411
	// Scrolling (unrelated to auto-scroll)
3412
	// -----------------------------------------------------------------------------------------------------------------
3413
3414
3415
	handleTouchScroll: function(ev) {
3416
		// if the drag is being initiated by touch, but a scroll happens before
3417
		// the drag-initiating delay is over, cancel the drag
3418
		if (!this.isDragging || this.scrollAlwaysKills) {
3419
			this.endInteraction(ev, true); // isCancelled=true
3420
		}
3421
	},
3422
3423
3424
	// Utils
3425
	// -----------------------------------------------------------------------------------------------------------------
3426
3427
3428
	// Triggers a callback. Calls a function in the option hash of the same name.
3429
	// Arguments beyond the first `name` are forwarded on.
3430
	trigger: function(name) {
3431
		if (this.options[name]) {
3432
			this.options[name].apply(this, Array.prototype.slice.call(arguments, 1));
3433
		}
3434
		// makes _methods callable by event name. TODO: kill this
3435
		if (this['_' + name]) {
3436
			this['_' + name].apply(this, Array.prototype.slice.call(arguments, 1));
3437
		}
3438
	}
3439
3440
3441
});
3442
3443
;;
3444
/*
3445
this.scrollEl is set in DragListener
3446
*/
3447
DragListener.mixin({
3448
3449
	isAutoScroll: false,
3450
3451
	scrollBounds: null, // { top, bottom, left, right }
3452
	scrollTopVel: null, // pixels per second
3453
	scrollLeftVel: null, // pixels per second
3454
	scrollIntervalId: null, // ID of setTimeout for scrolling animation loop
3455
3456
	// defaults
3457
	scrollSensitivity: 30, // pixels from edge for scrolling to start
3458
	scrollSpeed: 200, // pixels per second, at maximum speed
3459
	scrollIntervalMs: 50, // millisecond wait between scroll increment
3460
3461
3462
	initAutoScroll: function() {
3463
		var scrollEl = this.scrollEl;
3464
3465
		this.isAutoScroll =
3466
			this.options.scroll &&
3467
			scrollEl &&
3468
			!scrollEl.is(window) &&
3469
			!scrollEl.is(document);
3470
3471
		if (this.isAutoScroll) {
3472
			// debounce makes sure rapid calls don't happen
3473
			this.listenTo(scrollEl, 'scroll', debounce(this.handleDebouncedScroll, 100));
3474
		}
3475
	},
3476
3477
3478
	destroyAutoScroll: function() {
3479
		this.endAutoScroll(); // kill any animation loop
3480
3481
		// remove the scroll handler if there is a scrollEl
3482
		if (this.isAutoScroll) {
3483
			this.stopListeningTo(this.scrollEl, 'scroll'); // will probably get removed by unbindHandlers too :(
3484
		}
3485
	},
3486
3487
3488
	// Computes and stores the bounding rectangle of scrollEl
3489
	computeScrollBounds: function() {
3490
		if (this.isAutoScroll) {
3491
			this.scrollBounds = getOuterRect(this.scrollEl);
3492
			// TODO: use getClientRect in future. but prevents auto scrolling when on top of scrollbars
3493
		}
3494
	},
3495
3496
3497
	// Called when the dragging is in progress and scrolling should be updated
3498
	updateAutoScroll: function(ev) {
3499
		var sensitivity = this.scrollSensitivity;
3500
		var bounds = this.scrollBounds;
3501
		var topCloseness, bottomCloseness;
3502
		var leftCloseness, rightCloseness;
3503
		var topVel = 0;
3504
		var leftVel = 0;
3505
3506
		if (bounds) { // only scroll if scrollEl exists
3507
3508
			// compute closeness to edges. valid range is from 0.0 - 1.0
3509
			topCloseness = (sensitivity - (getEvY(ev) - bounds.top)) / sensitivity;
3510
			bottomCloseness = (sensitivity - (bounds.bottom - getEvY(ev))) / sensitivity;
3511
			leftCloseness = (sensitivity - (getEvX(ev) - bounds.left)) / sensitivity;
3512
			rightCloseness = (sensitivity - (bounds.right - getEvX(ev))) / sensitivity;
3513
3514
			// translate vertical closeness into velocity.
3515
			// mouse must be completely in bounds for velocity to happen.
3516
			if (topCloseness >= 0 && topCloseness <= 1) {
3517
				topVel = topCloseness * this.scrollSpeed * -1; // negative. for scrolling up
3518
			}
3519
			else if (bottomCloseness >= 0 && bottomCloseness <= 1) {
3520
				topVel = bottomCloseness * this.scrollSpeed;
3521
			}
3522
3523
			// translate horizontal closeness into velocity
3524
			if (leftCloseness >= 0 && leftCloseness <= 1) {
3525
				leftVel = leftCloseness * this.scrollSpeed * -1; // negative. for scrolling left
3526
			}
3527
			else if (rightCloseness >= 0 && rightCloseness <= 1) {
3528
				leftVel = rightCloseness * this.scrollSpeed;
3529
			}
3530
		}
3531
3532
		this.setScrollVel(topVel, leftVel);
3533
	},
3534
3535
3536
	// Sets the speed-of-scrolling for the scrollEl
3537
	setScrollVel: function(topVel, leftVel) {
3538
3539
		this.scrollTopVel = topVel;
3540
		this.scrollLeftVel = leftVel;
3541
3542
		this.constrainScrollVel(); // massages into realistic values
3543
3544
		// if there is non-zero velocity, and an animation loop hasn't already started, then START
3545
		if ((this.scrollTopVel || this.scrollLeftVel) && !this.scrollIntervalId) {
3546
			this.scrollIntervalId = setInterval(
3547
				proxy(this, 'scrollIntervalFunc'), // scope to `this`
3548
				this.scrollIntervalMs
3549
			);
3550
		}
3551
	},
3552
3553
3554
	// Forces scrollTopVel and scrollLeftVel to be zero if scrolling has already gone all the way
3555
	constrainScrollVel: function() {
3556
		var el = this.scrollEl;
3557
3558
		if (this.scrollTopVel < 0) { // scrolling up?
3559
			if (el.scrollTop() <= 0) { // already scrolled all the way up?
3560
				this.scrollTopVel = 0;
3561
			}
3562
		}
3563
		else if (this.scrollTopVel > 0) { // scrolling down?
3564
			if (el.scrollTop() + el[0].clientHeight >= el[0].scrollHeight) { // already scrolled all the way down?
3565
				this.scrollTopVel = 0;
3566
			}
3567
		}
3568
3569
		if (this.scrollLeftVel < 0) { // scrolling left?
3570
			if (el.scrollLeft() <= 0) { // already scrolled all the left?
3571
				this.scrollLeftVel = 0;
3572
			}
3573
		}
3574
		else if (this.scrollLeftVel > 0) { // scrolling right?
3575
			if (el.scrollLeft() + el[0].clientWidth >= el[0].scrollWidth) { // already scrolled all the way right?
3576
				this.scrollLeftVel = 0;
3577
			}
3578
		}
3579
	},
3580
3581
3582
	// This function gets called during every iteration of the scrolling animation loop
3583
	scrollIntervalFunc: function() {
3584
		var el = this.scrollEl;
3585
		var frac = this.scrollIntervalMs / 1000; // considering animation frequency, what the vel should be mult'd by
3586
3587
		// change the value of scrollEl's scroll
3588
		if (this.scrollTopVel) {
3589
			el.scrollTop(el.scrollTop() + this.scrollTopVel * frac);
3590
		}
3591
		if (this.scrollLeftVel) {
3592
			el.scrollLeft(el.scrollLeft() + this.scrollLeftVel * frac);
3593
		}
3594
3595
		this.constrainScrollVel(); // since the scroll values changed, recompute the velocities
3596
3597
		// if scrolled all the way, which causes the vels to be zero, stop the animation loop
3598
		if (!this.scrollTopVel && !this.scrollLeftVel) {
3599
			this.endAutoScroll();
3600
		}
3601
	},
3602
3603
3604
	// Kills any existing scrolling animation loop
3605
	endAutoScroll: function() {
3606
		if (this.scrollIntervalId) {
3607
			clearInterval(this.scrollIntervalId);
3608
			this.scrollIntervalId = null;
3609
3610
			this.handleScrollEnd();
3611
		}
3612
	},
3613
3614
3615
	// Get called when the scrollEl is scrolled (NOTE: this is delayed via debounce)
3616
	handleDebouncedScroll: function() {
3617
		// recompute all coordinates, but *only* if this is *not* part of our scrolling animation
3618
		if (!this.scrollIntervalId) {
3619
			this.handleScrollEnd();
3620
		}
3621
	},
3622
3623
3624
	// Called when scrolling has stopped, whether through auto scroll, or the user scrolling
3625
	handleScrollEnd: function() {
3626
	}
3627
3628
});
3629
;;
3630
3631
/* Tracks mouse movements over a component and raises events about which hit the mouse is over.
3632
------------------------------------------------------------------------------------------------------------------------
3633
options:
3634
- subjectEl
3635
- subjectCenter
3636
*/
3637
3638
var HitDragListener = DragListener.extend({
3639
3640
	component: null, // converts coordinates to hits
3641
		// methods: hitsNeeded, hitsNotNeeded, queryHit
3642
3643
	origHit: null, // the hit the mouse was over when listening started
3644
	hit: null, // the hit the mouse is over
3645
	coordAdjust: null, // delta that will be added to the mouse coordinates when computing collisions
3646
3647
3648
	constructor: function(component, options) {
3649
		DragListener.call(this, options); // call the super-constructor
3650
3651
		this.component = component;
3652
	},
3653
3654
3655
	// Called when drag listening starts (but a real drag has not necessarily began).
3656
	// ev might be undefined if dragging was started manually.
3657
	handleInteractionStart: function(ev) {
3658
		var subjectEl = this.subjectEl;
3659
		var subjectRect;
3660
		var origPoint;
3661
		var point;
3662
3663
		this.component.hitsNeeded();
3664
		this.computeScrollBounds(); // for autoscroll
3665
3666
		if (ev) {
3667
			origPoint = { left: getEvX(ev), top: getEvY(ev) };
3668
			point = origPoint;
3669
3670
			// constrain the point to bounds of the element being dragged
3671
			if (subjectEl) {
3672
				subjectRect = getOuterRect(subjectEl); // used for centering as well
3673
				point = constrainPoint(point, subjectRect);
3674
			}
3675
3676
			this.origHit = this.queryHit(point.left, point.top);
3677
3678
			// treat the center of the subject as the collision point?
3679
			if (subjectEl && this.options.subjectCenter) {
3680
3681
				// only consider the area the subject overlaps the hit. best for large subjects.
3682
				// TODO: skip this if hit didn't supply left/right/top/bottom
3683
				if (this.origHit) {
3684
					subjectRect = intersectRects(this.origHit, subjectRect) ||
3685
						subjectRect; // in case there is no intersection
3686
				}
3687
3688
				point = getRectCenter(subjectRect);
3689
			}
3690
3691
			this.coordAdjust = diffPoints(point, origPoint); // point - origPoint
3692
		}
3693
		else {
3694
			this.origHit = null;
3695
			this.coordAdjust = null;
3696
		}
3697
3698
		// call the super-method. do it after origHit has been computed
3699
		DragListener.prototype.handleInteractionStart.apply(this, arguments);
3700
	},
3701
3702
3703
	// Called when the actual drag has started
3704
	handleDragStart: function(ev) {
3705
		var hit;
3706
3707
		DragListener.prototype.handleDragStart.apply(this, arguments); // call the super-method
3708
3709
		// might be different from this.origHit if the min-distance is large
3710
		hit = this.queryHit(getEvX(ev), getEvY(ev));
3711
3712
		// report the initial hit the mouse is over
3713
		// especially important if no min-distance and drag starts immediately
3714
		if (hit) {
3715
			this.handleHitOver(hit);
3716
		}
3717
	},
3718
3719
3720
	// Called when the drag moves
3721
	handleDrag: function(dx, dy, ev) {
3722
		var hit;
3723
3724
		DragListener.prototype.handleDrag.apply(this, arguments); // call the super-method
3725
3726
		hit = this.queryHit(getEvX(ev), getEvY(ev));
3727
3728
		if (!isHitsEqual(hit, this.hit)) { // a different hit than before?
3729
			if (this.hit) {
3730
				this.handleHitOut();
3731
			}
3732
			if (hit) {
3733
				this.handleHitOver(hit);
3734
			}
3735
		}
3736
	},
3737
3738
3739
	// Called when dragging has been stopped
3740
	handleDragEnd: function() {
3741
		this.handleHitDone();
3742
		DragListener.prototype.handleDragEnd.apply(this, arguments); // call the super-method
3743
	},
3744
3745
3746
	// Called when a the mouse has just moved over a new hit
3747
	handleHitOver: function(hit) {
3748
		var isOrig = isHitsEqual(hit, this.origHit);
3749
3750
		this.hit = hit;
3751
3752
		this.trigger('hitOver', this.hit, isOrig, this.origHit);
3753
	},
3754
3755
3756
	// Called when the mouse has just moved out of a hit
3757
	handleHitOut: function() {
3758
		if (this.hit) {
3759
			this.trigger('hitOut', this.hit);
3760
			this.handleHitDone();
3761
			this.hit = null;
3762
		}
3763
	},
3764
3765
3766
	// Called after a hitOut. Also called before a dragStop
3767
	handleHitDone: function() {
3768
		if (this.hit) {
3769
			this.trigger('hitDone', this.hit);
3770
		}
3771
	},
3772
3773
3774
	// Called when the interaction ends, whether there was a real drag or not
3775
	handleInteractionEnd: function() {
3776
		DragListener.prototype.handleInteractionEnd.apply(this, arguments); // call the super-method
3777
3778
		this.origHit = null;
3779
		this.hit = null;
3780
3781
		this.component.hitsNotNeeded();
3782
	},
3783
3784
3785
	// Called when scrolling has stopped, whether through auto scroll, or the user scrolling
3786
	handleScrollEnd: function() {
3787
		DragListener.prototype.handleScrollEnd.apply(this, arguments); // call the super-method
3788
3789
		// hits' absolute positions will be in new places after a user's scroll.
3790
		// HACK for recomputing.
3791
		if (this.isDragging) {
3792
			this.component.releaseHits();
3793
			this.component.prepareHits();
3794
		}
3795
	},
3796
3797
3798
	// Gets the hit underneath the coordinates for the given mouse event
3799
	queryHit: function(left, top) {
3800
3801
		if (this.coordAdjust) {
3802
			left += this.coordAdjust.left;
3803
			top += this.coordAdjust.top;
3804
		}
3805
3806
		return this.component.queryHit(left, top);
3807
	}
3808
3809
});
3810
3811
3812
// Returns `true` if the hits are identically equal. `false` otherwise. Must be from the same component.
3813
// Two null values will be considered equal, as two "out of the component" states are the same.
3814
function isHitsEqual(hit0, hit1) {
3815
3816
	if (!hit0 && !hit1) {
3817
		return true;
3818
	}
3819
3820
	if (hit0 && hit1) {
3821
		return hit0.component === hit1.component &&
3822
			isHitPropsWithin(hit0, hit1) &&
3823
			isHitPropsWithin(hit1, hit0); // ensures all props are identical
3824
	}
3825
3826
	return false;
3827
}
3828
3829
3830
// Returns true if all of subHit's non-standard properties are within superHit
3831
function isHitPropsWithin(subHit, superHit) {
3832
	for (var propName in subHit) {
3833
		if (!/^(component|left|right|top|bottom)$/.test(propName)) {
3834
			if (subHit[propName] !== superHit[propName]) {
3835
				return false;
3836
			}
3837
		}
3838
	}
3839
	return true;
3840
}
3841
3842
;;
3843
3844
/*
3845
Listens to document and window-level user-interaction events, like touch events and mouse events,
3846
and fires these events as-is to whoever is observing a GlobalEmitter.
3847
Best when used as a singleton via GlobalEmitter.get()
3848
3849
Normalizes mouse/touch events. For examples:
3850
- ignores the the simulated mouse events that happen after a quick tap: mousemove+mousedown+mouseup+click
3851
- compensates for various buggy scenarios where a touchend does not fire
3852
*/
3853
3854
FC.touchMouseIgnoreWait = 500;
3855
3856
var GlobalEmitter = Class.extend(ListenerMixin, EmitterMixin, {
3857
3858
	isTouching: false,
3859
	mouseIgnoreDepth: 0,
3860
	handleScrollProxy: null,
3861
3862
3863
	bind: function() {
3864
		var _this = this;
3865
3866
		this.listenTo($(document), {
3867
			touchstart: this.handleTouchStart,
3868
			touchcancel: this.handleTouchCancel,
3869
			touchend: this.handleTouchEnd,
3870
			mousedown: this.handleMouseDown,
3871
			mousemove: this.handleMouseMove,
3872
			mouseup: this.handleMouseUp,
3873
			click: this.handleClick,
3874
			selectstart: this.handleSelectStart,
3875
			contextmenu: this.handleContextMenu
3876
		});
3877
3878
		// because we need to call preventDefault
3879
		// because https://www.chromestatus.com/features/5093566007214080
3880
		// TODO: investigate performance because this is a global handler
3881
		window.addEventListener(
3882
			'touchmove',
3883
			this.handleTouchMoveProxy = function(ev) {
3884
				_this.handleTouchMove($.Event(ev));
3885
			},
3886
			{ passive: false } // allows preventDefault()
3887
		);
3888
3889
		// attach a handler to get called when ANY scroll action happens on the page.
3890
		// this was impossible to do with normal on/off because 'scroll' doesn't bubble.
3891
		// http://stackoverflow.com/a/32954565/96342
3892
		window.addEventListener(
3893
			'scroll',
3894
			this.handleScrollProxy = function(ev) {
3895
				_this.handleScroll($.Event(ev));
3896
			},
3897
			true // useCapture
3898
		);
3899
	},
3900
3901
	unbind: function() {
3902
		this.stopListeningTo($(document));
3903
3904
		window.removeEventListener(
3905
			'touchmove',
3906
			this.handleTouchMoveProxy
3907
		);
3908
3909
		window.removeEventListener(
3910
			'scroll',
3911
			this.handleScrollProxy,
3912
			true // useCapture
3913
		);
3914
	},
3915
3916
3917
	// Touch Handlers
3918
	// -----------------------------------------------------------------------------------------------------------------
3919
3920
	handleTouchStart: function(ev) {
3921
3922
		// if a previous touch interaction never ended with a touchend, then implicitly end it,
3923
		// but since a new touch interaction is about to begin, don't start the mouse ignore period.
3924
		this.stopTouch(ev, true); // skipMouseIgnore=true
3925
3926
		this.isTouching = true;
3927
		this.trigger('touchstart', ev);
3928
	},
3929
3930
	handleTouchMove: function(ev) {
3931
		if (this.isTouching) {
3932
			this.trigger('touchmove', ev);
3933
		}
3934
	},
3935
3936
	handleTouchCancel: function(ev) {
3937
		if (this.isTouching) {
3938
			this.trigger('touchcancel', ev);
3939
3940
			// Have touchcancel fire an artificial touchend. That way, handlers won't need to listen to both.
3941
			// If touchend fires later, it won't have any effect b/c isTouching will be false.
3942
			this.stopTouch(ev);
3943
		}
3944
	},
3945
3946
	handleTouchEnd: function(ev) {
3947
		this.stopTouch(ev);
3948
	},
3949
3950
3951
	// Mouse Handlers
3952
	// -----------------------------------------------------------------------------------------------------------------
3953
3954
	handleMouseDown: function(ev) {
3955
		if (!this.shouldIgnoreMouse()) {
3956
			this.trigger('mousedown', ev);
3957
		}
3958
	},
3959
3960
	handleMouseMove: function(ev) {
3961
		if (!this.shouldIgnoreMouse()) {
3962
			this.trigger('mousemove', ev);
3963
		}
3964
	},
3965
3966
	handleMouseUp: function(ev) {
3967
		if (!this.shouldIgnoreMouse()) {
3968
			this.trigger('mouseup', ev);
3969
		}
3970
	},
3971
3972
	handleClick: function(ev) {
3973
		if (!this.shouldIgnoreMouse()) {
3974
			this.trigger('click', ev);
3975
		}
3976
	},
3977
3978
3979
	// Misc Handlers
3980
	// -----------------------------------------------------------------------------------------------------------------
3981
3982
	handleSelectStart: function(ev) {
3983
		this.trigger('selectstart', ev);
3984
	},
3985
3986
	handleContextMenu: function(ev) {
3987
		this.trigger('contextmenu', ev);
3988
	},
3989
3990
	handleScroll: function(ev) {
3991
		this.trigger('scroll', ev);
3992
	},
3993
3994
3995
	// Utils
3996
	// -----------------------------------------------------------------------------------------------------------------
3997
3998
	stopTouch: function(ev, skipMouseIgnore) {
3999
		if (this.isTouching) {
4000
			this.isTouching = false;
4001
			this.trigger('touchend', ev);
4002
4003
			if (!skipMouseIgnore) {
4004
				this.startTouchMouseIgnore();
4005
			}
4006
		}
4007
	},
4008
4009
	startTouchMouseIgnore: function() {
4010
		var _this = this;
4011
		var wait = FC.touchMouseIgnoreWait;
4012
4013
		if (wait) {
4014
			this.mouseIgnoreDepth++;
4015
			setTimeout(function() {
4016
				_this.mouseIgnoreDepth--;
4017
			}, wait);
4018
		}
4019
	},
4020
4021
	shouldIgnoreMouse: function() {
4022
		return this.isTouching || Boolean(this.mouseIgnoreDepth);
4023
	}
4024
4025
});
4026
4027
4028
// Singleton
4029
// ---------------------------------------------------------------------------------------------------------------------
4030
4031
(function() {
4032
	var globalEmitter = null;
4033
	var neededCount = 0;
4034
4035
4036
	// gets the singleton
4037
	GlobalEmitter.get = function() {
4038
4039
		if (!globalEmitter) {
4040
			globalEmitter = new GlobalEmitter();
4041
			globalEmitter.bind();
4042
		}
4043
4044
		return globalEmitter;
4045
	};
4046
4047
4048
	// called when an object knows it will need a GlobalEmitter in the near future.
4049
	GlobalEmitter.needed = function() {
4050
		GlobalEmitter.get(); // ensures globalEmitter
4051
		neededCount++;
4052
	};
4053
4054
4055
	// called when the object that originally called needed() doesn't need a GlobalEmitter anymore.
4056
	GlobalEmitter.unneeded = function() {
4057
		neededCount--;
4058
4059
		if (!neededCount) { // nobody else needs it
4060
			globalEmitter.unbind();
4061
			globalEmitter = null;
4062
		}
4063
	};
4064
4065
})();
4066
4067
;;
4068
4069
/* Creates a clone of an element and lets it track the mouse as it moves
4070
----------------------------------------------------------------------------------------------------------------------*/
4071
4072
var MouseFollower = Class.extend(ListenerMixin, {
4073
4074
	options: null,
4075
4076
	sourceEl: null, // the element that will be cloned and made to look like it is dragging
4077
	el: null, // the clone of `sourceEl` that will track the mouse
4078
	parentEl: null, // the element that `el` (the clone) will be attached to
4079
4080
	// the initial position of el, relative to the offset parent. made to match the initial offset of sourceEl
4081
	top0: null,
4082
	left0: null,
4083
4084
	// the absolute coordinates of the initiating touch/mouse action
4085
	y0: null,
4086
	x0: null,
4087
4088
	// the number of pixels the mouse has moved from its initial position
4089
	topDelta: null,
4090
	leftDelta: null,
4091
4092
	isFollowing: false,
4093
	isHidden: false,
4094
	isAnimating: false, // doing the revert animation?
4095
4096
	constructor: function(sourceEl, options) {
4097
		this.options = options = options || {};
4098
		this.sourceEl = sourceEl;
4099
		this.parentEl = options.parentEl ? $(options.parentEl) : sourceEl.parent(); // default to sourceEl's parent
4100
	},
4101
4102
4103
	// Causes the element to start following the mouse
4104
	start: function(ev) {
4105
		if (!this.isFollowing) {
4106
			this.isFollowing = true;
4107
4108
			this.y0 = getEvY(ev);
4109
			this.x0 = getEvX(ev);
4110
			this.topDelta = 0;
4111
			this.leftDelta = 0;
4112
4113
			if (!this.isHidden) {
4114
				this.updatePosition();
4115
			}
4116
4117
			if (getEvIsTouch(ev)) {
4118
				this.listenTo($(document), 'touchmove', this.handleMove);
4119
			}
4120
			else {
4121
				this.listenTo($(document), 'mousemove', this.handleMove);
4122
			}
4123
		}
4124
	},
4125
4126
4127
	// Causes the element to stop following the mouse. If shouldRevert is true, will animate back to original position.
4128
	// `callback` gets invoked when the animation is complete. If no animation, it is invoked immediately.
4129
	stop: function(shouldRevert, callback) {
4130
		var _this = this;
4131
		var revertDuration = this.options.revertDuration;
4132
4133
		function complete() { // might be called by .animate(), which might change `this` context
4134
			_this.isAnimating = false;
4135
			_this.removeElement();
4136
4137
			_this.top0 = _this.left0 = null; // reset state for future updatePosition calls
4138
4139
			if (callback) {
4140
				callback();
4141
			}
4142
		}
4143
4144
		if (this.isFollowing && !this.isAnimating) { // disallow more than one stop animation at a time
4145
			this.isFollowing = false;
4146
4147
			this.stopListeningTo($(document));
4148
4149
			if (shouldRevert && revertDuration && !this.isHidden) { // do a revert animation?
4150
				this.isAnimating = true;
4151
				this.el.animate({
4152
					top: this.top0,
4153
					left: this.left0
4154
				}, {
4155
					duration: revertDuration,
4156
					complete: complete
4157
				});
4158
			}
4159
			else {
4160
				complete();
4161
			}
4162
		}
4163
	},
4164
4165
4166
	// Gets the tracking element. Create it if necessary
4167
	getEl: function() {
4168
		var el = this.el;
4169
4170
		if (!el) {
4171
			el = this.el = this.sourceEl.clone()
4172
				.addClass(this.options.additionalClass || '')
4173
				.css({
4174
					position: 'absolute',
4175
					visibility: '', // in case original element was hidden (commonly through hideEvents())
4176
					display: this.isHidden ? 'none' : '', // for when initially hidden
4177
					margin: 0,
4178
					right: 'auto', // erase and set width instead
4179
					bottom: 'auto', // erase and set height instead
4180
					width: this.sourceEl.width(), // explicit height in case there was a 'right' value
4181
					height: this.sourceEl.height(), // explicit width in case there was a 'bottom' value
4182
					opacity: this.options.opacity || '',
4183
					zIndex: this.options.zIndex
4184
				});
4185
4186
			// we don't want long taps or any mouse interaction causing selection/menus.
4187
			// would use preventSelection(), but that prevents selectstart, causing problems.
4188
			el.addClass('fc-unselectable');
4189
4190
			el.appendTo(this.parentEl);
4191
		}
4192
4193
		return el;
4194
	},
4195
4196
4197
	// Removes the tracking element if it has already been created
4198
	removeElement: function() {
4199
		if (this.el) {
4200
			this.el.remove();
4201
			this.el = null;
4202
		}
4203
	},
4204
4205
4206
	// Update the CSS position of the tracking element
4207
	updatePosition: function() {
4208
		var sourceOffset;
4209
		var origin;
4210
4211
		this.getEl(); // ensure this.el
4212
4213
		// make sure origin info was computed
4214
		if (this.top0 === null) {
4215
			sourceOffset = this.sourceEl.offset();
4216
			origin = this.el.offsetParent().offset();
4217
			this.top0 = sourceOffset.top - origin.top;
4218
			this.left0 = sourceOffset.left - origin.left;
4219
		}
4220
4221
		this.el.css({
4222
			top: this.top0 + this.topDelta,
4223
			left: this.left0 + this.leftDelta
4224
		});
4225
	},
4226
4227
4228
	// Gets called when the user moves the mouse
4229
	handleMove: function(ev) {
4230
		this.topDelta = getEvY(ev) - this.y0;
4231
		this.leftDelta = getEvX(ev) - this.x0;
4232
4233
		if (!this.isHidden) {
4234
			this.updatePosition();
4235
		}
4236
	},
4237
4238
4239
	// Temporarily makes the tracking element invisible. Can be called before following starts
4240
	hide: function() {
4241
		if (!this.isHidden) {
4242
			this.isHidden = true;
4243
			if (this.el) {
4244
				this.el.hide();
4245
			}
4246
		}
4247
	},
4248
4249
4250
	// Show the tracking element after it has been temporarily hidden
4251
	show: function() {
4252
		if (this.isHidden) {
4253
			this.isHidden = false;
4254
			this.updatePosition();
4255
			this.getEl().show();
4256
		}
4257
	}
4258
4259
});
4260
4261
;;
4262
4263
/* An abstract class comprised of a "grid" of areas that each represent a specific datetime
4264
----------------------------------------------------------------------------------------------------------------------*/
4265
4266
var Grid = FC.Grid = Class.extend(ListenerMixin, {
4267
4268
	// self-config, overridable by subclasses
4269
	hasDayInteractions: true, // can user click/select ranges of time?
4270
4271
	view: null, // a View object
4272
	isRTL: null, // shortcut to the view's isRTL option
4273
4274
	start: null,
4275
	end: null,
4276
4277
	el: null, // the containing element
4278
	elsByFill: null, // a hash of jQuery element sets used for rendering each fill. Keyed by fill name.
4279
4280
	// derived from options
4281
	eventTimeFormat: null,
4282
	displayEventTime: null,
4283
	displayEventEnd: null,
4284
4285
	minResizeDuration: null, // TODO: hack. set by subclasses. minumum event resize duration
4286
4287
	// if defined, holds the unit identified (ex: "year" or "month") that determines the level of granularity
4288
	// of the date areas. if not defined, assumes to be day and time granularity.
4289
	// TODO: port isTimeScale into same system?
4290
	largeUnit: null,
4291
4292
	dayClickListener: null,
4293
	daySelectListener: null,
4294
	segDragListener: null,
4295
	segResizeListener: null,
4296
	externalDragListener: null,
4297
4298
4299
	constructor: function(view) {
4300
		this.view = view;
4301
		this.isRTL = view.opt('isRTL');
4302
		this.elsByFill = {};
4303
4304
		this.dayClickListener = this.buildDayClickListener();
4305
		this.daySelectListener = this.buildDaySelectListener();
4306
	},
4307
4308
4309
	/* Options
4310
	------------------------------------------------------------------------------------------------------------------*/
4311
4312
4313
	// Generates the format string used for event time text, if not explicitly defined by 'timeFormat'
4314
	computeEventTimeFormat: function() {
4315
		return this.view.opt('smallTimeFormat');
4316
	},
4317
4318
4319
	// Determines whether events should have their end times displayed, if not explicitly defined by 'displayEventTime'.
4320
	// Only applies to non-all-day events.
4321
	computeDisplayEventTime: function() {
4322
		return true;
4323
	},
4324
4325
4326
	// Determines whether events should have their end times displayed, if not explicitly defined by 'displayEventEnd'
4327
	computeDisplayEventEnd: function() {
4328
		return true;
4329
	},
4330
4331
4332
	/* Dates
4333
	------------------------------------------------------------------------------------------------------------------*/
4334
4335
4336
	// Tells the grid about what period of time to display.
4337
	// Any date-related internal data should be generated.
4338
	setRange: function(range) {
4339
		this.start = range.start.clone();
4340
		this.end = range.end.clone();
4341
4342
		this.rangeUpdated();
4343
		this.processRangeOptions();
4344
	},
4345
4346
4347
	// Called when internal variables that rely on the range should be updated
4348
	rangeUpdated: function() {
4349
	},
4350
4351
4352
	// Updates values that rely on options and also relate to range
4353
	processRangeOptions: function() {
4354
		var view = this.view;
4355
		var displayEventTime;
4356
		var displayEventEnd;
4357
4358
		this.eventTimeFormat =
4359
			view.opt('eventTimeFormat') ||
4360
			view.opt('timeFormat') || // deprecated
4361
			this.computeEventTimeFormat();
4362
4363
		displayEventTime = view.opt('displayEventTime');
4364
		if (displayEventTime == null) {
4365
			displayEventTime = this.computeDisplayEventTime(); // might be based off of range
4366
		}
4367
4368
		displayEventEnd = view.opt('displayEventEnd');
4369
		if (displayEventEnd == null) {
4370
			displayEventEnd = this.computeDisplayEventEnd(); // might be based off of range
4371
		}
4372
4373
		this.displayEventTime = displayEventTime;
4374
		this.displayEventEnd = displayEventEnd;
4375
	},
4376
4377
4378
	// Converts a span (has unzoned start/end and any other grid-specific location information)
4379
	// into an array of segments (pieces of events whose format is decided by the grid).
4380
	spanToSegs: function(span) {
4381
		// subclasses must implement
4382
	},
4383
4384
4385
	// Diffs the two dates, returning a duration, based on granularity of the grid
4386
	// TODO: port isTimeScale into this system?
4387
	diffDates: function(a, b) {
4388
		if (this.largeUnit) {
4389
			return diffByUnit(a, b, this.largeUnit);
4390
		}
4391
		else {
4392
			return diffDayTime(a, b);
4393
		}
4394
	},
4395
4396
4397
	/* Hit Area
4398
	------------------------------------------------------------------------------------------------------------------*/
4399
4400
	hitsNeededDepth: 0, // necessary because multiple callers might need the same hits
4401
4402
	hitsNeeded: function() {
4403
		if (!(this.hitsNeededDepth++)) {
4404
			this.prepareHits();
4405
		}
4406
	},
4407
4408
	hitsNotNeeded: function() {
4409
		if (this.hitsNeededDepth && !(--this.hitsNeededDepth)) {
4410
			this.releaseHits();
4411
		}
4412
	},
4413
4414
4415
	// Called before one or more queryHit calls might happen. Should prepare any cached coordinates for queryHit
4416
	prepareHits: function() {
4417
	},
4418
4419
4420
	// Called when queryHit calls have subsided. Good place to clear any coordinate caches.
4421
	releaseHits: function() {
4422
	},
4423
4424
4425
	// Given coordinates from the topleft of the document, return data about the date-related area underneath.
4426
	// Can return an object with arbitrary properties (although top/right/left/bottom are encouraged).
4427
	// Must have a `grid` property, a reference to this current grid. TODO: avoid this
4428
	// The returned object will be processed by getHitSpan and getHitEl.
4429
	queryHit: function(leftOffset, topOffset) {
4430
	},
4431
4432
4433
	// like getHitSpan, but returns null if the resulting span's range is invalid
4434
	getSafeHitSpan: function(hit) {
4435
		var hitSpan = this.getHitSpan(hit);
4436
4437
		if (!isRangeWithinRange(hitSpan, this.view.activeRange)) {
4438
			return null;
4439
		}
4440
4441
		return hitSpan;
4442
	},
4443
4444
4445
	// Given position-level information about a date-related area within the grid,
4446
	// should return an object with at least a start/end date. Can provide other information as well.
4447
	getHitSpan: function(hit) {
4448
	},
4449
4450
4451
	// Given position-level information about a date-related area within the grid,
4452
	// should return a jQuery element that best represents it. passed to dayClick callback.
4453
	getHitEl: function(hit) {
4454
	},
4455
4456
4457
	/* Rendering
4458
	------------------------------------------------------------------------------------------------------------------*/
4459
4460
4461
	// Sets the container element that the grid should render inside of.
4462
	// Does other DOM-related initializations.
4463
	setElement: function(el) {
4464
		this.el = el;
4465
4466
		if (this.hasDayInteractions) {
4467
			preventSelection(el);
4468
4469
			this.bindDayHandler('touchstart', this.dayTouchStart);
4470
			this.bindDayHandler('mousedown', this.dayMousedown);
4471
		}
4472
4473
		// attach event-element-related handlers. in Grid.events
4474
		// same garbage collection note as above.
4475
		this.bindSegHandlers();
4476
4477
		this.bindGlobalHandlers();
4478
	},
4479
4480
4481
	bindDayHandler: function(name, handler) {
4482
		var _this = this;
4483
4484
		// attach a handler to the grid's root element.
4485
		// jQuery will take care of unregistering them when removeElement gets called.
4486
		this.el.on(name, function(ev) {
4487
			if (
4488
				!$(ev.target).is(
4489
					_this.segSelector + ',' + // directly on an event element
4490
					_this.segSelector + ' *,' + // within an event element
4491
					'.fc-more,' + // a "more.." link
4492
					'a[data-goto]' // a clickable nav link
4493
				)
4494
			) {
4495
				return handler.call(_this, ev);
4496
			}
4497
		});
4498
	},
4499
4500
4501
	// Removes the grid's container element from the DOM. Undoes any other DOM-related attachments.
4502
	// DOES NOT remove any content beforehand (doesn't clear events or call unrenderDates), unlike View
4503
	removeElement: function() {
4504
		this.unbindGlobalHandlers();
4505
		this.clearDragListeners();
4506
4507
		this.el.remove();
4508
4509
		// NOTE: we don't null-out this.el for the same reasons we don't do it within View::removeElement
4510
	},
4511
4512
4513
	// Renders the basic structure of grid view before any content is rendered
4514
	renderSkeleton: function() {
4515
		// subclasses should implement
4516
	},
4517
4518
4519
	// Renders the grid's date-related content (like areas that represent days/times).
4520
	// Assumes setRange has already been called and the skeleton has already been rendered.
4521
	renderDates: function() {
4522
		// subclasses should implement
4523
	},
4524
4525
4526
	// Unrenders the grid's date-related content
4527
	unrenderDates: function() {
4528
		// subclasses should implement
4529
	},
4530
4531
4532
	/* Handlers
4533
	------------------------------------------------------------------------------------------------------------------*/
4534
4535
4536
	// Binds DOM handlers to elements that reside outside the grid, such as the document
4537
	bindGlobalHandlers: function() {
4538
		this.listenTo($(document), {
4539
			dragstart: this.externalDragStart, // jqui
4540
			sortstart: this.externalDragStart // jqui
4541
		});
4542
	},
4543
4544
4545
	// Unbinds DOM handlers from elements that reside outside the grid
4546
	unbindGlobalHandlers: function() {
4547
		this.stopListeningTo($(document));
4548
	},
4549
4550
4551
	// Process a mousedown on an element that represents a day. For day clicking and selecting.
4552
	dayMousedown: function(ev) {
4553
		var view = this.view;
4554
4555
		// HACK
4556
		// This will still work even though bindDayHandler doesn't use GlobalEmitter.
4557
		if (GlobalEmitter.get().shouldIgnoreMouse()) {
4558
			return;
4559
		}
4560
4561
		this.dayClickListener.startInteraction(ev);
4562
4563
		if (view.opt('selectable')) {
4564
			this.daySelectListener.startInteraction(ev, {
4565
				distance: view.opt('selectMinDistance')
4566
			});
4567
		}
4568
	},
4569
4570
4571
	dayTouchStart: function(ev) {
4572
		var view = this.view;
4573
		var selectLongPressDelay;
4574
4575
		// On iOS (and Android?) when a new selection is initiated overtop another selection,
4576
		// the touchend never fires because the elements gets removed mid-touch-interaction (my theory).
4577
		// HACK: simply don't allow this to happen.
4578
		// ALSO: prevent selection when an *event* is already raised.
4579
		if (view.isSelected || view.selectedEvent) {
4580
			return;
4581
		}
4582
4583
		selectLongPressDelay = view.opt('selectLongPressDelay');
4584
		if (selectLongPressDelay == null) {
4585
			selectLongPressDelay = view.opt('longPressDelay'); // fallback
4586
		}
4587
4588
		this.dayClickListener.startInteraction(ev);
4589
4590
		if (view.opt('selectable')) {
4591
			this.daySelectListener.startInteraction(ev, {
4592
				delay: selectLongPressDelay
4593
			});
4594
		}
4595
	},
4596
4597
4598
	// Creates a listener that tracks the user's drag across day elements, for day clicking.
4599
	buildDayClickListener: function() {
4600
		var _this = this;
4601
		var view = this.view;
4602
		var dayClickHit; // null if invalid dayClick
4603
4604
		var dragListener = new HitDragListener(this, {
4605
			scroll: view.opt('dragScroll'),
4606
			interactionStart: function() {
4607
				dayClickHit = dragListener.origHit;
4608
			},
4609
			hitOver: function(hit, isOrig, origHit) {
4610
				// if user dragged to another cell at any point, it can no longer be a dayClick
4611
				if (!isOrig) {
4612
					dayClickHit = null;
4613
				}
4614
			},
4615
			hitOut: function() { // called before mouse moves to a different hit OR moved out of all hits
4616
				dayClickHit = null;
4617
			},
4618
			interactionEnd: function(ev, isCancelled) {
4619
				var hitSpan;
4620
4621
				if (!isCancelled && dayClickHit) {
4622
					hitSpan = _this.getSafeHitSpan(dayClickHit);
4623
4624
					if (hitSpan) {
4625
						view.triggerDayClick(hitSpan, _this.getHitEl(dayClickHit), ev);
4626
					}
4627
				}
4628
			}
4629
		});
4630
4631
		// because dayClickListener won't be called with any time delay, "dragging" will begin immediately,
4632
		// which will kill any touchmoving/scrolling. Prevent this.
4633
		dragListener.shouldCancelTouchScroll = false;
4634
4635
		dragListener.scrollAlwaysKills = true;
4636
4637
		return dragListener;
4638
	},
4639
4640
4641
	// Creates a listener that tracks the user's drag across day elements, for day selecting.
4642
	buildDaySelectListener: function() {
4643
		var _this = this;
4644
		var view = this.view;
4645
		var selectionSpan; // null if invalid selection
4646
4647
		var dragListener = new HitDragListener(this, {
4648
			scroll: view.opt('dragScroll'),
4649
			interactionStart: function() {
4650
				selectionSpan = null;
4651
			},
4652
			dragStart: function() {
4653
				view.unselect(); // since we could be rendering a new selection, we want to clear any old one
4654
			},
4655
			hitOver: function(hit, isOrig, origHit) {
4656
				var origHitSpan;
4657
				var hitSpan;
4658
4659
				if (origHit) { // click needs to have started on a hit
4660
4661
					origHitSpan = _this.getSafeHitSpan(origHit);
4662
					hitSpan = _this.getSafeHitSpan(hit);
4663
4664
					if (origHitSpan && hitSpan) {
4665
						selectionSpan = _this.computeSelection(origHitSpan, hitSpan);
4666
					}
4667
					else {
4668
						selectionSpan = null;
4669
					}
4670
4671
					if (selectionSpan) {
4672
						_this.renderSelection(selectionSpan);
4673
					}
4674
					else if (selectionSpan === false) {
4675
						disableCursor();
4676
					}
4677
				}
4678
			},
4679
			hitOut: function() { // called before mouse moves to a different hit OR moved out of all hits
4680
				selectionSpan = null;
4681
				_this.unrenderSelection();
4682
			},
4683
			hitDone: function() { // called after a hitOut OR before a dragEnd
4684
				enableCursor();
4685
			},
4686
			interactionEnd: function(ev, isCancelled) {
4687
				if (!isCancelled && selectionSpan) {
4688
					// the selection will already have been rendered. just report it
4689
					view.reportSelection(selectionSpan, ev);
4690
				}
4691
			}
4692
		});
4693
4694
		return dragListener;
4695
	},
4696
4697
4698
	// Kills all in-progress dragging.
4699
	// Useful for when public API methods that result in re-rendering are invoked during a drag.
4700
	// Also useful for when touch devices misbehave and don't fire their touchend.
4701
	clearDragListeners: function() {
4702
		this.dayClickListener.endInteraction();
4703
		this.daySelectListener.endInteraction();
4704
4705
		if (this.segDragListener) {
4706
			this.segDragListener.endInteraction(); // will clear this.segDragListener
4707
		}
4708
		if (this.segResizeListener) {
4709
			this.segResizeListener.endInteraction(); // will clear this.segResizeListener
4710
		}
4711
		if (this.externalDragListener) {
4712
			this.externalDragListener.endInteraction(); // will clear this.externalDragListener
4713
		}
4714
	},
4715
4716
4717
	/* Event Helper
4718
	------------------------------------------------------------------------------------------------------------------*/
4719
	// TODO: should probably move this to Grid.events, like we did event dragging / resizing
4720
4721
4722
	// Renders a mock event at the given event location, which contains zoned start/end properties.
4723
	// Returns all mock event elements.
4724
	renderEventLocationHelper: function(eventLocation, sourceSeg) {
4725
		var fakeEvent = this.fabricateHelperEvent(eventLocation, sourceSeg);
4726
4727
		return this.renderHelper(fakeEvent, sourceSeg); // do the actual rendering
4728
	},
4729
4730
4731
	// Builds a fake event given zoned event date properties and a segment is should be inspired from.
4732
	// The range's end can be null, in which case the mock event that is rendered will have a null end time.
4733
	// `sourceSeg` is the internal segment object involved in the drag. If null, something external is dragging.
4734
	fabricateHelperEvent: function(eventLocation, sourceSeg) {
4735
		var fakeEvent = sourceSeg ? createObject(sourceSeg.event) : {}; // mask the original event object if possible
4736
4737
		fakeEvent.start = eventLocation.start.clone();
4738
		fakeEvent.end = eventLocation.end ? eventLocation.end.clone() : null;
4739
		fakeEvent.allDay = null; // force it to be freshly computed by normalizeEventDates
4740
		this.view.calendar.normalizeEventDates(fakeEvent);
4741
4742
		// this extra className will be useful for differentiating real events from mock events in CSS
4743
		fakeEvent.className = (fakeEvent.className || []).concat('fc-helper');
4744
4745
		// if something external is being dragged in, don't render a resizer
4746
		if (!sourceSeg) {
4747
			fakeEvent.editable = false;
4748
		}
4749
4750
		return fakeEvent;
4751
	},
4752
4753
4754
	// Renders a mock event. Given zoned event date properties.
4755
	// Must return all mock event elements.
4756
	renderHelper: function(eventLocation, sourceSeg) {
4757
		// subclasses must implement
4758
	},
4759
4760
4761
	// Unrenders a mock event
4762
	unrenderHelper: function() {
4763
		// subclasses must implement
4764
	},
4765
4766
4767
	/* Selection
4768
	------------------------------------------------------------------------------------------------------------------*/
4769
4770
4771
	// Renders a visual indication of a selection. Will highlight by default but can be overridden by subclasses.
4772
	// Given a span (unzoned start/end and other misc data)
4773
	renderSelection: function(span) {
4774
		this.renderHighlight(span);
4775
	},
4776
4777
4778
	// Unrenders any visual indications of a selection. Will unrender a highlight by default.
4779
	unrenderSelection: function() {
4780
		this.unrenderHighlight();
4781
	},
4782
4783
4784
	// Given the first and last date-spans of a selection, returns another date-span object.
4785
	// Subclasses can override and provide additional data in the span object. Will be passed to renderSelection().
4786
	// Will return false if the selection is invalid and this should be indicated to the user.
4787
	// Will return null/undefined if a selection invalid but no error should be reported.
4788
	computeSelection: function(span0, span1) {
4789
		var span = this.computeSelectionSpan(span0, span1);
4790
4791
		if (span && !this.view.calendar.isSelectionSpanAllowed(span)) {
4792
			return false;
4793
		}
4794
4795
		return span;
4796
	},
4797
4798
4799
	// Given two spans, must return the combination of the two.
4800
	// TODO: do this separation of concerns (combining VS validation) for event dnd/resize too.
4801
	computeSelectionSpan: function(span0, span1) {
4802
		var dates = [ span0.start, span0.end, span1.start, span1.end ];
4803
4804
		dates.sort(compareNumbers); // sorts chronologically. works with Moments
4805
4806
		return { start: dates[0].clone(), end: dates[3].clone() };
4807
	},
4808
4809
4810
	/* Highlight
4811
	------------------------------------------------------------------------------------------------------------------*/
4812
4813
4814
	// Renders an emphasis on the given date range. Given a span (unzoned start/end and other misc data)
4815
	renderHighlight: function(span) {
4816
		this.renderFill('highlight', this.spanToSegs(span));
4817
	},
4818
4819
4820
	// Unrenders the emphasis on a date range
4821
	unrenderHighlight: function() {
4822
		this.unrenderFill('highlight');
4823
	},
4824
4825
4826
	// Generates an array of classNames for rendering the highlight. Used by the fill system.
4827
	highlightSegClasses: function() {
4828
		return [ 'fc-highlight' ];
4829
	},
4830
4831
4832
	/* Business Hours
4833
	------------------------------------------------------------------------------------------------------------------*/
4834
4835
4836
	renderBusinessHours: function() {
4837
	},
4838
4839
4840
	unrenderBusinessHours: function() {
4841
	},
4842
4843
4844
	/* Now Indicator
4845
	------------------------------------------------------------------------------------------------------------------*/
4846
4847
4848
	getNowIndicatorUnit: function() {
4849
	},
4850
4851
4852
	renderNowIndicator: function(date) {
4853
	},
4854
4855
4856
	unrenderNowIndicator: function() {
4857
	},
4858
4859
4860
	/* Fill System (highlight, background events, business hours)
4861
	--------------------------------------------------------------------------------------------------------------------
4862
	TODO: remove this system. like we did in TimeGrid
4863
	*/
4864
4865
4866
	// Renders a set of rectangles over the given segments of time.
4867
	// MUST RETURN a subset of segs, the segs that were actually rendered.
4868
	// Responsible for populating this.elsByFill. TODO: better API for expressing this requirement
4869
	renderFill: function(type, segs) {
4870
		// subclasses must implement
4871
	},
4872
4873
4874
	// Unrenders a specific type of fill that is currently rendered on the grid
4875
	unrenderFill: function(type) {
4876
		var el = this.elsByFill[type];
4877
4878
		if (el) {
4879
			el.remove();
4880
			delete this.elsByFill[type];
4881
		}
4882
	},
4883
4884
4885
	// Renders and assigns an `el` property for each fill segment. Generic enough to work with different types.
4886
	// Only returns segments that successfully rendered.
4887
	// To be harnessed by renderFill (implemented by subclasses).
4888
	// Analagous to renderFgSegEls.
4889
	renderFillSegEls: function(type, segs) {
4890
		var _this = this;
4891
		var segElMethod = this[type + 'SegEl'];
4892
		var html = '';
4893
		var renderedSegs = [];
4894
		var i;
4895
4896
		if (segs.length) {
4897
4898
			// build a large concatenation of segment HTML
4899
			for (i = 0; i < segs.length; i++) {
4900
				html += this.fillSegHtml(type, segs[i]);
4901
			}
4902
4903
			// Grab individual elements from the combined HTML string. Use each as the default rendering.
4904
			// Then, compute the 'el' for each segment.
4905
			$(html).each(function(i, node) {
4906
				var seg = segs[i];
4907
				var el = $(node);
4908
4909
				// allow custom filter methods per-type
4910
				if (segElMethod) {
4911
					el = segElMethod.call(_this, seg, el);
4912
				}
4913
4914
				if (el) { // custom filters did not cancel the render
4915
					el = $(el); // allow custom filter to return raw DOM node
4916
4917
					// correct element type? (would be bad if a non-TD were inserted into a table for example)
4918
					if (el.is(_this.fillSegTag)) {
4919
						seg.el = el;
4920
						renderedSegs.push(seg);
4921
					}
4922
				}
4923
			});
4924
		}
4925
4926
		return renderedSegs;
4927
	},
4928
4929
4930
	fillSegTag: 'div', // subclasses can override
4931
4932
4933
	// Builds the HTML needed for one fill segment. Generic enough to work with different types.
4934
	fillSegHtml: function(type, seg) {
4935
4936
		// custom hooks per-type
4937
		var classesMethod = this[type + 'SegClasses'];
4938
		var cssMethod = this[type + 'SegCss'];
4939
4940
		var classes = classesMethod ? classesMethod.call(this, seg) : [];
4941
		var css = cssToStr(cssMethod ? cssMethod.call(this, seg) : {});
4942
4943
		return '<' + this.fillSegTag +
4944
			(classes.length ? ' class="' + classes.join(' ') + '"' : '') +
4945
			(css ? ' style="' + css + '"' : '') +
4946
			' />';
4947
	},
4948
4949
4950
4951
	/* Generic rendering utilities for subclasses
4952
	------------------------------------------------------------------------------------------------------------------*/
4953
4954
4955
	// Computes HTML classNames for a single-day element
4956
	getDayClasses: function(date, noThemeHighlight) {
4957
		var view = this.view;
4958
		var classes = [];
4959
		var today;
4960
4961
		if (!isDateWithinRange(date, view.activeRange)) {
4962
			classes.push('fc-disabled-day'); // TODO: jQuery UI theme?
4963
		}
4964
		else {
4965
			classes.push('fc-' + dayIDs[date.day()]);
4966
4967
			if (
4968
				view.currentRangeAs('months') == 1 && // TODO: somehow get into MonthView
4969
				date.month() != view.currentRange.start.month()
4970
			) {
4971
				classes.push('fc-other-month');
4972
			}
4973
4974
			today = view.calendar.getNow();
4975
4976
			if (date.isSame(today, 'day')) {
4977
				classes.push('fc-today');
4978
4979
				if (noThemeHighlight !== true) {
4980
					classes.push(view.highlightStateClass);
4981
				}
4982
			}
4983
			else if (date < today) {
4984
				classes.push('fc-past');
4985
			}
4986
			else {
4987
				classes.push('fc-future');
4988
			}
4989
		}
4990
4991
		return classes;
4992
	}
4993
4994
});
4995
4996
;;
4997
4998
/* Event-rendering and event-interaction methods for the abstract Grid class
4999
----------------------------------------------------------------------------------------------------------------------
5000
5001
Data Types:
5002
	event - { title, id, start, (end), whatever }
5003
	location - { start, (end), allDay }
5004
	rawEventRange - { start, end }
5005
	eventRange - { start, end, isStart, isEnd }
5006
	eventSpan - { start, end, isStart, isEnd, whatever }
5007
	eventSeg - { event, whatever }
5008
	seg - { whatever }
5009
*/
5010
5011
Grid.mixin({
5012
5013
	// self-config, overridable by subclasses
5014
	segSelector: '.fc-event-container > *', // what constitutes an event element?
5015
5016
	mousedOverSeg: null, // the segment object the user's mouse is over. null if over nothing
5017
	isDraggingSeg: false, // is a segment being dragged? boolean
5018
	isResizingSeg: false, // is a segment being resized? boolean
5019
	isDraggingExternal: false, // jqui-dragging an external element? boolean
5020
	segs: null, // the *event* segments currently rendered in the grid. TODO: rename to `eventSegs`
5021
5022
5023
	// Renders the given events onto the grid
5024
	renderEvents: function(events) {
5025
		var bgEvents = [];
5026
		var fgEvents = [];
5027
		var i;
5028
5029
		for (i = 0; i < events.length; i++) {
5030
			(isBgEvent(events[i]) ? bgEvents : fgEvents).push(events[i]);
5031
		}
5032
5033
		this.segs = [].concat( // record all segs
5034
			this.renderBgEvents(bgEvents),
5035
			this.renderFgEvents(fgEvents)
5036
		);
5037
	},
5038
5039
5040
	renderBgEvents: function(events) {
5041
		var segs = this.eventsToSegs(events);
5042
5043
		// renderBgSegs might return a subset of segs, segs that were actually rendered
5044
		return this.renderBgSegs(segs) || segs;
5045
	},
5046
5047
5048
	renderFgEvents: function(events) {
5049
		var segs = this.eventsToSegs(events);
5050
5051
		// renderFgSegs might return a subset of segs, segs that were actually rendered
5052
		return this.renderFgSegs(segs) || segs;
5053
	},
5054
5055
5056
	// Unrenders all events currently rendered on the grid
5057
	unrenderEvents: function() {
5058
		this.handleSegMouseout(); // trigger an eventMouseout if user's mouse is over an event
5059
		this.clearDragListeners();
5060
5061
		this.unrenderFgSegs();
5062
		this.unrenderBgSegs();
5063
5064
		this.segs = null;
5065
	},
5066
5067
5068
	// Retrieves all rendered segment objects currently rendered on the grid
5069
	getEventSegs: function() {
5070
		return this.segs || [];
5071
	},
5072
5073
5074
	/* Foreground Segment Rendering
5075
	------------------------------------------------------------------------------------------------------------------*/
5076
5077
5078
	// Renders foreground event segments onto the grid. May return a subset of segs that were rendered.
5079
	renderFgSegs: function(segs) {
5080
		// subclasses must implement
5081
	},
5082
5083
5084
	// Unrenders all currently rendered foreground segments
5085
	unrenderFgSegs: function() {
5086
		// subclasses must implement
5087
	},
5088
5089
5090
	// Renders and assigns an `el` property for each foreground event segment.
5091
	// Only returns segments that successfully rendered.
5092
	// A utility that subclasses may use.
5093
	renderFgSegEls: function(segs, disableResizing) {
5094
		var view = this.view;
5095
		var html = '';
5096
		var renderedSegs = [];
5097
		var i;
5098
5099
		if (segs.length) { // don't build an empty html string
5100
5101
			// build a large concatenation of event segment HTML
5102
			for (i = 0; i < segs.length; i++) {
5103
				html += this.fgSegHtml(segs[i], disableResizing);
5104
			}
5105
5106
			// Grab individual elements from the combined HTML string. Use each as the default rendering.
5107
			// Then, compute the 'el' for each segment. An el might be null if the eventRender callback returned false.
5108
			$(html).each(function(i, node) {
5109
				var seg = segs[i];
5110
				var el = view.resolveEventEl(seg.event, $(node));
5111
5112
				if (el) {
5113
					el.data('fc-seg', seg); // used by handlers
5114
					seg.el = el;
5115
					renderedSegs.push(seg);
5116
				}
5117
			});
5118
		}
5119
5120
		return renderedSegs;
5121
	},
5122
5123
5124
	// Generates the HTML for the default rendering of a foreground event segment. Used by renderFgSegEls()
5125
	fgSegHtml: function(seg, disableResizing) {
5126
		// subclasses should implement
5127
	},
5128
5129
5130
	/* Background Segment Rendering
5131
	------------------------------------------------------------------------------------------------------------------*/
5132
5133
5134
	// Renders the given background event segments onto the grid.
5135
	// Returns a subset of the segs that were actually rendered.
5136
	renderBgSegs: function(segs) {
5137
		return this.renderFill('bgEvent', segs);
5138
	},
5139
5140
5141
	// Unrenders all the currently rendered background event segments
5142
	unrenderBgSegs: function() {
5143
		this.unrenderFill('bgEvent');
5144
	},
5145
5146
5147
	// Renders a background event element, given the default rendering. Called by the fill system.
5148
	bgEventSegEl: function(seg, el) {
5149
		return this.view.resolveEventEl(seg.event, el); // will filter through eventRender
5150
	},
5151
5152
5153
	// Generates an array of classNames to be used for the default rendering of a background event.
5154
	// Called by fillSegHtml.
5155
	bgEventSegClasses: function(seg) {
5156
		var event = seg.event;
5157
		var source = event.source || {};
5158
5159
		return [ 'fc-bgevent' ].concat(
5160
			event.className,
5161
			source.className || []
5162
		);
5163
	},
5164
5165
5166
	// Generates a semicolon-separated CSS string to be used for the default rendering of a background event.
5167
	// Called by fillSegHtml.
5168
	bgEventSegCss: function(seg) {
5169
		return {
5170
			'background-color': this.getSegSkinCss(seg)['background-color']
5171
		};
5172
	},
5173
5174
5175
	// Generates an array of classNames to be used for the rendering business hours overlay. Called by the fill system.
5176
	// Called by fillSegHtml.
5177
	businessHoursSegClasses: function(seg) {
5178
		return [ 'fc-nonbusiness', 'fc-bgevent' ];
5179
	},
5180
5181
5182
	/* Business Hours
5183
	------------------------------------------------------------------------------------------------------------------*/
5184
5185
5186
	// Compute business hour segs for the grid's current date range.
5187
	// Caller must ask if whole-day business hours are needed.
5188
	// If no `businessHours` configuration value is specified, assumes the calendar default.
5189
	buildBusinessHourSegs: function(wholeDay, businessHours) {
5190
		return this.eventsToSegs(
5191
			this.buildBusinessHourEvents(wholeDay, businessHours)
5192
		);
5193
	},
5194
5195
5196
	// Compute business hour *events* for the grid's current date range.
5197
	// Caller must ask if whole-day business hours are needed.
5198
	// If no `businessHours` configuration value is specified, assumes the calendar default.
5199
	buildBusinessHourEvents: function(wholeDay, businessHours) {
5200
		var calendar = this.view.calendar;
5201
		var events;
5202
5203
		if (businessHours == null) {
5204
			// fallback
5205
			// access from calendawr. don't access from view. doesn't update with dynamic options.
5206
			businessHours = calendar.opt('businessHours');
5207
		}
5208
5209
		events = calendar.computeBusinessHourEvents(wholeDay, businessHours);
5210
5211
		// HACK. Eventually refactor business hours "events" system.
5212
		// If no events are given, but businessHours is activated, this means the entire visible range should be
5213
		// marked as *not* business-hours, via inverse-background rendering.
5214
		if (!events.length && businessHours) {
5215
			events = [
5216
				$.extend({}, BUSINESS_HOUR_EVENT_DEFAULTS, {
5217
					start: this.view.activeRange.end, // guaranteed out-of-range
5218
					end: this.view.activeRange.end,   // "
5219
					dow: null
5220
				})
5221
			];
5222
		}
5223
5224
		return events;
5225
	},
5226
5227
5228
	/* Handlers
5229
	------------------------------------------------------------------------------------------------------------------*/
5230
5231
5232
	// Attaches event-element-related handlers for *all* rendered event segments of the view.
5233
	bindSegHandlers: function() {
5234
		this.bindSegHandlersToEl(this.el);
5235
	},
5236
5237
5238
	// Attaches event-element-related handlers to an arbitrary container element. leverages bubbling.
5239
	bindSegHandlersToEl: function(el) {
5240
		this.bindSegHandlerToEl(el, 'touchstart', this.handleSegTouchStart);
5241
		this.bindSegHandlerToEl(el, 'mouseenter', this.handleSegMouseover);
5242
		this.bindSegHandlerToEl(el, 'mouseleave', this.handleSegMouseout);
5243
		this.bindSegHandlerToEl(el, 'mousedown', this.handleSegMousedown);
5244
		this.bindSegHandlerToEl(el, 'click', this.handleSegClick);
5245
	},
5246
5247
5248
	// Executes a handler for any a user-interaction on a segment.
5249
	// Handler gets called with (seg, ev), and with the `this` context of the Grid
5250
	bindSegHandlerToEl: function(el, name, handler) {
5251
		var _this = this;
5252
5253
		el.on(name, this.segSelector, function(ev) {
5254
			var seg = $(this).data('fc-seg'); // grab segment data. put there by View::renderEvents
5255
5256
			// only call the handlers if there is not a drag/resize in progress
5257
			if (seg && !_this.isDraggingSeg && !_this.isResizingSeg) {
5258
				return handler.call(_this, seg, ev); // context will be the Grid
5259
			}
5260
		});
5261
	},
5262
5263
5264
	handleSegClick: function(seg, ev) {
5265
		var res = this.view.publiclyTrigger('eventClick', seg.el[0], seg.event, ev); // can return `false` to cancel
5266
		if (res === false) {
5267
			ev.preventDefault();
5268
		}
5269
	},
5270
5271
5272
	// Updates internal state and triggers handlers for when an event element is moused over
5273
	handleSegMouseover: function(seg, ev) {
5274
		if (
5275
			!GlobalEmitter.get().shouldIgnoreMouse() &&
5276
			!this.mousedOverSeg
5277
		) {
5278
			this.mousedOverSeg = seg;
5279
			if (this.view.isEventResizable(seg.event)) {
5280
				seg.el.addClass('fc-allow-mouse-resize');
5281
			}
5282
			this.view.publiclyTrigger('eventMouseover', seg.el[0], seg.event, ev);
5283
		}
5284
	},
5285
5286
5287
	// Updates internal state and triggers handlers for when an event element is moused out.
5288
	// Can be given no arguments, in which case it will mouseout the segment that was previously moused over.
5289
	handleSegMouseout: function(seg, ev) {
5290
		ev = ev || {}; // if given no args, make a mock mouse event
5291
5292
		if (this.mousedOverSeg) {
5293
			seg = seg || this.mousedOverSeg; // if given no args, use the currently moused-over segment
5294
			this.mousedOverSeg = null;
5295
			if (this.view.isEventResizable(seg.event)) {
5296
				seg.el.removeClass('fc-allow-mouse-resize');
5297
			}
5298
			this.view.publiclyTrigger('eventMouseout', seg.el[0], seg.event, ev);
5299
		}
5300
	},
5301
5302
5303
	handleSegMousedown: function(seg, ev) {
5304
		var isResizing = this.startSegResize(seg, ev, { distance: 5 });
5305
5306
		if (!isResizing && this.view.isEventDraggable(seg.event)) {
5307
			this.buildSegDragListener(seg)
5308
				.startInteraction(ev, {
5309
					distance: 5
5310
				});
5311
		}
5312
	},
5313
5314
5315
	handleSegTouchStart: function(seg, ev) {
5316
		var view = this.view;
5317
		var event = seg.event;
5318
		var isSelected = view.isEventSelected(event);
5319
		var isDraggable = view.isEventDraggable(event);
5320
		var isResizable = view.isEventResizable(event);
5321
		var isResizing = false;
5322
		var dragListener;
5323
		var eventLongPressDelay;
5324
5325
		if (isSelected && isResizable) {
5326
			// only allow resizing of the event is selected
5327
			isResizing = this.startSegResize(seg, ev);
5328
		}
5329
5330
		if (!isResizing && (isDraggable || isResizable)) { // allowed to be selected?
5331
5332
			eventLongPressDelay = view.opt('eventLongPressDelay');
5333
			if (eventLongPressDelay == null) {
5334
				eventLongPressDelay = view.opt('longPressDelay'); // fallback
5335
			}
5336
5337
			dragListener = isDraggable ?
5338
				this.buildSegDragListener(seg) :
5339
				this.buildSegSelectListener(seg); // seg isn't draggable, but still needs to be selected
5340
5341
			dragListener.startInteraction(ev, { // won't start if already started
5342
				delay: isSelected ? 0 : eventLongPressDelay // do delay if not already selected
5343
			});
5344
		}
5345
	},
5346
5347
5348
	// returns boolean whether resizing actually started or not.
5349
	// assumes the seg allows resizing.
5350
	// `dragOptions` are optional.
5351
	startSegResize: function(seg, ev, dragOptions) {
5352
		if ($(ev.target).is('.fc-resizer')) {
5353
			this.buildSegResizeListener(seg, $(ev.target).is('.fc-start-resizer'))
5354
				.startInteraction(ev, dragOptions);
5355
			return true;
5356
		}
5357
		return false;
5358
	},
5359
5360
5361
5362
	/* Event Dragging
5363
	------------------------------------------------------------------------------------------------------------------*/
5364
5365
5366
	// Builds a listener that will track user-dragging on an event segment.
5367
	// Generic enough to work with any type of Grid.
5368
	// Has side effect of setting/unsetting `segDragListener`
5369
	buildSegDragListener: function(seg) {
5370
		var _this = this;
5371
		var view = this.view;
5372
		var el = seg.el;
5373
		var event = seg.event;
5374
		var isDragging;
5375
		var mouseFollower; // A clone of the original element that will move with the mouse
5376
		var dropLocation; // zoned event date properties
5377
5378
		if (this.segDragListener) {
5379
			return this.segDragListener;
5380
		}
5381
5382
		// Tracks mouse movement over the *view's* coordinate map. Allows dragging and dropping between subcomponents
5383
		// of the view.
5384
		var dragListener = this.segDragListener = new HitDragListener(view, {
5385
			scroll: view.opt('dragScroll'),
5386
			subjectEl: el,
5387
			subjectCenter: true,
5388
			interactionStart: function(ev) {
5389
				seg.component = _this; // for renderDrag
5390
				isDragging = false;
5391
				mouseFollower = new MouseFollower(seg.el, {
5392
					additionalClass: 'fc-dragging',
5393
					parentEl: view.el,
5394
					opacity: dragListener.isTouch ? null : view.opt('dragOpacity'),
5395
					revertDuration: view.opt('dragRevertDuration'),
5396
					zIndex: 2 // one above the .fc-view
5397
				});
5398
				mouseFollower.hide(); // don't show until we know this is a real drag
5399
				mouseFollower.start(ev);
5400
			},
5401
			dragStart: function(ev) {
5402
				if (dragListener.isTouch && !view.isEventSelected(event)) {
5403
					// if not previously selected, will fire after a delay. then, select the event
5404
					view.selectEvent(event);
5405
				}
5406
				isDragging = true;
5407
				_this.handleSegMouseout(seg, ev); // ensure a mouseout on the manipulated event has been reported
5408
				_this.segDragStart(seg, ev);
5409
				view.hideEvent(event); // hide all event segments. our mouseFollower will take over
5410
			},
5411
			hitOver: function(hit, isOrig, origHit) {
5412
				var isAllowed = true;
5413
				var origHitSpan;
5414
				var hitSpan;
5415
				var dragHelperEls;
5416
5417
				// starting hit could be forced (DayGrid.limit)
5418
				if (seg.hit) {
5419
					origHit = seg.hit;
5420
				}
5421
5422
				// hit might not belong to this grid, so query origin grid
5423
				origHitSpan = origHit.component.getSafeHitSpan(origHit);
5424
				hitSpan = hit.component.getSafeHitSpan(hit);
5425
5426
				if (origHitSpan && hitSpan) {
5427
					dropLocation = _this.computeEventDrop(origHitSpan, hitSpan, event);
5428
					isAllowed = dropLocation && _this.isEventLocationAllowed(dropLocation, event);
5429
				}
5430
				else {
5431
					isAllowed = false;
5432
				}
5433
5434
				if (!isAllowed) {
5435
					dropLocation = null;
5436
					disableCursor();
5437
				}
5438
5439
				// if a valid drop location, have the subclass render a visual indication
5440
				if (dropLocation && (dragHelperEls = view.renderDrag(dropLocation, seg))) {
5441
5442
					dragHelperEls.addClass('fc-dragging');
5443
					if (!dragListener.isTouch) {
5444
						_this.applyDragOpacity(dragHelperEls);
5445
					}
5446
5447
					mouseFollower.hide(); // if the subclass is already using a mock event "helper", hide our own
5448
				}
5449
				else {
5450
					mouseFollower.show(); // otherwise, have the helper follow the mouse (no snapping)
5451
				}
5452
5453
				if (isOrig) {
5454
					dropLocation = null; // needs to have moved hits to be a valid drop
5455
				}
5456
			},
5457
			hitOut: function() { // called before mouse moves to a different hit OR moved out of all hits
5458
				view.unrenderDrag(); // unrender whatever was done in renderDrag
5459
				mouseFollower.show(); // show in case we are moving out of all hits
5460
				dropLocation = null;
5461
			},
5462
			hitDone: function() { // Called after a hitOut OR before a dragEnd
5463
				enableCursor();
5464
			},
5465
			interactionEnd: function(ev) {
5466
				delete seg.component; // prevent side effects
5467
5468
				// do revert animation if hasn't changed. calls a callback when finished (whether animation or not)
5469
				mouseFollower.stop(!dropLocation, function() {
5470
					if (isDragging) {
5471
						view.unrenderDrag();
5472
						_this.segDragStop(seg, ev);
5473
					}
5474
5475
					if (dropLocation) {
5476
						// no need to re-show original, will rerender all anyways. esp important if eventRenderWait
5477
						view.reportSegDrop(seg, dropLocation, _this.largeUnit, el, ev);
5478
					}
5479
					else {
5480
						view.showEvent(event);
5481
					}
5482
				});
5483
				_this.segDragListener = null;
5484
			}
5485
		});
5486
5487
		return dragListener;
5488
	},
5489
5490
5491
	// seg isn't draggable, but let's use a generic DragListener
5492
	// simply for the delay, so it can be selected.
5493
	// Has side effect of setting/unsetting `segDragListener`
5494
	buildSegSelectListener: function(seg) {
5495
		var _this = this;
5496
		var view = this.view;
5497
		var event = seg.event;
5498
5499
		if (this.segDragListener) {
5500
			return this.segDragListener;
5501
		}
5502
5503
		var dragListener = this.segDragListener = new DragListener({
5504
			dragStart: function(ev) {
5505
				if (dragListener.isTouch && !view.isEventSelected(event)) {
5506
					// if not previously selected, will fire after a delay. then, select the event
5507
					view.selectEvent(event);
5508
				}
5509
			},
5510
			interactionEnd: function(ev) {
5511
				_this.segDragListener = null;
5512
			}
5513
		});
5514
5515
		return dragListener;
5516
	},
5517
5518
5519
	// Called before event segment dragging starts
5520
	segDragStart: function(seg, ev) {
5521
		this.isDraggingSeg = true;
5522
		this.view.publiclyTrigger('eventDragStart', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy
5523
	},
5524
5525
5526
	// Called after event segment dragging stops
5527
	segDragStop: function(seg, ev) {
5528
		this.isDraggingSeg = false;
5529
		this.view.publiclyTrigger('eventDragStop', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy
5530
	},
5531
5532
5533
	// Given the spans an event drag began, and the span event was dropped, calculates the new zoned start/end/allDay
5534
	// values for the event. Subclasses may override and set additional properties to be used by renderDrag.
5535
	// A falsy returned value indicates an invalid drop.
5536
	// DOES NOT consider overlap/constraint.
5537
	computeEventDrop: function(startSpan, endSpan, event) {
5538
		var calendar = this.view.calendar;
5539
		var dragStart = startSpan.start;
5540
		var dragEnd = endSpan.start;
5541
		var delta;
5542
		var dropLocation; // zoned event date properties
5543
5544
		if (dragStart.hasTime() === dragEnd.hasTime()) {
5545
			delta = this.diffDates(dragEnd, dragStart);
5546
5547
			// if an all-day event was in a timed area and it was dragged to a different time,
5548
			// guarantee an end and adjust start/end to have times
5549
			if (event.allDay && durationHasTime(delta)) {
5550
				dropLocation = {
5551
					start: event.start.clone(),
5552
					end: calendar.getEventEnd(event), // will be an ambig day
5553
					allDay: false // for normalizeEventTimes
5554
				};
5555
				calendar.normalizeEventTimes(dropLocation);
5556
			}
5557
			// othewise, work off existing values
5558
			else {
5559
				dropLocation = pluckEventDateProps(event);
5560
			}
5561
5562
			dropLocation.start.add(delta);
5563
			if (dropLocation.end) {
5564
				dropLocation.end.add(delta);
5565
			}
5566
		}
5567
		else {
5568
			// if switching from day <-> timed, start should be reset to the dropped date, and the end cleared
5569
			dropLocation = {
5570
				start: dragEnd.clone(),
5571
				end: null, // end should be cleared
5572
				allDay: !dragEnd.hasTime()
5573
			};
5574
		}
5575
5576
		return dropLocation;
5577
	},
5578
5579
5580
	// Utility for apply dragOpacity to a jQuery set
5581
	applyDragOpacity: function(els) {
5582
		var opacity = this.view.opt('dragOpacity');
5583
5584
		if (opacity != null) {
5585
			els.css('opacity', opacity);
5586
		}
5587
	},
5588
5589
5590
	/* External Element Dragging
5591
	------------------------------------------------------------------------------------------------------------------*/
5592
5593
5594
	// Called when a jQuery UI drag is initiated anywhere in the DOM
5595
	externalDragStart: function(ev, ui) {
5596
		var view = this.view;
5597
		var el;
5598
		var accept;
5599
5600
		if (view.opt('droppable')) { // only listen if this setting is on
5601
			el = $((ui ? ui.item : null) || ev.target);
5602
5603
			// Test that the dragged element passes the dropAccept selector or filter function.
5604
			// FYI, the default is "*" (matches all)
5605
			accept = view.opt('dropAccept');
5606
			if ($.isFunction(accept) ? accept.call(el[0], el) : el.is(accept)) {
5607
				if (!this.isDraggingExternal) { // prevent double-listening if fired twice
5608
					this.listenToExternalDrag(el, ev, ui);
5609
				}
5610
			}
5611
		}
5612
	},
5613
5614
5615
	// Called when a jQuery UI drag starts and it needs to be monitored for dropping
5616
	listenToExternalDrag: function(el, ev, ui) {
5617
		var _this = this;
5618
		var view = this.view;
5619
		var meta = getDraggedElMeta(el); // extra data about event drop, including possible event to create
5620
		var dropLocation; // a null value signals an unsuccessful drag
5621
5622
		// listener that tracks mouse movement over date-associated pixel regions
5623
		var dragListener = _this.externalDragListener = new HitDragListener(this, {
5624
			interactionStart: function() {
5625
				_this.isDraggingExternal = true;
5626
			},
5627
			hitOver: function(hit) {
5628
				var isAllowed = true;
5629
				var hitSpan = hit.component.getSafeHitSpan(hit); // hit might not belong to this grid
5630
5631
				if (hitSpan) {
5632
					dropLocation = _this.computeExternalDrop(hitSpan, meta);
5633
					isAllowed = dropLocation && _this.isExternalLocationAllowed(dropLocation, meta.eventProps);
5634
				}
5635
				else {
5636
					isAllowed = false;
5637
				}
5638
5639
				if (!isAllowed) {
5640
					dropLocation = null;
5641
					disableCursor();
5642
				}
5643
5644
				if (dropLocation) {
5645
					_this.renderDrag(dropLocation); // called without a seg parameter
5646
				}
5647
			},
5648
			hitOut: function() {
5649
				dropLocation = null; // signal unsuccessful
5650
			},
5651
			hitDone: function() { // Called after a hitOut OR before a dragEnd
5652
				enableCursor();
5653
				_this.unrenderDrag();
5654
			},
5655
			interactionEnd: function(ev) {
5656
				if (dropLocation) { // element was dropped on a valid hit
5657
					view.reportExternalDrop(meta, dropLocation, el, ev, ui);
5658
				}
5659
				_this.isDraggingExternal = false;
5660
				_this.externalDragListener = null;
5661
			}
5662
		});
5663
5664
		dragListener.startDrag(ev); // start listening immediately
5665
	},
5666
5667
5668
	// Given a hit to be dropped upon, and misc data associated with the jqui drag (guaranteed to be a plain object),
5669
	// returns the zoned start/end dates for the event that would result from the hypothetical drop. end might be null.
5670
	// Returning a null value signals an invalid drop hit.
5671
	// DOES NOT consider overlap/constraint.
5672
	computeExternalDrop: function(span, meta) {
5673
		var calendar = this.view.calendar;
5674
		var dropLocation = {
5675
			start: calendar.applyTimezone(span.start), // simulate a zoned event start date
5676
			end: null
5677
		};
5678
5679
		// if dropped on an all-day span, and element's metadata specified a time, set it
5680
		if (meta.startTime && !dropLocation.start.hasTime()) {
5681
			dropLocation.start.time(meta.startTime);
5682
		}
5683
5684
		if (meta.duration) {
5685
			dropLocation.end = dropLocation.start.clone().add(meta.duration);
5686
		}
5687
5688
		return dropLocation;
5689
	},
5690
5691
5692
5693
	/* Drag Rendering (for both events and an external elements)
5694
	------------------------------------------------------------------------------------------------------------------*/
5695
5696
5697
	// Renders a visual indication of an event or external element being dragged.
5698
	// `dropLocation` contains hypothetical start/end/allDay values the event would have if dropped. end can be null.
5699
	// `seg` is the internal segment object that is being dragged. If dragging an external element, `seg` is null.
5700
	// A truthy returned value indicates this method has rendered a helper element.
5701
	// Must return elements used for any mock events.
5702
	renderDrag: function(dropLocation, seg) {
5703
		// subclasses must implement
5704
	},
5705
5706
5707
	// Unrenders a visual indication of an event or external element being dragged
5708
	unrenderDrag: function() {
5709
		// subclasses must implement
5710
	},
5711
5712
5713
	/* Resizing
5714
	------------------------------------------------------------------------------------------------------------------*/
5715
5716
5717
	// Creates a listener that tracks the user as they resize an event segment.
5718
	// Generic enough to work with any type of Grid.
5719
	buildSegResizeListener: function(seg, isStart) {
5720
		var _this = this;
5721
		var view = this.view;
5722
		var calendar = view.calendar;
5723
		var el = seg.el;
5724
		var event = seg.event;
5725
		var eventEnd = calendar.getEventEnd(event);
5726
		var isDragging;
5727
		var resizeLocation; // zoned event date properties. falsy if invalid resize
5728
5729
		// Tracks mouse movement over the *grid's* coordinate map
5730
		var dragListener = this.segResizeListener = new HitDragListener(this, {
5731
			scroll: view.opt('dragScroll'),
5732
			subjectEl: el,
5733
			interactionStart: function() {
5734
				isDragging = false;
5735
			},
5736
			dragStart: function(ev) {
5737
				isDragging = true;
5738
				_this.handleSegMouseout(seg, ev); // ensure a mouseout on the manipulated event has been reported
5739
				_this.segResizeStart(seg, ev);
5740
			},
5741
			hitOver: function(hit, isOrig, origHit) {
5742
				var isAllowed = true;
5743
				var origHitSpan = _this.getSafeHitSpan(origHit);
5744
				var hitSpan = _this.getSafeHitSpan(hit);
5745
5746
				if (origHitSpan && hitSpan) {
5747
					resizeLocation = isStart ?
5748
						_this.computeEventStartResize(origHitSpan, hitSpan, event) :
5749
						_this.computeEventEndResize(origHitSpan, hitSpan, event);
5750
5751
					isAllowed = resizeLocation && _this.isEventLocationAllowed(resizeLocation, event);
5752
				}
5753
				else {
5754
					isAllowed = false;
5755
				}
5756
5757
				if (!isAllowed) {
5758
					resizeLocation = null;
5759
					disableCursor();
5760
				}
5761
				else {
5762
					if (
5763
						resizeLocation.start.isSame(event.start.clone().stripZone()) &&
5764
						resizeLocation.end.isSame(eventEnd.clone().stripZone())
5765
					) {
5766
						// no change. (FYI, event dates might have zones)
5767
						resizeLocation = null;
5768
					}
5769
				}
5770
5771
				if (resizeLocation) {
5772
					view.hideEvent(event);
5773
					_this.renderEventResize(resizeLocation, seg);
5774
				}
5775
			},
5776
			hitOut: function() { // called before mouse moves to a different hit OR moved out of all hits
5777
				resizeLocation = null;
5778
				view.showEvent(event); // for when out-of-bounds. show original
5779
			},
5780
			hitDone: function() { // resets the rendering to show the original event
5781
				_this.unrenderEventResize();
5782
				enableCursor();
5783
			},
5784
			interactionEnd: function(ev) {
5785
				if (isDragging) {
5786
					_this.segResizeStop(seg, ev);
5787
				}
5788
5789
				if (resizeLocation) { // valid date to resize to?
5790
					// no need to re-show original, will rerender all anyways. esp important if eventRenderWait
5791
					view.reportSegResize(seg, resizeLocation, _this.largeUnit, el, ev);
5792
				}
5793
				else {
5794
					view.showEvent(event);
5795
				}
5796
				_this.segResizeListener = null;
5797
			}
5798
		});
5799
5800
		return dragListener;
5801
	},
5802
5803
5804
	// Called before event segment resizing starts
5805
	segResizeStart: function(seg, ev) {
5806
		this.isResizingSeg = true;
5807
		this.view.publiclyTrigger('eventResizeStart', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy
5808
	},
5809
5810
5811
	// Called after event segment resizing stops
5812
	segResizeStop: function(seg, ev) {
5813
		this.isResizingSeg = false;
5814
		this.view.publiclyTrigger('eventResizeStop', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy
5815
	},
5816
5817
5818
	// Returns new date-information for an event segment being resized from its start
5819
	computeEventStartResize: function(startSpan, endSpan, event) {
5820
		return this.computeEventResize('start', startSpan, endSpan, event);
5821
	},
5822
5823
5824
	// Returns new date-information for an event segment being resized from its end
5825
	computeEventEndResize: function(startSpan, endSpan, event) {
5826
		return this.computeEventResize('end', startSpan, endSpan, event);
5827
	},
5828
5829
5830
	// Returns new zoned date information for an event segment being resized from its start OR end
5831
	// `type` is either 'start' or 'end'.
5832
	// DOES NOT consider overlap/constraint.
5833
	computeEventResize: function(type, startSpan, endSpan, event) {
5834
		var calendar = this.view.calendar;
5835
		var delta = this.diffDates(endSpan[type], startSpan[type]);
5836
		var resizeLocation; // zoned event date properties
5837
		var defaultDuration;
5838
5839
		// build original values to work from, guaranteeing a start and end
5840
		resizeLocation = {
5841
			start: event.start.clone(),
5842
			end: calendar.getEventEnd(event),
5843
			allDay: event.allDay
5844
		};
5845
5846
		// if an all-day event was in a timed area and was resized to a time, adjust start/end to have times
5847
		if (resizeLocation.allDay && durationHasTime(delta)) {
5848
			resizeLocation.allDay = false;
5849
			calendar.normalizeEventTimes(resizeLocation);
5850
		}
5851
5852
		resizeLocation[type].add(delta); // apply delta to start or end
5853
5854
		// if the event was compressed too small, find a new reasonable duration for it
5855
		if (!resizeLocation.start.isBefore(resizeLocation.end)) {
5856
5857
			defaultDuration =
5858
				this.minResizeDuration || // TODO: hack
5859
				(event.allDay ?
5860
					calendar.defaultAllDayEventDuration :
5861
					calendar.defaultTimedEventDuration);
5862
5863
			if (type == 'start') { // resizing the start?
5864
				resizeLocation.start = resizeLocation.end.clone().subtract(defaultDuration);
5865
			}
5866
			else { // resizing the end?
5867
				resizeLocation.end = resizeLocation.start.clone().add(defaultDuration);
5868
			}
5869
		}
5870
5871
		return resizeLocation;
5872
	},
5873
5874
5875
	// Renders a visual indication of an event being resized.
5876
	// `range` has the updated dates of the event. `seg` is the original segment object involved in the drag.
5877
	// Must return elements used for any mock events.
5878
	renderEventResize: function(range, seg) {
5879
		// subclasses must implement
5880
	},
5881
5882
5883
	// Unrenders a visual indication of an event being resized.
5884
	unrenderEventResize: function() {
5885
		// subclasses must implement
5886
	},
5887
5888
5889
	/* Rendering Utils
5890
	------------------------------------------------------------------------------------------------------------------*/
5891
5892
5893
	// Compute the text that should be displayed on an event's element.
5894
	// `range` can be the Event object itself, or something range-like, with at least a `start`.
5895
	// If event times are disabled, or the event has no time, will return a blank string.
5896
	// If not specified, formatStr will default to the eventTimeFormat setting,
5897
	// and displayEnd will default to the displayEventEnd setting.
5898
	getEventTimeText: function(range, formatStr, displayEnd) {
5899
5900
		if (formatStr == null) {
5901
			formatStr = this.eventTimeFormat;
5902
		}
5903
5904
		if (displayEnd == null) {
5905
			displayEnd = this.displayEventEnd;
5906
		}
5907
5908
		if (this.displayEventTime && range.start.hasTime()) {
5909
			if (displayEnd && range.end) {
5910
				return this.view.formatRange(range, formatStr);
5911
			}
5912
			else {
5913
				return range.start.format(formatStr);
5914
			}
5915
		}
5916
5917
		return '';
5918
	},
5919
5920
5921
	// Generic utility for generating the HTML classNames for an event segment's element
5922
	getSegClasses: function(seg, isDraggable, isResizable) {
5923
		var view = this.view;
5924
		var classes = [
5925
			'fc-event',
5926
			seg.isStart ? 'fc-start' : 'fc-not-start',
5927
			seg.isEnd ? 'fc-end' : 'fc-not-end'
5928
		].concat(this.getSegCustomClasses(seg));
5929
5930
		if (isDraggable) {
5931
			classes.push('fc-draggable');
5932
		}
5933
		if (isResizable) {
5934
			classes.push('fc-resizable');
5935
		}
5936
5937
		// event is currently selected? attach a className.
5938
		if (view.isEventSelected(seg.event)) {
5939
			classes.push('fc-selected');
5940
		}
5941
5942
		return classes;
5943
	},
5944
5945
5946
	// List of classes that were defined by the caller of the API in some way
5947
	getSegCustomClasses: function(seg) {
5948
		var event = seg.event;
5949
5950
		return [].concat(
5951
			event.className, // guaranteed to be an array
5952
			event.source ? event.source.className : []
5953
		);
5954
	},
5955
5956
5957
	// Utility for generating event skin-related CSS properties
5958
	getSegSkinCss: function(seg) {
5959
		return {
5960
			'background-color': this.getSegBackgroundColor(seg),
5961
			'border-color': this.getSegBorderColor(seg),
5962
			color: this.getSegTextColor(seg)
5963
		};
5964
	},
5965
5966
5967
	// Queries for caller-specified color, then falls back to default
5968
	getSegBackgroundColor: function(seg) {
5969
		return seg.event.backgroundColor ||
5970
			seg.event.color ||
5971
			this.getSegDefaultBackgroundColor(seg);
5972
	},
5973
5974
5975
	getSegDefaultBackgroundColor: function(seg) {
5976
		var source = seg.event.source || {};
5977
5978
		return source.backgroundColor ||
5979
			source.color ||
5980
			this.view.opt('eventBackgroundColor') ||
5981
			this.view.opt('eventColor');
5982
	},
5983
5984
5985
	// Queries for caller-specified color, then falls back to default
5986
	getSegBorderColor: function(seg) {
5987
		return seg.event.borderColor ||
5988
			seg.event.color ||
5989
			this.getSegDefaultBorderColor(seg);
5990
	},
5991
5992
5993
	getSegDefaultBorderColor: function(seg) {
5994
		var source = seg.event.source || {};
5995
5996
		return source.borderColor ||
5997
			source.color ||
5998
			this.view.opt('eventBorderColor') ||
5999
			this.view.opt('eventColor');
6000
	},
6001
6002
6003
	// Queries for caller-specified color, then falls back to default
6004
	getSegTextColor: function(seg) {
6005
		return seg.event.textColor ||
6006
			this.getSegDefaultTextColor(seg);
6007
	},
6008
6009
6010
	getSegDefaultTextColor: function(seg) {
6011
		var source = seg.event.source || {};
6012
6013
		return source.textColor ||
6014
			this.view.opt('eventTextColor');
6015
	},
6016
6017
6018
	/* Event Location Validation
6019
	------------------------------------------------------------------------------------------------------------------*/
6020
6021
6022
	isEventLocationAllowed: function(eventLocation, event) {
6023
		if (this.isEventLocationInRange(eventLocation)) {
6024
			var calendar = this.view.calendar;
6025
			var eventSpans = this.eventToSpans(eventLocation);
6026
			var i;
6027
6028
			if (eventSpans.length) {
6029
				for (i = 0; i < eventSpans.length; i++) {
6030
					if (!calendar.isEventSpanAllowed(eventSpans[i], event)) {
6031
						return false;
6032
					}
6033
				}
6034
6035
				return true;
6036
			}
6037
		}
6038
6039
		return false;
6040
	},
6041
6042
6043
	isExternalLocationAllowed: function(eventLocation, metaProps) { // FOR the external element
6044
		if (this.isEventLocationInRange(eventLocation)) {
6045
			var calendar = this.view.calendar;
6046
			var eventSpans = this.eventToSpans(eventLocation);
6047
			var i;
6048
6049
			if (eventSpans.length) {
6050
				for (i = 0; i < eventSpans.length; i++) {
6051
					if (!calendar.isExternalSpanAllowed(eventSpans[i], eventLocation, metaProps)) {
6052
						return false;
6053
					}
6054
				}
6055
6056
				return true;
6057
			}
6058
		}
6059
6060
		return false;
6061
	},
6062
6063
6064
	isEventLocationInRange: function(eventLocation) {
6065
		return isRangeWithinRange(
6066
			this.eventToRawRange(eventLocation),
6067
			this.view.validRange
6068
		);
6069
	},
6070
6071
6072
	/* Converting events -> eventRange -> eventSpan -> eventSegs
6073
	------------------------------------------------------------------------------------------------------------------*/
6074
6075
6076
	// Generates an array of segments for the given single event
6077
	// Can accept an event "location" as well (which only has start/end and no allDay)
6078
	eventToSegs: function(event) {
6079
		return this.eventsToSegs([ event ]);
6080
	},
6081
6082
6083
	// Generates spans (always unzoned) for the given event.
6084
	// Does not do any inverting for inverse-background events.
6085
	// Can accept an event "location" as well (which only has start/end and no allDay)
6086
	eventToSpans: function(event) {
6087
		var eventRange = this.eventToRange(event); // { start, end, isStart, isEnd }
6088
6089
		if (eventRange) {
6090
			return this.eventRangeToSpans(eventRange, event);
6091
		}
6092
		else { // out of view's valid range
6093
			return [];
6094
		}
6095
	},
6096
6097
6098
6099
	// Converts an array of event objects into an array of event segment objects.
6100
	// A custom `segSliceFunc` may be given for arbitrarily slicing up events.
6101
	// Doesn't guarantee an order for the resulting array.
6102
	eventsToSegs: function(allEvents, segSliceFunc) {
6103
		var _this = this;
6104
		var eventsById = groupEventsById(allEvents);
6105
		var segs = [];
6106
6107
		$.each(eventsById, function(id, events) {
6108
			var visibleEvents = [];
6109
			var eventRanges = [];
6110
			var eventRange; // { start, end, isStart, isEnd }
6111
			var i;
6112
6113
			for (i = 0; i < events.length; i++) {
6114
				eventRange = _this.eventToRange(events[i]); // might be null if completely out of range
6115
6116
				if (eventRange) {
6117
					eventRanges.push(eventRange);
6118
					visibleEvents.push(events[i]);
6119
				}
6120
			}
6121
6122
			// inverse-background events (utilize only the first event in calculations)
6123
			if (isInverseBgEvent(events[0])) {
6124
				eventRanges = _this.invertRanges(eventRanges); // will lose isStart/isEnd
6125
6126
				for (i = 0; i < eventRanges.length; i++) {
6127
					segs.push.apply(segs, // append to
6128
						_this.eventRangeToSegs(eventRanges[i], events[0], segSliceFunc)
6129
					);
6130
				}
6131
			}
6132
			// normal event ranges
6133
			else {
6134
				for (i = 0; i < eventRanges.length; i++) {
6135
					segs.push.apply(segs, // append to
6136
						_this.eventRangeToSegs(eventRanges[i], visibleEvents[i], segSliceFunc)
6137
					);
6138
				}
6139
			}
6140
		});
6141
6142
		return segs;
6143
	},
6144
6145
6146
	// Generates the unzoned start/end dates an event appears to occupy
6147
	// Can accept an event "location" as well (which only has start/end and no allDay)
6148
	// returns { start, end, isStart, isEnd }
6149
	// If the event is completely outside of the grid's valid range, will return undefined.
6150
	eventToRange: function(event) {
6151
		return this.refineRawEventRange(
6152
			this.eventToRawRange(event)
6153
		);
6154
	},
6155
6156
6157
	// Ensures the given range is within the view's activeRange and is correctly localized.
6158
	// Always returns a result
6159
	refineRawEventRange: function(rawRange) {
6160
		var view = this.view;
6161
		var calendar = view.calendar;
6162
		var range = intersectRanges(rawRange, view.activeRange);
6163
6164
		if (range) { // otherwise, event doesn't have valid range
6165
6166
			// hack: dynamic locale change forgets to upate stored event localed
6167
			calendar.localizeMoment(range.start);
6168
			calendar.localizeMoment(range.end);
6169
6170
			return range;
6171
		}
6172
	},
6173
6174
6175
	// not constrained to valid dates
6176
	// not given localizeMoment hack
6177
	eventToRawRange: function(event) {
6178
		var calendar = this.view.calendar;
6179
		var start = event.start.clone().stripZone();
6180
		var end = (
6181
				event.end ?
6182
					event.end.clone() :
6183
					// derive the end from the start and allDay. compute allDay if necessary
6184
					calendar.getDefaultEventEnd(
6185
						event.allDay != null ?
6186
							event.allDay :
6187
							!event.start.hasTime(),
6188
						event.start
6189
					)
6190
			).stripZone();
6191
6192
		return { start: start, end: end };
6193
	},
6194
6195
6196
	// Given an event's range (unzoned start/end), and the event itself,
6197
	// slice into segments (using the segSliceFunc function if specified)
6198
	// eventRange - { start, end, isStart, isEnd }
6199
	eventRangeToSegs: function(eventRange, event, segSliceFunc) {
6200
		var eventSpans = this.eventRangeToSpans(eventRange, event);
6201
		var segs = [];
6202
		var i;
6203
6204
		for (i = 0; i < eventSpans.length; i++) {
6205
			segs.push.apply(segs, // append to
6206
				this.eventSpanToSegs(eventSpans[i], event, segSliceFunc)
6207
			);
6208
		}
6209
6210
		return segs;
6211
	},
6212
6213
6214
	// Given an event's unzoned date range, return an array of eventSpan objects.
6215
	// eventSpan - { start, end, isStart, isEnd, otherthings... }
6216
	// Subclasses can override.
6217
	// Subclasses are obligated to forward eventRange.isStart/isEnd to the resulting spans.
6218
	eventRangeToSpans: function(eventRange, event) {
6219
		return [ $.extend({}, eventRange) ]; // copy into a single-item array
6220
	},
6221
6222
6223
	// Given an event's span (unzoned start/end and other misc data), and the event itself,
6224
	// slices into segments and attaches event-derived properties to them.
6225
	// eventSpan - { start, end, isStart, isEnd, otherthings... }
6226
	eventSpanToSegs: function(eventSpan, event, segSliceFunc) {
6227
		var segs = segSliceFunc ? segSliceFunc(eventSpan) : this.spanToSegs(eventSpan);
6228
		var i, seg;
6229
6230
		for (i = 0; i < segs.length; i++) {
6231
			seg = segs[i];
6232
6233
			// the eventSpan's isStart/isEnd takes precedence over the seg's
6234
			if (!eventSpan.isStart) {
6235
				seg.isStart = false;
6236
			}
6237
			if (!eventSpan.isEnd) {
6238
				seg.isEnd = false;
6239
			}
6240
6241
			seg.event = event;
6242
			seg.eventStartMS = +eventSpan.start; // TODO: not the best name after making spans unzoned
6243
			seg.eventDurationMS = eventSpan.end - eventSpan.start;
6244
		}
6245
6246
		return segs;
6247
	},
6248
6249
6250
	// Produces a new array of range objects that will cover all the time NOT covered by the given ranges.
6251
	// SIDE EFFECT: will mutate the given array and will use its date references.
6252
	invertRanges: function(ranges) {
6253
		var view = this.view;
6254
		var viewStart = view.activeRange.start.clone(); // need a copy
6255
		var viewEnd = view.activeRange.end.clone(); // need a copy
6256
		var inverseRanges = [];
6257
		var start = viewStart; // the end of the previous range. the start of the new range
6258
		var i, range;
6259
6260
		// ranges need to be in order. required for our date-walking algorithm
6261
		ranges.sort(compareRanges);
6262
6263
		for (i = 0; i < ranges.length; i++) {
6264
			range = ranges[i];
6265
6266
			// add the span of time before the event (if there is any)
6267
			if (range.start > start) { // compare millisecond time (skip any ambig logic)
6268
				inverseRanges.push({
6269
					start: start,
6270
					end: range.start
6271
				});
6272
			}
6273
6274
			if (range.end > start) {
6275
				start = range.end;
6276
			}
6277
		}
6278
6279
		// add the span of time after the last event (if there is any)
6280
		if (start < viewEnd) { // compare millisecond time (skip any ambig logic)
6281
			inverseRanges.push({
6282
				start: start,
6283
				end: viewEnd
6284
			});
6285
		}
6286
6287
		return inverseRanges;
6288
	},
6289
6290
6291
	sortEventSegs: function(segs) {
6292
		segs.sort(proxy(this, 'compareEventSegs'));
6293
	},
6294
6295
6296
	// A cmp function for determining which segments should take visual priority
6297
	compareEventSegs: function(seg1, seg2) {
6298
		return seg1.eventStartMS - seg2.eventStartMS || // earlier events go first
6299
			seg2.eventDurationMS - seg1.eventDurationMS || // tie? longer events go first
6300
			seg2.event.allDay - seg1.event.allDay || // tie? put all-day events first (booleans cast to 0/1)
6301
			compareByFieldSpecs(seg1.event, seg2.event, this.view.eventOrderSpecs);
6302
	}
6303
6304
});
6305
6306
6307
/* Utilities
6308
----------------------------------------------------------------------------------------------------------------------*/
6309
6310
6311
function pluckEventDateProps(event) {
6312
	return {
6313
		start: event.start.clone(),
6314
		end: event.end ? event.end.clone() : null,
6315
		allDay: event.allDay // keep it the same
6316
	};
6317
}
6318
FC.pluckEventDateProps = pluckEventDateProps;
6319
6320
6321
function isBgEvent(event) { // returns true if background OR inverse-background
6322
	var rendering = getEventRendering(event);
6323
	return rendering === 'background' || rendering === 'inverse-background';
6324
}
6325
FC.isBgEvent = isBgEvent; // export
6326
6327
6328
function isInverseBgEvent(event) {
6329
	return getEventRendering(event) === 'inverse-background';
6330
}
6331
6332
6333
function getEventRendering(event) {
6334
	return firstDefined((event.source || {}).rendering, event.rendering);
6335
}
6336
6337
6338
function groupEventsById(events) {
6339
	var eventsById = {};
6340
	var i, event;
6341
6342
	for (i = 0; i < events.length; i++) {
6343
		event = events[i];
6344
		(eventsById[event._id] || (eventsById[event._id] = [])).push(event);
6345
	}
6346
6347
	return eventsById;
6348
}
6349
6350
6351
// A cmp function for determining which non-inverted "ranges" (see above) happen earlier
6352
function compareRanges(range1, range2) {
6353
	return range1.start - range2.start; // earlier ranges go first
6354
}
6355
6356
6357
/* External-Dragging-Element Data
6358
----------------------------------------------------------------------------------------------------------------------*/
6359
6360
// Require all HTML5 data-* attributes used by FullCalendar to have this prefix.
6361
// A value of '' will query attributes like data-event. A value of 'fc' will query attributes like data-fc-event.
6362
FC.dataAttrPrefix = '';
6363
6364
// Given a jQuery element that might represent a dragged FullCalendar event, returns an intermediate data structure
6365
// to be used for Event Object creation.
6366
// A defined `.eventProps`, even when empty, indicates that an event should be created.
6367
function getDraggedElMeta(el) {
6368
	var prefix = FC.dataAttrPrefix;
6369
	var eventProps; // properties for creating the event, not related to date/time
6370
	var startTime; // a Duration
6371
	var duration;
6372
	var stick;
6373
6374
	if (prefix) { prefix += '-'; }
6375
	eventProps = el.data(prefix + 'event') || null;
6376
6377
	if (eventProps) {
6378
		if (typeof eventProps === 'object') {
6379
			eventProps = $.extend({}, eventProps); // make a copy
6380
		}
6381
		else { // something like 1 or true. still signal event creation
6382
			eventProps = {};
6383
		}
6384
6385
		// pluck special-cased date/time properties
6386
		startTime = eventProps.start;
6387
		if (startTime == null) { startTime = eventProps.time; } // accept 'time' as well
6388
		duration = eventProps.duration;
6389
		stick = eventProps.stick;
6390
		delete eventProps.start;
6391
		delete eventProps.time;
6392
		delete eventProps.duration;
6393
		delete eventProps.stick;
6394
	}
6395
6396
	// fallback to standalone attribute values for each of the date/time properties
6397
	if (startTime == null) { startTime = el.data(prefix + 'start'); }
6398
	if (startTime == null) { startTime = el.data(prefix + 'time'); } // accept 'time' as well
6399
	if (duration == null) { duration = el.data(prefix + 'duration'); }
6400
	if (stick == null) { stick = el.data(prefix + 'stick'); }
6401
6402
	// massage into correct data types
6403
	startTime = startTime != null ? moment.duration(startTime) : null;
6404
	duration = duration != null ? moment.duration(duration) : null;
6405
	stick = Boolean(stick);
6406
6407
	return { eventProps: eventProps, startTime: startTime, duration: duration, stick: stick };
6408
}
6409
6410
6411
;;
6412
6413
/*
6414
A set of rendering and date-related methods for a visual component comprised of one or more rows of day columns.
6415
Prerequisite: the object being mixed into needs to be a *Grid*
6416
*/
6417
var DayTableMixin = FC.DayTableMixin = {
6418
6419
	breakOnWeeks: false, // should create a new row for each week?
6420
	dayDates: null, // whole-day dates for each column. left to right
6421
	dayIndices: null, // for each day from start, the offset
6422
	daysPerRow: null,
6423
	rowCnt: null,
6424
	colCnt: null,
6425
	colHeadFormat: null,
6426
6427
6428
	// Populates internal variables used for date calculation and rendering
6429
	updateDayTable: function() {
6430
		var view = this.view;
6431
		var date = this.start.clone();
6432
		var dayIndex = -1;
6433
		var dayIndices = [];
6434
		var dayDates = [];
6435
		var daysPerRow;
6436
		var firstDay;
6437
		var rowCnt;
6438
6439
		while (date.isBefore(this.end)) { // loop each day from start to end
6440
			if (view.isHiddenDay(date)) {
6441
				dayIndices.push(dayIndex + 0.5); // mark that it's between indices
6442
			}
6443
			else {
6444
				dayIndex++;
6445
				dayIndices.push(dayIndex);
6446
				dayDates.push(date.clone());
6447
			}
6448
			date.add(1, 'days');
6449
		}
6450
6451
		if (this.breakOnWeeks) {
6452
			// count columns until the day-of-week repeats
6453
			firstDay = dayDates[0].day();
6454
			for (daysPerRow = 1; daysPerRow < dayDates.length; daysPerRow++) {
6455
				if (dayDates[daysPerRow].day() == firstDay) {
6456
					break;
6457
				}
6458
			}
6459
			rowCnt = Math.ceil(dayDates.length / daysPerRow);
6460
		}
6461
		else {
6462
			rowCnt = 1;
6463
			daysPerRow = dayDates.length;
6464
		}
6465
6466
		this.dayDates = dayDates;
6467
		this.dayIndices = dayIndices;
6468
		this.daysPerRow = daysPerRow;
6469
		this.rowCnt = rowCnt;
6470
6471
		this.updateDayTableCols();
6472
	},
6473
6474
6475
	// Computes and assigned the colCnt property and updates any options that may be computed from it
6476
	updateDayTableCols: function() {
6477
		this.colCnt = this.computeColCnt();
6478
		this.colHeadFormat = this.view.opt('columnFormat') || this.computeColHeadFormat();
6479
	},
6480
6481
6482
	// Determines how many columns there should be in the table
6483
	computeColCnt: function() {
6484
		return this.daysPerRow;
6485
	},
6486
6487
6488
	// Computes the ambiguously-timed moment for the given cell
6489
	getCellDate: function(row, col) {
6490
		return this.dayDates[
6491
				this.getCellDayIndex(row, col)
6492
			].clone();
6493
	},
6494
6495
6496
	// Computes the ambiguously-timed date range for the given cell
6497
	getCellRange: function(row, col) {
6498
		var start = this.getCellDate(row, col);
6499
		var end = start.clone().add(1, 'days');
6500
6501
		return { start: start, end: end };
6502
	},
6503
6504
6505
	// Returns the number of day cells, chronologically, from the first of the grid (0-based)
6506
	getCellDayIndex: function(row, col) {
6507
		return row * this.daysPerRow + this.getColDayIndex(col);
6508
	},
6509
6510
6511
	// Returns the numner of day cells, chronologically, from the first cell in *any given row*
6512
	getColDayIndex: function(col) {
6513
		if (this.isRTL) {
6514
			return this.colCnt - 1 - col;
6515
		}
6516
		else {
6517
			return col;
6518
		}
6519
	},
6520
6521
6522
	// Given a date, returns its chronolocial cell-index from the first cell of the grid.
6523
	// If the date lies between cells (because of hiddenDays), returns a floating-point value between offsets.
6524
	// If before the first offset, returns a negative number.
6525
	// If after the last offset, returns an offset past the last cell offset.
6526
	// Only works for *start* dates of cells. Will not work for exclusive end dates for cells.
6527
	getDateDayIndex: function(date) {
6528
		var dayIndices = this.dayIndices;
6529
		var dayOffset = date.diff(this.start, 'days');
6530
6531
		if (dayOffset < 0) {
6532
			return dayIndices[0] - 1;
6533
		}
6534
		else if (dayOffset >= dayIndices.length) {
6535
			return dayIndices[dayIndices.length - 1] + 1;
6536
		}
6537
		else {
6538
			return dayIndices[dayOffset];
6539
		}
6540
	},
6541
6542
6543
	/* Options
6544
	------------------------------------------------------------------------------------------------------------------*/
6545
6546
6547
	// Computes a default column header formatting string if `colFormat` is not explicitly defined
6548
	computeColHeadFormat: function() {
6549
		// if more than one week row, or if there are a lot of columns with not much space,
6550
		// put just the day numbers will be in each cell
6551
		if (this.rowCnt > 1 || this.colCnt > 10) {
6552
			return 'ddd'; // "Sat"
6553
		}
6554
		// multiple days, so full single date string WON'T be in title text
6555
		else if (this.colCnt > 1) {
6556
			return this.view.opt('dayOfMonthFormat'); // "Sat 12/10"
6557
		}
6558
		// single day, so full single date string will probably be in title text
6559
		else {
6560
			return 'dddd'; // "Saturday"
6561
		}
6562
	},
6563
6564
6565
	/* Slicing
6566
	------------------------------------------------------------------------------------------------------------------*/
6567
6568
6569
	// Slices up a date range into a segment for every week-row it intersects with
6570
	sliceRangeByRow: function(range) {
6571
		var daysPerRow = this.daysPerRow;
6572
		var normalRange = this.view.computeDayRange(range); // make whole-day range, considering nextDayThreshold
6573
		var rangeFirst = this.getDateDayIndex(normalRange.start); // inclusive first index
6574
		var rangeLast = this.getDateDayIndex(normalRange.end.clone().subtract(1, 'days')); // inclusive last index
6575
		var segs = [];
6576
		var row;
6577
		var rowFirst, rowLast; // inclusive day-index range for current row
6578
		var segFirst, segLast; // inclusive day-index range for segment
6579
6580
		for (row = 0; row < this.rowCnt; row++) {
6581
			rowFirst = row * daysPerRow;
6582
			rowLast = rowFirst + daysPerRow - 1;
6583
6584
			// intersect segment's offset range with the row's
6585
			segFirst = Math.max(rangeFirst, rowFirst);
6586
			segLast = Math.min(rangeLast, rowLast);
6587
6588
			// deal with in-between indices
6589
			segFirst = Math.ceil(segFirst); // in-between starts round to next cell
6590
			segLast = Math.floor(segLast); // in-between ends round to prev cell
6591
6592
			if (segFirst <= segLast) { // was there any intersection with the current row?
6593
				segs.push({
6594
					row: row,
6595
6596
					// normalize to start of row
6597
					firstRowDayIndex: segFirst - rowFirst,
6598
					lastRowDayIndex: segLast - rowFirst,
6599
6600
					// must be matching integers to be the segment's start/end
6601
					isStart: segFirst === rangeFirst,
6602
					isEnd: segLast === rangeLast
6603
				});
6604
			}
6605
		}
6606
6607
		return segs;
6608
	},
6609
6610
6611
	// Slices up a date range into a segment for every day-cell it intersects with.
6612
	// TODO: make more DRY with sliceRangeByRow somehow.
6613
	sliceRangeByDay: function(range) {
6614
		var daysPerRow = this.daysPerRow;
6615
		var normalRange = this.view.computeDayRange(range); // make whole-day range, considering nextDayThreshold
6616
		var rangeFirst = this.getDateDayIndex(normalRange.start); // inclusive first index
6617
		var rangeLast = this.getDateDayIndex(normalRange.end.clone().subtract(1, 'days')); // inclusive last index
6618
		var segs = [];
6619
		var row;
6620
		var rowFirst, rowLast; // inclusive day-index range for current row
6621
		var i;
6622
		var segFirst, segLast; // inclusive day-index range for segment
6623
6624
		for (row = 0; row < this.rowCnt; row++) {
6625
			rowFirst = row * daysPerRow;
6626
			rowLast = rowFirst + daysPerRow - 1;
6627
6628
			for (i = rowFirst; i <= rowLast; i++) {
6629
6630
				// intersect segment's offset range with the row's
6631
				segFirst = Math.max(rangeFirst, i);
6632
				segLast = Math.min(rangeLast, i);
6633
6634
				// deal with in-between indices
6635
				segFirst = Math.ceil(segFirst); // in-between starts round to next cell
6636
				segLast = Math.floor(segLast); // in-between ends round to prev cell
6637
6638
				if (segFirst <= segLast) { // was there any intersection with the current row?
6639
					segs.push({
6640
						row: row,
6641
6642
						// normalize to start of row
6643
						firstRowDayIndex: segFirst - rowFirst,
6644
						lastRowDayIndex: segLast - rowFirst,
6645
6646
						// must be matching integers to be the segment's start/end
6647
						isStart: segFirst === rangeFirst,
6648
						isEnd: segLast === rangeLast
6649
					});
6650
				}
6651
			}
6652
		}
6653
6654
		return segs;
6655
	},
6656
6657
6658
	/* Header Rendering
6659
	------------------------------------------------------------------------------------------------------------------*/
6660
6661
6662
	renderHeadHtml: function() {
6663
		var view = this.view;
6664
6665
		return '' +
6666
			'<div class="fc-row ' + view.widgetHeaderClass + '">' +
6667
				'<table>' +
6668
					'<thead>' +
6669
						this.renderHeadTrHtml() +
6670
					'</thead>' +
6671
				'</table>' +
6672
			'</div>';
6673
	},
6674
6675
6676
	renderHeadIntroHtml: function() {
6677
		return this.renderIntroHtml(); // fall back to generic
6678
	},
6679
6680
6681
	renderHeadTrHtml: function() {
6682
		return '' +
6683
			'<tr>' +
6684
				(this.isRTL ? '' : this.renderHeadIntroHtml()) +
6685
				this.renderHeadDateCellsHtml() +
6686
				(this.isRTL ? this.renderHeadIntroHtml() : '') +
6687
			'</tr>';
6688
	},
6689
6690
6691
	renderHeadDateCellsHtml: function() {
6692
		var htmls = [];
6693
		var col, date;
6694
6695
		for (col = 0; col < this.colCnt; col++) {
6696
			date = this.getCellDate(0, col);
6697
			htmls.push(this.renderHeadDateCellHtml(date));
6698
		}
6699
6700
		return htmls.join('');
6701
	},
6702
6703
6704
	// TODO: when internalApiVersion, accept an object for HTML attributes
6705
	// (colspan should be no different)
6706
	renderHeadDateCellHtml: function(date, colspan, otherAttrs) {
6707
		var view = this.view;
6708
		var isDateValid = isDateWithinRange(date, view.activeRange); // TODO: called too frequently. cache somehow.
6709
		var classNames = [
6710
			'fc-day-header',
6711
			view.widgetHeaderClass
6712
		];
6713
		var innerHtml = htmlEscape(date.format(this.colHeadFormat));
6714
6715
		// if only one row of days, the classNames on the header can represent the specific days beneath
6716
		if (this.rowCnt === 1) {
6717
			classNames = classNames.concat(
6718
				// includes the day-of-week class
6719
				// noThemeHighlight=true (don't highlight the header)
6720
				this.getDayClasses(date, true)
6721
			);
6722
		}
6723
		else {
6724
			classNames.push('fc-' + dayIDs[date.day()]); // only add the day-of-week class
6725
		}
6726
6727
		return '' +
6728
            '<th class="' + classNames.join(' ') + '"' +
6729
				((isDateValid && this.rowCnt) === 1 ?
6730
					' data-date="' + date.format('YYYY-MM-DD') + '"' :
6731
					'') +
6732
				(colspan > 1 ?
6733
					' colspan="' + colspan + '"' :
6734
					'') +
6735
				(otherAttrs ?
6736
					' ' + otherAttrs :
6737
					'') +
6738
				'>' +
6739
				(isDateValid ?
6740
					// don't make a link if the heading could represent multiple days, or if there's only one day (forceOff)
6741
					view.buildGotoAnchorHtml(
6742
						{ date: date, forceOff: this.rowCnt > 1 || this.colCnt === 1 },
6743
						innerHtml
6744
					) :
6745
					// if not valid, display text, but no link
6746
					innerHtml
6747
				) +
6748
			'</th>';
6749
	},
6750
6751
6752
	/* Background Rendering
6753
	------------------------------------------------------------------------------------------------------------------*/
6754
6755
6756
	renderBgTrHtml: function(row) {
6757
		return '' +
6758
			'<tr>' +
6759
				(this.isRTL ? '' : this.renderBgIntroHtml(row)) +
6760
				this.renderBgCellsHtml(row) +
6761
				(this.isRTL ? this.renderBgIntroHtml(row) : '') +
6762
			'</tr>';
6763
	},
6764
6765
6766
	renderBgIntroHtml: function(row) {
6767
		return this.renderIntroHtml(); // fall back to generic
6768
	},
6769
6770
6771
	renderBgCellsHtml: function(row) {
6772
		var htmls = [];
6773
		var col, date;
6774
6775
		for (col = 0; col < this.colCnt; col++) {
6776
			date = this.getCellDate(row, col);
6777
			htmls.push(this.renderBgCellHtml(date));
6778
		}
6779
6780
		return htmls.join('');
6781
	},
6782
6783
6784
	renderBgCellHtml: function(date, otherAttrs) {
6785
		var view = this.view;
6786
		var isDateValid = isDateWithinRange(date, view.activeRange); // TODO: called too frequently. cache somehow.
6787
		var classes = this.getDayClasses(date);
6788
6789
		classes.unshift('fc-day', view.widgetContentClass);
6790
6791
		return '<td class="' + classes.join(' ') + '"' +
6792
			(isDateValid ?
6793
				' data-date="' + date.format('YYYY-MM-DD') + '"' : // if date has a time, won't format it
6794
				'') +
6795
			(otherAttrs ?
6796
				' ' + otherAttrs :
6797
				'') +
6798
			'></td>';
6799
	},
6800
6801
6802
	/* Generic
6803
	------------------------------------------------------------------------------------------------------------------*/
6804
6805
6806
	// Generates the default HTML intro for any row. User classes should override
6807
	renderIntroHtml: function() {
6808
	},
6809
6810
6811
	// TODO: a generic method for dealing with <tr>, RTL, intro
6812
	// when increment internalApiVersion
6813
	// wrapTr (scheduler)
6814
6815
6816
	/* Utils
6817
	------------------------------------------------------------------------------------------------------------------*/
6818
6819
6820
	// Applies the generic "intro" and "outro" HTML to the given cells.
6821
	// Intro means the leftmost cell when the calendar is LTR and the rightmost cell when RTL. Vice-versa for outro.
6822
	bookendCells: function(trEl) {
6823
		var introHtml = this.renderIntroHtml();
6824
6825
		if (introHtml) {
6826
			if (this.isRTL) {
6827
				trEl.append(introHtml);
6828
			}
6829
			else {
6830
				trEl.prepend(introHtml);
6831
			}
6832
		}
6833
	}
6834
6835
};
6836
6837
;;
6838
6839
/* A component that renders a grid of whole-days that runs horizontally. There can be multiple rows, one per week.
6840
----------------------------------------------------------------------------------------------------------------------*/
6841
6842
var DayGrid = FC.DayGrid = Grid.extend(DayTableMixin, {
6843
6844
	numbersVisible: false, // should render a row for day/week numbers? set by outside view. TODO: make internal
6845
	bottomCoordPadding: 0, // hack for extending the hit area for the last row of the coordinate grid
6846
6847
	rowEls: null, // set of fake row elements
6848
	cellEls: null, // set of whole-day elements comprising the row's background
6849
	helperEls: null, // set of cell skeleton elements for rendering the mock event "helper"
6850
6851
	rowCoordCache: null,
6852
	colCoordCache: null,
6853
6854
6855
	// Renders the rows and columns into the component's `this.el`, which should already be assigned.
6856
	// isRigid determins whether the individual rows should ignore the contents and be a constant height.
6857
	// Relies on the view's colCnt and rowCnt. In the future, this component should probably be self-sufficient.
6858
	renderDates: function(isRigid) {
6859
		var view = this.view;
6860
		var rowCnt = this.rowCnt;
6861
		var colCnt = this.colCnt;
6862
		var html = '';
6863
		var row;
6864
		var col;
6865
6866
		for (row = 0; row < rowCnt; row++) {
6867
			html += this.renderDayRowHtml(row, isRigid);
6868
		}
6869
		this.el.html(html);
6870
6871
		this.rowEls = this.el.find('.fc-row');
6872
		this.cellEls = this.el.find('.fc-day, .fc-disabled-day');
6873
6874
		this.rowCoordCache = new CoordCache({
6875
			els: this.rowEls,
6876
			isVertical: true
6877
		});
6878
		this.colCoordCache = new CoordCache({
6879
			els: this.cellEls.slice(0, this.colCnt), // only the first row
6880
			isHorizontal: true
6881
		});
6882
6883
		// trigger dayRender with each cell's element
6884
		for (row = 0; row < rowCnt; row++) {
6885
			for (col = 0; col < colCnt; col++) {
6886
				view.publiclyTrigger(
6887
					'dayRender',
6888
					null,
6889
					this.getCellDate(row, col),
6890
					this.getCellEl(row, col)
6891
				);
6892
			}
6893
		}
6894
	},
6895
6896
6897
	unrenderDates: function() {
6898
		this.removeSegPopover();
6899
	},
6900
6901
6902
	renderBusinessHours: function() {
6903
		var segs = this.buildBusinessHourSegs(true); // wholeDay=true
6904
		this.renderFill('businessHours', segs, 'bgevent');
6905
	},
6906
6907
6908
	unrenderBusinessHours: function() {
6909
		this.unrenderFill('businessHours');
6910
	},
6911
6912
6913
	// Generates the HTML for a single row, which is a div that wraps a table.
6914
	// `row` is the row number.
6915
	renderDayRowHtml: function(row, isRigid) {
6916
		var view = this.view;
6917
		var classes = [ 'fc-row', 'fc-week', view.widgetContentClass ];
6918
6919
		if (isRigid) {
6920
			classes.push('fc-rigid');
6921
		}
6922
6923
		return '' +
6924
			'<div class="' + classes.join(' ') + '">' +
6925
				'<div class="fc-bg">' +
6926
					'<table>' +
6927
						this.renderBgTrHtml(row) +
6928
					'</table>' +
6929
				'</div>' +
6930
				'<div class="fc-content-skeleton">' +
6931
					'<table>' +
6932
						(this.numbersVisible ?
6933
							'<thead>' +
6934
								this.renderNumberTrHtml(row) +
6935
							'</thead>' :
6936
							''
6937
							) +
6938
					'</table>' +
6939
				'</div>' +
6940
			'</div>';
6941
	},
6942
6943
6944
	/* Grid Number Rendering
6945
	------------------------------------------------------------------------------------------------------------------*/
6946
6947
6948
	renderNumberTrHtml: function(row) {
6949
		return '' +
6950
			'<tr>' +
6951
				(this.isRTL ? '' : this.renderNumberIntroHtml(row)) +
6952
				this.renderNumberCellsHtml(row) +
6953
				(this.isRTL ? this.renderNumberIntroHtml(row) : '') +
6954
			'</tr>';
6955
	},
6956
6957
6958
	renderNumberIntroHtml: function(row) {
6959
		return this.renderIntroHtml();
6960
	},
6961
6962
6963
	renderNumberCellsHtml: function(row) {
6964
		var htmls = [];
6965
		var col, date;
6966
6967
		for (col = 0; col < this.colCnt; col++) {
6968
			date = this.getCellDate(row, col);
6969
			htmls.push(this.renderNumberCellHtml(date));
6970
		}
6971
6972
		return htmls.join('');
6973
	},
6974
6975
6976
	// Generates the HTML for the <td>s of the "number" row in the DayGrid's content skeleton.
6977
	// The number row will only exist if either day numbers or week numbers are turned on.
6978
	renderNumberCellHtml: function(date) {
6979
		var view = this.view;
6980
		var html = '';
6981
		var isDateValid = isDateWithinRange(date, view.activeRange); // TODO: called too frequently. cache somehow.
6982
		var isDayNumberVisible = view.dayNumbersVisible && isDateValid;
6983
		var classes;
6984
		var weekCalcFirstDoW;
6985
6986
		if (!isDayNumberVisible && !view.cellWeekNumbersVisible) {
6987
			// no numbers in day cell (week number must be along the side)
6988
			return '<td/>'; //  will create an empty space above events :(
6989
		}
6990
6991
		classes = this.getDayClasses(date);
6992
		classes.unshift('fc-day-top');
6993
6994
		if (view.cellWeekNumbersVisible) {
6995
			// To determine the day of week number change under ISO, we cannot
6996
			// rely on moment.js methods such as firstDayOfWeek() or weekday(),
6997
			// because they rely on the locale's dow (possibly overridden by
6998
			// our firstDay option), which may not be Monday. We cannot change
6999
			// dow, because that would affect the calendar start day as well.
7000
			if (date._locale._fullCalendar_weekCalc === 'ISO') {
7001
				weekCalcFirstDoW = 1;  // Monday by ISO 8601 definition
7002
			}
7003
			else {
7004
				weekCalcFirstDoW = date._locale.firstDayOfWeek();
7005
			}
7006
		}
7007
7008
		html += '<td class="' + classes.join(' ') + '"' +
7009
			(isDateValid ?
7010
				' data-date="' + date.format() + '"' :
7011
				''
7012
				) +
7013
			'>';
7014
7015
		if (view.cellWeekNumbersVisible && (date.day() == weekCalcFirstDoW)) {
7016
			html += view.buildGotoAnchorHtml(
7017
				{ date: date, type: 'week' },
7018
				{ 'class': 'fc-week-number' },
7019
				date.format('w') // inner HTML
7020
			);
7021
		}
7022
7023
		if (isDayNumberVisible) {
7024
			html += view.buildGotoAnchorHtml(
7025
				date,
7026
				{ 'class': 'fc-day-number' },
7027
				date.date() // inner HTML
7028
			);
7029
		}
7030
7031
		html += '</td>';
7032
7033
		return html;
7034
	},
7035
7036
7037
	/* Options
7038
	------------------------------------------------------------------------------------------------------------------*/
7039
7040
7041
	// Computes a default event time formatting string if `timeFormat` is not explicitly defined
7042
	computeEventTimeFormat: function() {
7043
		return this.view.opt('extraSmallTimeFormat'); // like "6p" or "6:30p"
7044
	},
7045
7046
7047
	// Computes a default `displayEventEnd` value if one is not expliclty defined
7048
	computeDisplayEventEnd: function() {
7049
		return this.colCnt == 1; // we'll likely have space if there's only one day
7050
	},
7051
7052
7053
	/* Dates
7054
	------------------------------------------------------------------------------------------------------------------*/
7055
7056
7057
	rangeUpdated: function() {
7058
		this.updateDayTable();
7059
	},
7060
7061
7062
	// Slices up the given span (unzoned start/end with other misc data) into an array of segments
7063
	spanToSegs: function(span) {
7064
		var segs = this.sliceRangeByRow(span);
7065
		var i, seg;
7066
7067
		for (i = 0; i < segs.length; i++) {
7068
			seg = segs[i];
7069
			if (this.isRTL) {
7070
				seg.leftCol = this.daysPerRow - 1 - seg.lastRowDayIndex;
7071
				seg.rightCol = this.daysPerRow - 1 - seg.firstRowDayIndex;
7072
			}
7073
			else {
7074
				seg.leftCol = seg.firstRowDayIndex;
7075
				seg.rightCol = seg.lastRowDayIndex;
7076
			}
7077
		}
7078
7079
		return segs;
7080
	},
7081
7082
7083
	/* Hit System
7084
	------------------------------------------------------------------------------------------------------------------*/
7085
7086
7087
	prepareHits: function() {
7088
		this.colCoordCache.build();
7089
		this.rowCoordCache.build();
7090
		this.rowCoordCache.bottoms[this.rowCnt - 1] += this.bottomCoordPadding; // hack
7091
	},
7092
7093
7094
	releaseHits: function() {
7095
		this.colCoordCache.clear();
7096
		this.rowCoordCache.clear();
7097
	},
7098
7099
7100
	queryHit: function(leftOffset, topOffset) {
7101
		if (this.colCoordCache.isLeftInBounds(leftOffset) && this.rowCoordCache.isTopInBounds(topOffset)) {
7102
			var col = this.colCoordCache.getHorizontalIndex(leftOffset);
7103
			var row = this.rowCoordCache.getVerticalIndex(topOffset);
7104
7105
			if (row != null && col != null) {
7106
				return this.getCellHit(row, col);
7107
			}
7108
		}
7109
	},
7110
7111
7112
	getHitSpan: function(hit) {
7113
		return this.getCellRange(hit.row, hit.col);
7114
	},
7115
7116
7117
	getHitEl: function(hit) {
7118
		return this.getCellEl(hit.row, hit.col);
7119
	},
7120
7121
7122
	/* Cell System
7123
	------------------------------------------------------------------------------------------------------------------*/
7124
	// FYI: the first column is the leftmost column, regardless of date
7125
7126
7127
	getCellHit: function(row, col) {
7128
		return {
7129
			row: row,
7130
			col: col,
7131
			component: this, // needed unfortunately :(
7132
			left: this.colCoordCache.getLeftOffset(col),
7133
			right: this.colCoordCache.getRightOffset(col),
7134
			top: this.rowCoordCache.getTopOffset(row),
7135
			bottom: this.rowCoordCache.getBottomOffset(row)
7136
		};
7137
	},
7138
7139
7140
	getCellEl: function(row, col) {
7141
		return this.cellEls.eq(row * this.colCnt + col);
7142
	},
7143
7144
7145
	/* Event Drag Visualization
7146
	------------------------------------------------------------------------------------------------------------------*/
7147
	// TODO: move to DayGrid.event, similar to what we did with Grid's drag methods
7148
7149
7150
	// Renders a visual indication of an event or external element being dragged.
7151
	// `eventLocation` has zoned start and end (optional)
7152
	renderDrag: function(eventLocation, seg) {
7153
		var eventSpans = this.eventToSpans(eventLocation);
7154
		var i;
7155
7156
		// always render a highlight underneath
7157
		for (i = 0; i < eventSpans.length; i++) {
7158
			this.renderHighlight(eventSpans[i]);
7159
		}
7160
7161
		// if a segment from the same calendar but another component is being dragged, render a helper event
7162
		if (seg && seg.component !== this) {
7163
			return this.renderEventLocationHelper(eventLocation, seg); // returns mock event elements
7164
		}
7165
	},
7166
7167
7168
	// Unrenders any visual indication of a hovering event
7169
	unrenderDrag: function() {
7170
		this.unrenderHighlight();
7171
		this.unrenderHelper();
7172
	},
7173
7174
7175
	/* Event Resize Visualization
7176
	------------------------------------------------------------------------------------------------------------------*/
7177
7178
7179
	// Renders a visual indication of an event being resized
7180
	renderEventResize: function(eventLocation, seg) {
7181
		var eventSpans = this.eventToSpans(eventLocation);
7182
		var i;
7183
7184
		for (i = 0; i < eventSpans.length; i++) {
7185
			this.renderHighlight(eventSpans[i]);
7186
		}
7187
7188
		return this.renderEventLocationHelper(eventLocation, seg); // returns mock event elements
7189
	},
7190
7191
7192
	// Unrenders a visual indication of an event being resized
7193
	unrenderEventResize: function() {
7194
		this.unrenderHighlight();
7195
		this.unrenderHelper();
7196
	},
7197
7198
7199
	/* Event Helper
7200
	------------------------------------------------------------------------------------------------------------------*/
7201
7202
7203
	// Renders a mock "helper" event. `sourceSeg` is the associated internal segment object. It can be null.
7204
	renderHelper: function(event, sourceSeg) {
7205
		var helperNodes = [];
7206
		var segs = this.eventToSegs(event);
7207
		var rowStructs;
7208
7209
		segs = this.renderFgSegEls(segs); // assigns each seg's el and returns a subset of segs that were rendered
7210
		rowStructs = this.renderSegRows(segs);
7211
7212
		// inject each new event skeleton into each associated row
7213
		this.rowEls.each(function(row, rowNode) {
7214
			var rowEl = $(rowNode); // the .fc-row
7215
			var skeletonEl = $('<div class="fc-helper-skeleton"><table/></div>'); // will be absolutely positioned
7216
			var skeletonTop;
7217
7218
			// If there is an original segment, match the top position. Otherwise, put it at the row's top level
7219
			if (sourceSeg && sourceSeg.row === row) {
7220
				skeletonTop = sourceSeg.el.position().top;
7221
			}
7222
			else {
7223
				skeletonTop = rowEl.find('.fc-content-skeleton tbody').position().top;
7224
			}
7225
7226
			skeletonEl.css('top', skeletonTop)
7227
				.find('table')
7228
					.append(rowStructs[row].tbodyEl);
7229
7230
			rowEl.append(skeletonEl);
7231
			helperNodes.push(skeletonEl[0]);
7232
		});
7233
7234
		return ( // must return the elements rendered
7235
			this.helperEls = $(helperNodes) // array -> jQuery set
7236
		);
7237
	},
7238
7239
7240
	// Unrenders any visual indication of a mock helper event
7241
	unrenderHelper: function() {
7242
		if (this.helperEls) {
7243
			this.helperEls.remove();
7244
			this.helperEls = null;
7245
		}
7246
	},
7247
7248
7249
	/* Fill System (highlight, background events, business hours)
7250
	------------------------------------------------------------------------------------------------------------------*/
7251
7252
7253
	fillSegTag: 'td', // override the default tag name
7254
7255
7256
	// Renders a set of rectangles over the given segments of days.
7257
	// Only returns segments that successfully rendered.
7258
	renderFill: function(type, segs, className) {
7259
		var nodes = [];
7260
		var i, seg;
7261
		var skeletonEl;
7262
7263
		segs = this.renderFillSegEls(type, segs); // assignes `.el` to each seg. returns successfully rendered segs
7264
7265
		for (i = 0; i < segs.length; i++) {
7266
			seg = segs[i];
7267
			skeletonEl = this.renderFillRow(type, seg, className);
7268
			this.rowEls.eq(seg.row).append(skeletonEl);
7269
			nodes.push(skeletonEl[0]);
7270
		}
7271
7272
		this.elsByFill[type] = $(nodes);
7273
7274
		return segs;
7275
	},
7276
7277
7278
	// Generates the HTML needed for one row of a fill. Requires the seg's el to be rendered.
7279
	renderFillRow: function(type, seg, className) {
7280
		var colCnt = this.colCnt;
7281
		var startCol = seg.leftCol;
7282
		var endCol = seg.rightCol + 1;
7283
		var skeletonEl;
7284
		var trEl;
7285
7286
		className = className || type.toLowerCase();
7287
7288
		skeletonEl = $(
7289
			'<div class="fc-' + className + '-skeleton">' +
7290
				'<table><tr/></table>' +
7291
			'</div>'
7292
		);
7293
		trEl = skeletonEl.find('tr');
7294
7295
		if (startCol > 0) {
7296
			trEl.append('<td colspan="' + startCol + '"/>');
7297
		}
7298
7299
		trEl.append(
7300
			seg.el.attr('colspan', endCol - startCol)
7301
		);
7302
7303
		if (endCol < colCnt) {
7304
			trEl.append('<td colspan="' + (colCnt - endCol) + '"/>');
7305
		}
7306
7307
		this.bookendCells(trEl);
7308
7309
		return skeletonEl;
7310
	}
7311
7312
});
7313
7314
;;
7315
7316
/* Event-rendering methods for the DayGrid class
7317
----------------------------------------------------------------------------------------------------------------------*/
7318
7319
DayGrid.mixin({
7320
7321
	rowStructs: null, // an array of objects, each holding information about a row's foreground event-rendering
7322
7323
7324
	// Unrenders all events currently rendered on the grid
7325
	unrenderEvents: function() {
7326
		this.removeSegPopover(); // removes the "more.." events popover
7327
		Grid.prototype.unrenderEvents.apply(this, arguments); // calls the super-method
7328
	},
7329
7330
7331
	// Retrieves all rendered segment objects currently rendered on the grid
7332
	getEventSegs: function() {
7333
		return Grid.prototype.getEventSegs.call(this) // get the segments from the super-method
7334
			.concat(this.popoverSegs || []); // append the segments from the "more..." popover
7335
	},
7336
7337
7338
	// Renders the given background event segments onto the grid
7339
	renderBgSegs: function(segs) {
7340
7341
		// don't render timed background events
7342
		var allDaySegs = $.grep(segs, function(seg) {
7343
			return seg.event.allDay;
7344
		});
7345
7346
		return Grid.prototype.renderBgSegs.call(this, allDaySegs); // call the super-method
7347
	},
7348
7349
7350
	// Renders the given foreground event segments onto the grid
7351
	renderFgSegs: function(segs) {
7352
		var rowStructs;
7353
7354
		// render an `.el` on each seg
7355
		// returns a subset of the segs. segs that were actually rendered
7356
		segs = this.renderFgSegEls(segs);
7357
7358
		rowStructs = this.rowStructs = this.renderSegRows(segs);
7359
7360
		// append to each row's content skeleton
7361
		this.rowEls.each(function(i, rowNode) {
7362
			$(rowNode).find('.fc-content-skeleton > table').append(
7363
				rowStructs[i].tbodyEl
7364
			);
7365
		});
7366
7367
		return segs; // return only the segs that were actually rendered
7368
	},
7369
7370
7371
	// Unrenders all currently rendered foreground event segments
7372
	unrenderFgSegs: function() {
7373
		var rowStructs = this.rowStructs || [];
7374
		var rowStruct;
7375
7376
		while ((rowStruct = rowStructs.pop())) {
7377
			rowStruct.tbodyEl.remove();
7378
		}
7379
7380
		this.rowStructs = null;
7381
	},
7382
7383
7384
	// Uses the given events array to generate <tbody> elements that should be appended to each row's content skeleton.
7385
	// Returns an array of rowStruct objects (see the bottom of `renderSegRow`).
7386
	// PRECONDITION: each segment shoud already have a rendered and assigned `.el`
7387
	renderSegRows: function(segs) {
7388
		var rowStructs = [];
7389
		var segRows;
7390
		var row;
7391
7392
		segRows = this.groupSegRows(segs); // group into nested arrays
7393
7394
		// iterate each row of segment groupings
7395
		for (row = 0; row < segRows.length; row++) {
7396
			rowStructs.push(
7397
				this.renderSegRow(row, segRows[row])
7398
			);
7399
		}
7400
7401
		return rowStructs;
7402
	},
7403
7404
7405
	// Builds the HTML to be used for the default element for an individual segment
7406
	fgSegHtml: function(seg, disableResizing) {
7407
		var view = this.view;
7408
		var event = seg.event;
7409
		var isDraggable = view.isEventDraggable(event);
7410
		var isResizableFromStart = !disableResizing && event.allDay &&
7411
			seg.isStart && view.isEventResizableFromStart(event);
7412
		var isResizableFromEnd = !disableResizing && event.allDay &&
7413
			seg.isEnd && view.isEventResizableFromEnd(event);
7414
		var classes = this.getSegClasses(seg, isDraggable, isResizableFromStart || isResizableFromEnd);
7415
		var skinCss = cssToStr(this.getSegSkinCss(seg));
7416
		var timeHtml = '';
7417
		var timeText;
7418
		var titleHtml;
7419
7420
		classes.unshift('fc-day-grid-event', 'fc-h-event');
7421
7422
		// Only display a timed events time if it is the starting segment
7423
		if (seg.isStart) {
7424
			timeText = this.getEventTimeText(event);
7425
			if (timeText) {
7426
				timeHtml = '<span class="fc-time">' + htmlEscape(timeText) + '</span>';
7427
			}
7428
		}
7429
7430
		titleHtml =
7431
			'<span class="fc-title">' +
7432
				(htmlEscape(event.title || '') || '&nbsp;') + // we always want one line of height
7433
			'</span>';
7434
		
7435
		return '<a class="' + classes.join(' ') + '"' +
7436
				(event.url ?
7437
					' href="' + htmlEscape(event.url) + '"' :
7438
					''
7439
					) +
7440
				(skinCss ?
7441
					' style="' + skinCss + '"' :
7442
					''
7443
					) +
7444
			'>' +
7445
				'<div class="fc-content">' +
7446
					(this.isRTL ?
7447
						titleHtml + ' ' + timeHtml : // put a natural space in between
7448
						timeHtml + ' ' + titleHtml   //
7449
						) +
7450
				'</div>' +
7451
				(isResizableFromStart ?
7452
					'<div class="fc-resizer fc-start-resizer" />' :
7453
					''
7454
					) +
7455
				(isResizableFromEnd ?
7456
					'<div class="fc-resizer fc-end-resizer" />' :
7457
					''
7458
					) +
7459
			'</a>';
7460
	},
7461
7462
7463
	// Given a row # and an array of segments all in the same row, render a <tbody> element, a skeleton that contains
7464
	// the segments. Returns object with a bunch of internal data about how the render was calculated.
7465
	// NOTE: modifies rowSegs
7466
	renderSegRow: function(row, rowSegs) {
7467
		var colCnt = this.colCnt;
7468
		var segLevels = this.buildSegLevels(rowSegs); // group into sub-arrays of levels
7469
		var levelCnt = Math.max(1, segLevels.length); // ensure at least one level
7470
		var tbody = $('<tbody/>');
7471
		var segMatrix = []; // lookup for which segments are rendered into which level+col cells
7472
		var cellMatrix = []; // lookup for all <td> elements of the level+col matrix
7473
		var loneCellMatrix = []; // lookup for <td> elements that only take up a single column
7474
		var i, levelSegs;
7475
		var col;
7476
		var tr;
7477
		var j, seg;
7478
		var td;
7479
7480
		// populates empty cells from the current column (`col`) to `endCol`
7481
		function emptyCellsUntil(endCol) {
7482
			while (col < endCol) {
7483
				// try to grab a cell from the level above and extend its rowspan. otherwise, create a fresh cell
7484
				td = (loneCellMatrix[i - 1] || [])[col];
7485
				if (td) {
7486
					td.attr(
7487
						'rowspan',
7488
						parseInt(td.attr('rowspan') || 1, 10) + 1
7489
					);
7490
				}
7491
				else {
7492
					td = $('<td/>');
7493
					tr.append(td);
7494
				}
7495
				cellMatrix[i][col] = td;
7496
				loneCellMatrix[i][col] = td;
7497
				col++;
7498
			}
7499
		}
7500
7501
		for (i = 0; i < levelCnt; i++) { // iterate through all levels
7502
			levelSegs = segLevels[i];
7503
			col = 0;
7504
			tr = $('<tr/>');
7505
7506
			segMatrix.push([]);
7507
			cellMatrix.push([]);
7508
			loneCellMatrix.push([]);
7509
7510
			// levelCnt might be 1 even though there are no actual levels. protect against this.
7511
			// this single empty row is useful for styling.
7512
			if (levelSegs) {
7513
				for (j = 0; j < levelSegs.length; j++) { // iterate through segments in level
7514
					seg = levelSegs[j];
7515
7516
					emptyCellsUntil(seg.leftCol);
7517
7518
					// create a container that occupies or more columns. append the event element.
7519
					td = $('<td class="fc-event-container"/>').append(seg.el);
7520
					if (seg.leftCol != seg.rightCol) {
7521
						td.attr('colspan', seg.rightCol - seg.leftCol + 1);
7522
					}
7523
					else { // a single-column segment
7524
						loneCellMatrix[i][col] = td;
7525
					}
7526
7527
					while (col <= seg.rightCol) {
7528
						cellMatrix[i][col] = td;
7529
						segMatrix[i][col] = seg;
7530
						col++;
7531
					}
7532
7533
					tr.append(td);
7534
				}
7535
			}
7536
7537
			emptyCellsUntil(colCnt); // finish off the row
7538
			this.bookendCells(tr);
7539
			tbody.append(tr);
7540
		}
7541
7542
		return { // a "rowStruct"
7543
			row: row, // the row number
7544
			tbodyEl: tbody,
7545
			cellMatrix: cellMatrix,
7546
			segMatrix: segMatrix,
7547
			segLevels: segLevels,
7548
			segs: rowSegs
7549
		};
7550
	},
7551
7552
7553
	// Stacks a flat array of segments, which are all assumed to be in the same row, into subarrays of vertical levels.
7554
	// NOTE: modifies segs
7555
	buildSegLevels: function(segs) {
7556
		var levels = [];
7557
		var i, seg;
7558
		var j;
7559
7560
		// Give preference to elements with certain criteria, so they have
7561
		// a chance to be closer to the top.
7562
		this.sortEventSegs(segs);
7563
		
7564
		for (i = 0; i < segs.length; i++) {
7565
			seg = segs[i];
7566
7567
			// loop through levels, starting with the topmost, until the segment doesn't collide with other segments
7568
			for (j = 0; j < levels.length; j++) {
7569
				if (!isDaySegCollision(seg, levels[j])) {
7570
					break;
7571
				}
7572
			}
7573
			// `j` now holds the desired subrow index
7574
			seg.level = j;
7575
7576
			// create new level array if needed and append segment
7577
			(levels[j] || (levels[j] = [])).push(seg);
7578
		}
7579
7580
		// order segments left-to-right. very important if calendar is RTL
7581
		for (j = 0; j < levels.length; j++) {
7582
			levels[j].sort(compareDaySegCols);
7583
		}
7584
7585
		return levels;
7586
	},
7587
7588
7589
	// Given a flat array of segments, return an array of sub-arrays, grouped by each segment's row
7590
	groupSegRows: function(segs) {
7591
		var segRows = [];
7592
		var i;
7593
7594
		for (i = 0; i < this.rowCnt; i++) {
7595
			segRows.push([]);
7596
		}
7597
7598
		for (i = 0; i < segs.length; i++) {
7599
			segRows[segs[i].row].push(segs[i]);
7600
		}
7601
7602
		return segRows;
7603
	}
7604
7605
});
7606
7607
7608
// Computes whether two segments' columns collide. They are assumed to be in the same row.
7609
function isDaySegCollision(seg, otherSegs) {
7610
	var i, otherSeg;
7611
7612
	for (i = 0; i < otherSegs.length; i++) {
7613
		otherSeg = otherSegs[i];
7614
7615
		if (
7616
			otherSeg.leftCol <= seg.rightCol &&
7617
			otherSeg.rightCol >= seg.leftCol
7618
		) {
7619
			return true;
7620
		}
7621
	}
7622
7623
	return false;
7624
}
7625
7626
7627
// A cmp function for determining the leftmost event
7628
function compareDaySegCols(a, b) {
7629
	return a.leftCol - b.leftCol;
7630
}
7631
7632
;;
7633
7634
/* Methods relate to limiting the number events for a given day on a DayGrid
7635
----------------------------------------------------------------------------------------------------------------------*/
7636
// NOTE: all the segs being passed around in here are foreground segs
7637
7638
DayGrid.mixin({
7639
7640
	segPopover: null, // the Popover that holds events that can't fit in a cell. null when not visible
7641
	popoverSegs: null, // an array of segment objects that the segPopover holds. null when not visible
7642
7643
7644
	removeSegPopover: function() {
7645
		if (this.segPopover) {
7646
			this.segPopover.hide(); // in handler, will call segPopover's removeElement
7647
		}
7648
	},
7649
7650
7651
	// Limits the number of "levels" (vertically stacking layers of events) for each row of the grid.
7652
	// `levelLimit` can be false (don't limit), a number, or true (should be computed).
7653
	limitRows: function(levelLimit) {
7654
		var rowStructs = this.rowStructs || [];
7655
		var row; // row #
7656
		var rowLevelLimit;
7657
7658
		for (row = 0; row < rowStructs.length; row++) {
7659
			this.unlimitRow(row);
7660
7661
			if (!levelLimit) {
7662
				rowLevelLimit = false;
7663
			}
7664
			else if (typeof levelLimit === 'number') {
7665
				rowLevelLimit = levelLimit;
7666
			}
7667
			else {
7668
				rowLevelLimit = this.computeRowLevelLimit(row);
7669
			}
7670
7671
			if (rowLevelLimit !== false) {
7672
				this.limitRow(row, rowLevelLimit);
7673
			}
7674
		}
7675
	},
7676
7677
7678
	// Computes the number of levels a row will accomodate without going outside its bounds.
7679
	// Assumes the row is "rigid" (maintains a constant height regardless of what is inside).
7680
	// `row` is the row number.
7681
	computeRowLevelLimit: function(row) {
7682
		var rowEl = this.rowEls.eq(row); // the containing "fake" row div
7683
		var rowHeight = rowEl.height(); // TODO: cache somehow?
7684
		var trEls = this.rowStructs[row].tbodyEl.children();
7685
		var i, trEl;
7686
		var trHeight;
7687
7688
		function iterInnerHeights(i, childNode) {
7689
			trHeight = Math.max(trHeight, $(childNode).outerHeight());
7690
		}
7691
7692
		// Reveal one level <tr> at a time and stop when we find one out of bounds
7693
		for (i = 0; i < trEls.length; i++) {
7694
			trEl = trEls.eq(i).removeClass('fc-limited'); // reset to original state (reveal)
7695
7696
			// with rowspans>1 and IE8, trEl.outerHeight() would return the height of the largest cell,
7697
			// so instead, find the tallest inner content element.
7698
			trHeight = 0;
7699
			trEl.find('> td > :first-child').each(iterInnerHeights);
7700
7701
			if (trEl.position().top + trHeight > rowHeight) {
7702
				return i;
7703
			}
7704
		}
7705
7706
		return false; // should not limit at all
7707
	},
7708
7709
7710
	// Limits the given grid row to the maximum number of levels and injects "more" links if necessary.
7711
	// `row` is the row number.
7712
	// `levelLimit` is a number for the maximum (inclusive) number of levels allowed.
7713
	limitRow: function(row, levelLimit) {
7714
		var _this = this;
7715
		var rowStruct = this.rowStructs[row];
7716
		var moreNodes = []; // array of "more" <a> links and <td> DOM nodes
7717
		var col = 0; // col #, left-to-right (not chronologically)
7718
		var levelSegs; // array of segment objects in the last allowable level, ordered left-to-right
7719
		var cellMatrix; // a matrix (by level, then column) of all <td> jQuery elements in the row
7720
		var limitedNodes; // array of temporarily hidden level <tr> and segment <td> DOM nodes
7721
		var i, seg;
7722
		var segsBelow; // array of segment objects below `seg` in the current `col`
7723
		var totalSegsBelow; // total number of segments below `seg` in any of the columns `seg` occupies
7724
		var colSegsBelow; // array of segment arrays, below seg, one for each column (offset from segs's first column)
7725
		var td, rowspan;
7726
		var segMoreNodes; // array of "more" <td> cells that will stand-in for the current seg's cell
7727
		var j;
7728
		var moreTd, moreWrap, moreLink;
7729
7730
		// Iterates through empty level cells and places "more" links inside if need be
7731
		function emptyCellsUntil(endCol) { // goes from current `col` to `endCol`
7732
			while (col < endCol) {
7733
				segsBelow = _this.getCellSegs(row, col, levelLimit);
7734
				if (segsBelow.length) {
7735
					td = cellMatrix[levelLimit - 1][col];
7736
					moreLink = _this.renderMoreLink(row, col, segsBelow);
7737
					moreWrap = $('<div/>').append(moreLink);
7738
					td.append(moreWrap);
7739
					moreNodes.push(moreWrap[0]);
7740
				}
7741
				col++;
7742
			}
7743
		}
7744
7745
		if (levelLimit && levelLimit < rowStruct.segLevels.length) { // is it actually over the limit?
7746
			levelSegs = rowStruct.segLevels[levelLimit - 1];
7747
			cellMatrix = rowStruct.cellMatrix;
7748
7749
			limitedNodes = rowStruct.tbodyEl.children().slice(levelLimit) // get level <tr> elements past the limit
7750
				.addClass('fc-limited').get(); // hide elements and get a simple DOM-nodes array
7751
7752
			// iterate though segments in the last allowable level
7753
			for (i = 0; i < levelSegs.length; i++) {
7754
				seg = levelSegs[i];
7755
				emptyCellsUntil(seg.leftCol); // process empty cells before the segment
7756
7757
				// determine *all* segments below `seg` that occupy the same columns
7758
				colSegsBelow = [];
7759
				totalSegsBelow = 0;
7760
				while (col <= seg.rightCol) {
7761
					segsBelow = this.getCellSegs(row, col, levelLimit);
7762
					colSegsBelow.push(segsBelow);
7763
					totalSegsBelow += segsBelow.length;
7764
					col++;
7765
				}
7766
7767
				if (totalSegsBelow) { // do we need to replace this segment with one or many "more" links?
7768
					td = cellMatrix[levelLimit - 1][seg.leftCol]; // the segment's parent cell
7769
					rowspan = td.attr('rowspan') || 1;
7770
					segMoreNodes = [];
7771
7772
					// make a replacement <td> for each column the segment occupies. will be one for each colspan
7773
					for (j = 0; j < colSegsBelow.length; j++) {
7774
						moreTd = $('<td class="fc-more-cell"/>').attr('rowspan', rowspan);
7775
						segsBelow = colSegsBelow[j];
7776
						moreLink = this.renderMoreLink(
7777
							row,
7778
							seg.leftCol + j,
7779
							[ seg ].concat(segsBelow) // count seg as hidden too
7780
						);
7781
						moreWrap = $('<div/>').append(moreLink);
7782
						moreTd.append(moreWrap);
7783
						segMoreNodes.push(moreTd[0]);
7784
						moreNodes.push(moreTd[0]);
7785
					}
7786
7787
					td.addClass('fc-limited').after($(segMoreNodes)); // hide original <td> and inject replacements
7788
					limitedNodes.push(td[0]);
7789
				}
7790
			}
7791
7792
			emptyCellsUntil(this.colCnt); // finish off the level
7793
			rowStruct.moreEls = $(moreNodes); // for easy undoing later
7794
			rowStruct.limitedEls = $(limitedNodes); // for easy undoing later
7795
		}
7796
	},
7797
7798
7799
	// Reveals all levels and removes all "more"-related elements for a grid's row.
7800
	// `row` is a row number.
7801
	unlimitRow: function(row) {
7802
		var rowStruct = this.rowStructs[row];
7803
7804
		if (rowStruct.moreEls) {
7805
			rowStruct.moreEls.remove();
7806
			rowStruct.moreEls = null;
7807
		}
7808
7809
		if (rowStruct.limitedEls) {
7810
			rowStruct.limitedEls.removeClass('fc-limited');
7811
			rowStruct.limitedEls = null;
7812
		}
7813
	},
7814
7815
7816
	// Renders an <a> element that represents hidden event element for a cell.
7817
	// Responsible for attaching click handler as well.
7818
	renderMoreLink: function(row, col, hiddenSegs) {
7819
		var _this = this;
7820
		var view = this.view;
7821
7822
		return $('<a class="fc-more"/>')
7823
			.text(
7824
				this.getMoreLinkText(hiddenSegs.length)
7825
			)
7826
			.on('click', function(ev) {
7827
				var clickOption = view.opt('eventLimitClick');
7828
				var date = _this.getCellDate(row, col);
7829
				var moreEl = $(this);
7830
				var dayEl = _this.getCellEl(row, col);
7831
				var allSegs = _this.getCellSegs(row, col);
7832
7833
				// rescope the segments to be within the cell's date
7834
				var reslicedAllSegs = _this.resliceDaySegs(allSegs, date);
7835
				var reslicedHiddenSegs = _this.resliceDaySegs(hiddenSegs, date);
7836
7837
				if (typeof clickOption === 'function') {
7838
					// the returned value can be an atomic option
7839
					clickOption = view.publiclyTrigger('eventLimitClick', null, {
7840
						date: date,
7841
						dayEl: dayEl,
7842
						moreEl: moreEl,
7843
						segs: reslicedAllSegs,
7844
						hiddenSegs: reslicedHiddenSegs
7845
					}, ev);
7846
				}
7847
7848
				if (clickOption === 'popover') {
7849
					_this.showSegPopover(row, col, moreEl, reslicedAllSegs);
7850
				}
7851
				else if (typeof clickOption === 'string') { // a view name
7852
					view.calendar.zoomTo(date, clickOption);
7853
				}
7854
			});
7855
	},
7856
7857
7858
	// Reveals the popover that displays all events within a cell
7859
	showSegPopover: function(row, col, moreLink, segs) {
7860
		var _this = this;
7861
		var view = this.view;
7862
		var moreWrap = moreLink.parent(); // the <div> wrapper around the <a>
7863
		var topEl; // the element we want to match the top coordinate of
7864
		var options;
7865
7866
		if (this.rowCnt == 1) {
7867
			topEl = view.el; // will cause the popover to cover any sort of header
7868
		}
7869
		else {
7870
			topEl = this.rowEls.eq(row); // will align with top of row
7871
		}
7872
7873
		options = {
7874
			className: 'fc-more-popover',
7875
			content: this.renderSegPopoverContent(row, col, segs),
7876
			parentEl: this.view.el, // attach to root of view. guarantees outside of scrollbars.
7877
			top: topEl.offset().top,
7878
			autoHide: true, // when the user clicks elsewhere, hide the popover
7879
			viewportConstrain: view.opt('popoverViewportConstrain'),
7880
			hide: function() {
7881
				// kill everything when the popover is hidden
7882
				// notify events to be removed
7883
				if (_this.popoverSegs) {
7884
					var seg;
7885
					for (var i = 0; i < _this.popoverSegs.length; ++i) {
7886
						seg = _this.popoverSegs[i];
7887
						view.publiclyTrigger('eventDestroy', seg.event, seg.event, seg.el);
7888
					}
7889
				}
7890
				_this.segPopover.removeElement();
7891
				_this.segPopover = null;
7892
				_this.popoverSegs = null;
7893
			}
7894
		};
7895
7896
		// Determine horizontal coordinate.
7897
		// We use the moreWrap instead of the <td> to avoid border confusion.
7898
		if (this.isRTL) {
7899
			options.right = moreWrap.offset().left + moreWrap.outerWidth() + 1; // +1 to be over cell border
7900
		}
7901
		else {
7902
			options.left = moreWrap.offset().left - 1; // -1 to be over cell border
7903
		}
7904
7905
		this.segPopover = new Popover(options);
7906
		this.segPopover.show();
7907
7908
		// the popover doesn't live within the grid's container element, and thus won't get the event
7909
		// delegated-handlers for free. attach event-related handlers to the popover.
7910
		this.bindSegHandlersToEl(this.segPopover.el);
7911
	},
7912
7913
7914
	// Builds the inner DOM contents of the segment popover
7915
	renderSegPopoverContent: function(row, col, segs) {
7916
		var view = this.view;
7917
		var isTheme = view.opt('theme');
7918
		var title = this.getCellDate(row, col).format(view.opt('dayPopoverFormat'));
7919
		var content = $(
7920
			'<div class="fc-header ' + view.widgetHeaderClass + '">' +
7921
				'<span class="fc-close ' +
7922
					(isTheme ? 'ui-icon ui-icon-closethick' : 'fc-icon fc-icon-x') +
7923
				'"></span>' +
7924
				'<span class="fc-title">' +
7925
					htmlEscape(title) +
7926
				'</span>' +
7927
				'<div class="fc-clear"/>' +
7928
			'</div>' +
7929
			'<div class="fc-body ' + view.widgetContentClass + '">' +
7930
				'<div class="fc-event-container"></div>' +
7931
			'</div>'
7932
		);
7933
		var segContainer = content.find('.fc-event-container');
7934
		var i;
7935
7936
		// render each seg's `el` and only return the visible segs
7937
		segs = this.renderFgSegEls(segs, true); // disableResizing=true
7938
		this.popoverSegs = segs;
7939
7940
		for (i = 0; i < segs.length; i++) {
7941
7942
			// because segments in the popover are not part of a grid coordinate system, provide a hint to any
7943
			// grids that want to do drag-n-drop about which cell it came from
7944
			this.hitsNeeded();
7945
			segs[i].hit = this.getCellHit(row, col);
7946
			this.hitsNotNeeded();
7947
7948
			segContainer.append(segs[i].el);
7949
		}
7950
7951
		return content;
7952
	},
7953
7954
7955
	// Given the events within an array of segment objects, reslice them to be in a single day
7956
	resliceDaySegs: function(segs, dayDate) {
7957
7958
		// build an array of the original events
7959
		var events = $.map(segs, function(seg) {
7960
			return seg.event;
7961
		});
7962
7963
		var dayStart = dayDate.clone();
7964
		var dayEnd = dayStart.clone().add(1, 'days');
7965
		var dayRange = { start: dayStart, end: dayEnd };
7966
7967
		// slice the events with a custom slicing function
7968
		segs = this.eventsToSegs(
7969
			events,
7970
			function(range) {
7971
				var seg = intersectRanges(range, dayRange); // undefind if no intersection
7972
				return seg ? [ seg ] : []; // must return an array of segments
7973
			}
7974
		);
7975
7976
		// force an order because eventsToSegs doesn't guarantee one
7977
		this.sortEventSegs(segs);
7978
7979
		return segs;
7980
	},
7981
7982
7983
	// Generates the text that should be inside a "more" link, given the number of events it represents
7984
	getMoreLinkText: function(num) {
7985
		var opt = this.view.opt('eventLimitText');
7986
7987
		if (typeof opt === 'function') {
7988
			return opt(num);
7989
		}
7990
		else {
7991
			return '+' + num + ' ' + opt;
7992
		}
7993
	},
7994
7995
7996
	// Returns segments within a given cell.
7997
	// If `startLevel` is specified, returns only events including and below that level. Otherwise returns all segs.
7998
	getCellSegs: function(row, col, startLevel) {
7999
		var segMatrix = this.rowStructs[row].segMatrix;
8000
		var level = startLevel || 0;
8001
		var segs = [];
8002
		var seg;
8003
8004
		while (level < segMatrix.length) {
8005
			seg = segMatrix[level][col];
8006
			if (seg) {
8007
				segs.push(seg);
8008
			}
8009
			level++;
8010
		}
8011
8012
		return segs;
8013
	}
8014
8015
});
8016
8017
;;
8018
8019
/* A component that renders one or more columns of vertical time slots
8020
----------------------------------------------------------------------------------------------------------------------*/
8021
// We mixin DayTable, even though there is only a single row of days
8022
8023
var TimeGrid = FC.TimeGrid = Grid.extend(DayTableMixin, {
8024
8025
	slotDuration: null, // duration of a "slot", a distinct time segment on given day, visualized by lines
8026
	snapDuration: null, // granularity of time for dragging and selecting
8027
	snapsPerSlot: null,
8028
	labelFormat: null, // formatting string for times running along vertical axis
8029
	labelInterval: null, // duration of how often a label should be displayed for a slot
8030
8031
	colEls: null, // cells elements in the day-row background
8032
	slatContainerEl: null, // div that wraps all the slat rows
8033
	slatEls: null, // elements running horizontally across all columns
8034
	nowIndicatorEls: null,
8035
8036
	colCoordCache: null,
8037
	slatCoordCache: null,
8038
8039
8040
	constructor: function() {
8041
		Grid.apply(this, arguments); // call the super-constructor
8042
8043
		this.processOptions();
8044
	},
8045
8046
8047
	// Renders the time grid into `this.el`, which should already be assigned.
8048
	// Relies on the view's colCnt. In the future, this component should probably be self-sufficient.
8049
	renderDates: function() {
8050
		this.el.html(this.renderHtml());
8051
		this.colEls = this.el.find('.fc-day, .fc-disabled-day');
8052
		this.slatContainerEl = this.el.find('.fc-slats');
8053
		this.slatEls = this.slatContainerEl.find('tr');
8054
8055
		this.colCoordCache = new CoordCache({
8056
			els: this.colEls,
8057
			isHorizontal: true
8058
		});
8059
		this.slatCoordCache = new CoordCache({
8060
			els: this.slatEls,
8061
			isVertical: true
8062
		});
8063
8064
		this.renderContentSkeleton();
8065
	},
8066
8067
8068
	// Renders the basic HTML skeleton for the grid
8069
	renderHtml: function() {
8070
		return '' +
8071
			'<div class="fc-bg">' +
8072
				'<table>' +
8073
					this.renderBgTrHtml(0) + // row=0
8074
				'</table>' +
8075
			'</div>' +
8076
			'<div class="fc-slats">' +
8077
				'<table>' +
8078
					this.renderSlatRowHtml() +
8079
				'</table>' +
8080
			'</div>';
8081
	},
8082
8083
8084
	// Generates the HTML for the horizontal "slats" that run width-wise. Has a time axis on a side. Depends on RTL.
8085
	renderSlatRowHtml: function() {
8086
		var view = this.view;
8087
		var isRTL = this.isRTL;
8088
		var html = '';
8089
		var slotTime = moment.duration(+this.view.minTime); // wish there was .clone() for durations
8090
		var slotDate; // will be on the view's first day, but we only care about its time
8091
		var isLabeled;
8092
		var axisHtml;
8093
8094
		// Calculate the time for each slot
8095
		while (slotTime < this.view.maxTime) {
8096
			slotDate = this.start.clone().time(slotTime);
8097
			isLabeled = isInt(divideDurationByDuration(slotTime, this.labelInterval));
8098
8099
			axisHtml =
8100
				'<td class="fc-axis fc-time ' + view.widgetContentClass + '" ' + view.axisStyleAttr() + '>' +
8101
					(isLabeled ?
8102
						'<span>' + // for matchCellWidths
8103
							htmlEscape(slotDate.format(this.labelFormat)) +
8104
						'</span>' :
8105
						''
8106
						) +
8107
				'</td>';
8108
8109
			html +=
8110
				'<tr data-time="' + slotDate.format('HH:mm:ss') + '"' +
8111
					(isLabeled ? '' : ' class="fc-minor"') +
8112
					'>' +
8113
					(!isRTL ? axisHtml : '') +
8114
					'<td class="' + view.widgetContentClass + '"/>' +
8115
					(isRTL ? axisHtml : '') +
8116
				"</tr>";
8117
8118
			slotTime.add(this.slotDuration);
8119
		}
8120
8121
		return html;
8122
	},
8123
8124
8125
	/* Options
8126
	------------------------------------------------------------------------------------------------------------------*/
8127
8128
8129
	// Parses various options into properties of this object
8130
	processOptions: function() {
8131
		var view = this.view;
8132
		var slotDuration = view.opt('slotDuration');
8133
		var snapDuration = view.opt('snapDuration');
8134
		var input;
8135
8136
		slotDuration = moment.duration(slotDuration);
8137
		snapDuration = snapDuration ? moment.duration(snapDuration) : slotDuration;
8138
8139
		this.slotDuration = slotDuration;
8140
		this.snapDuration = snapDuration;
8141
		this.snapsPerSlot = slotDuration / snapDuration; // TODO: ensure an integer multiple?
8142
8143
		this.minResizeDuration = snapDuration; // hack
8144
8145
		// might be an array value (for TimelineView).
8146
		// if so, getting the most granular entry (the last one probably).
8147
		input = view.opt('slotLabelFormat');
8148
		if ($.isArray(input)) {
8149
			input = input[input.length - 1];
8150
		}
8151
8152
		this.labelFormat =
8153
			input ||
8154
			view.opt('smallTimeFormat'); // the computed default
8155
8156
		input = view.opt('slotLabelInterval');
8157
		this.labelInterval = input ?
8158
			moment.duration(input) :
8159
			this.computeLabelInterval(slotDuration);
8160
	},
8161
8162
8163
	// Computes an automatic value for slotLabelInterval
8164
	computeLabelInterval: function(slotDuration) {
8165
		var i;
8166
		var labelInterval;
8167
		var slotsPerLabel;
8168
8169
		// find the smallest stock label interval that results in more than one slots-per-label
8170
		for (i = AGENDA_STOCK_SUB_DURATIONS.length - 1; i >= 0; i--) {
8171
			labelInterval = moment.duration(AGENDA_STOCK_SUB_DURATIONS[i]);
8172
			slotsPerLabel = divideDurationByDuration(labelInterval, slotDuration);
8173
			if (isInt(slotsPerLabel) && slotsPerLabel > 1) {
8174
				return labelInterval;
8175
			}
8176
		}
8177
8178
		return moment.duration(slotDuration); // fall back. clone
8179
	},
8180
8181
8182
	// Computes a default event time formatting string if `timeFormat` is not explicitly defined
8183
	computeEventTimeFormat: function() {
8184
		return this.view.opt('noMeridiemTimeFormat'); // like "6:30" (no AM/PM)
8185
	},
8186
8187
8188
	// Computes a default `displayEventEnd` value if one is not expliclty defined
8189
	computeDisplayEventEnd: function() {
8190
		return true;
8191
	},
8192
8193
8194
	/* Hit System
8195
	------------------------------------------------------------------------------------------------------------------*/
8196
8197
8198
	prepareHits: function() {
8199
		this.colCoordCache.build();
8200
		this.slatCoordCache.build();
8201
	},
8202
8203
8204
	releaseHits: function() {
8205
		this.colCoordCache.clear();
8206
		// NOTE: don't clear slatCoordCache because we rely on it for computeTimeTop
8207
	},
8208
8209
8210
	queryHit: function(leftOffset, topOffset) {
8211
		var snapsPerSlot = this.snapsPerSlot;
8212
		var colCoordCache = this.colCoordCache;
8213
		var slatCoordCache = this.slatCoordCache;
8214
8215
		if (colCoordCache.isLeftInBounds(leftOffset) && slatCoordCache.isTopInBounds(topOffset)) {
8216
			var colIndex = colCoordCache.getHorizontalIndex(leftOffset);
8217
			var slatIndex = slatCoordCache.getVerticalIndex(topOffset);
8218
8219
			if (colIndex != null && slatIndex != null) {
8220
				var slatTop = slatCoordCache.getTopOffset(slatIndex);
8221
				var slatHeight = slatCoordCache.getHeight(slatIndex);
8222
				var partial = (topOffset - slatTop) / slatHeight; // floating point number between 0 and 1
8223
				var localSnapIndex = Math.floor(partial * snapsPerSlot); // the snap # relative to start of slat
8224
				var snapIndex = slatIndex * snapsPerSlot + localSnapIndex;
8225
				var snapTop = slatTop + (localSnapIndex / snapsPerSlot) * slatHeight;
8226
				var snapBottom = slatTop + ((localSnapIndex + 1) / snapsPerSlot) * slatHeight;
8227
8228
				return {
8229
					col: colIndex,
8230
					snap: snapIndex,
8231
					component: this, // needed unfortunately :(
8232
					left: colCoordCache.getLeftOffset(colIndex),
8233
					right: colCoordCache.getRightOffset(colIndex),
8234
					top: snapTop,
8235
					bottom: snapBottom
8236
				};
8237
			}
8238
		}
8239
	},
8240
8241
8242
	getHitSpan: function(hit) {
8243
		var start = this.getCellDate(0, hit.col); // row=0
8244
		var time = this.computeSnapTime(hit.snap); // pass in the snap-index
8245
		var end;
8246
8247
		start.time(time);
8248
		end = start.clone().add(this.snapDuration);
8249
8250
		return { start: start, end: end };
8251
	},
8252
8253
8254
	getHitEl: function(hit) {
8255
		return this.colEls.eq(hit.col);
8256
	},
8257
8258
8259
	/* Dates
8260
	------------------------------------------------------------------------------------------------------------------*/
8261
8262
8263
	rangeUpdated: function() {
8264
		this.updateDayTable();
8265
	},
8266
8267
8268
	// Given a row number of the grid, representing a "snap", returns a time (Duration) from its start-of-day
8269
	computeSnapTime: function(snapIndex) {
8270
		return moment.duration(this.view.minTime + this.snapDuration * snapIndex);
8271
	},
8272
8273
8274
	// Slices up the given span (unzoned start/end with other misc data) into an array of segments
8275
	spanToSegs: function(span) {
8276
		var segs = this.sliceRangeByTimes(span);
8277
		var i;
8278
8279
		for (i = 0; i < segs.length; i++) {
8280
			if (this.isRTL) {
8281
				segs[i].col = this.daysPerRow - 1 - segs[i].dayIndex;
8282
			}
8283
			else {
8284
				segs[i].col = segs[i].dayIndex;
8285
			}
8286
		}
8287
8288
		return segs;
8289
	},
8290
8291
8292
	sliceRangeByTimes: function(range) {
8293
		var segs = [];
8294
		var seg;
8295
		var dayIndex;
8296
		var dayDate;
8297
		var dayRange;
8298
8299
		for (dayIndex = 0; dayIndex < this.daysPerRow; dayIndex++) {
8300
			dayDate = this.dayDates[dayIndex].clone().time(0); // TODO: better API for this?
8301
			dayRange = {
8302
				start: dayDate.clone().add(this.view.minTime), // don't use .time() because it sux with negatives
8303
				end: dayDate.clone().add(this.view.maxTime)
8304
			};
8305
			seg = intersectRanges(range, dayRange); // both will be ambig timezone
8306
			if (seg) {
8307
				seg.dayIndex = dayIndex;
8308
				segs.push(seg);
8309
			}
8310
		}
8311
8312
		return segs;
8313
	},
8314
8315
8316
	/* Coordinates
8317
	------------------------------------------------------------------------------------------------------------------*/
8318
8319
8320
	updateSize: function(isResize) { // NOT a standard Grid method
8321
		this.slatCoordCache.build();
8322
8323
		if (isResize) {
8324
			this.updateSegVerticals(
8325
				[].concat(this.fgSegs || [], this.bgSegs || [], this.businessSegs || [])
8326
			);
8327
		}
8328
	},
8329
8330
8331
	getTotalSlatHeight: function() {
8332
		return this.slatContainerEl.outerHeight();
8333
	},
8334
8335
8336
	// Computes the top coordinate, relative to the bounds of the grid, of the given date.
8337
	// A `startOfDayDate` must be given for avoiding ambiguity over how to treat midnight.
8338
	computeDateTop: function(date, startOfDayDate) {
8339
		return this.computeTimeTop(
8340
			moment.duration(
8341
				date - startOfDayDate.clone().stripTime()
8342
			)
8343
		);
8344
	},
8345
8346
8347
	// Computes the top coordinate, relative to the bounds of the grid, of the given time (a Duration).
8348
	computeTimeTop: function(time) {
8349
		var len = this.slatEls.length;
8350
		var slatCoverage = (time - this.view.minTime) / this.slotDuration; // floating-point value of # of slots covered
8351
		var slatIndex;
8352
		var slatRemainder;
8353
8354
		// compute a floating-point number for how many slats should be progressed through.
8355
		// from 0 to number of slats (inclusive)
8356
		// constrained because minTime/maxTime might be customized.
8357
		slatCoverage = Math.max(0, slatCoverage);
8358
		slatCoverage = Math.min(len, slatCoverage);
8359
8360
		// an integer index of the furthest whole slat
8361
		// from 0 to number slats (*exclusive*, so len-1)
8362
		slatIndex = Math.floor(slatCoverage);
8363
		slatIndex = Math.min(slatIndex, len - 1);
8364
8365
		// how much further through the slatIndex slat (from 0.0-1.0) must be covered in addition.
8366
		// could be 1.0 if slatCoverage is covering *all* the slots
8367
		slatRemainder = slatCoverage - slatIndex;
8368
8369
		return this.slatCoordCache.getTopPosition(slatIndex) +
8370
			this.slatCoordCache.getHeight(slatIndex) * slatRemainder;
8371
	},
8372
8373
8374
8375
	/* Event Drag Visualization
8376
	------------------------------------------------------------------------------------------------------------------*/
8377
8378
8379
	// Renders a visual indication of an event being dragged over the specified date(s).
8380
	// A returned value of `true` signals that a mock "helper" event has been rendered.
8381
	renderDrag: function(eventLocation, seg) {
8382
		var eventSpans;
8383
		var i;
8384
8385
		if (seg) { // if there is event information for this drag, render a helper event
8386
8387
			// returns mock event elements
8388
			// signal that a helper has been rendered
8389
			return this.renderEventLocationHelper(eventLocation, seg);
8390
		}
8391
		else { // otherwise, just render a highlight
8392
			eventSpans = this.eventToSpans(eventLocation);
8393
8394
			for (i = 0; i < eventSpans.length; i++) {
8395
				this.renderHighlight(eventSpans[i]);
8396
			}
8397
		}
8398
	},
8399
8400
8401
	// Unrenders any visual indication of an event being dragged
8402
	unrenderDrag: function() {
8403
		this.unrenderHelper();
8404
		this.unrenderHighlight();
8405
	},
8406
8407
8408
	/* Event Resize Visualization
8409
	------------------------------------------------------------------------------------------------------------------*/
8410
8411
8412
	// Renders a visual indication of an event being resized
8413
	renderEventResize: function(eventLocation, seg) {
8414
		return this.renderEventLocationHelper(eventLocation, seg); // returns mock event elements
8415
	},
8416
8417
8418
	// Unrenders any visual indication of an event being resized
8419
	unrenderEventResize: function() {
8420
		this.unrenderHelper();
8421
	},
8422
8423
8424
	/* Event Helper
8425
	------------------------------------------------------------------------------------------------------------------*/
8426
8427
8428
	// Renders a mock "helper" event. `sourceSeg` is the original segment object and might be null (an external drag)
8429
	renderHelper: function(event, sourceSeg) {
8430
		return this.renderHelperSegs(this.eventToSegs(event), sourceSeg); // returns mock event elements
8431
	},
8432
8433
8434
	// Unrenders any mock helper event
8435
	unrenderHelper: function() {
8436
		this.unrenderHelperSegs();
8437
	},
8438
8439
8440
	/* Business Hours
8441
	------------------------------------------------------------------------------------------------------------------*/
8442
8443
8444
	renderBusinessHours: function() {
8445
		this.renderBusinessSegs(
8446
			this.buildBusinessHourSegs()
8447
		);
8448
	},
8449
8450
8451
	unrenderBusinessHours: function() {
8452
		this.unrenderBusinessSegs();
8453
	},
8454
8455
8456
	/* Now Indicator
8457
	------------------------------------------------------------------------------------------------------------------*/
8458
8459
8460
	getNowIndicatorUnit: function() {
8461
		return 'minute'; // will refresh on the minute
8462
	},
8463
8464
8465
	renderNowIndicator: function(date) {
8466
		// seg system might be overkill, but it handles scenario where line needs to be rendered
8467
		//  more than once because of columns with the same date (resources columns for example)
8468
		var segs = this.spanToSegs({ start: date, end: date });
8469
		var top = this.computeDateTop(date, date);
8470
		var nodes = [];
8471
		var i;
8472
8473
		// render lines within the columns
8474
		for (i = 0; i < segs.length; i++) {
8475
			nodes.push($('<div class="fc-now-indicator fc-now-indicator-line"></div>')
8476
				.css('top', top)
8477
				.appendTo(this.colContainerEls.eq(segs[i].col))[0]);
8478
		}
8479
8480
		// render an arrow over the axis
8481
		if (segs.length > 0) { // is the current time in view?
8482
			nodes.push($('<div class="fc-now-indicator fc-now-indicator-arrow"></div>')
8483
				.css('top', top)
8484
				.appendTo(this.el.find('.fc-content-skeleton'))[0]);
8485
		}
8486
8487
		this.nowIndicatorEls = $(nodes);
8488
	},
8489
8490
8491
	unrenderNowIndicator: function() {
8492
		if (this.nowIndicatorEls) {
8493
			this.nowIndicatorEls.remove();
8494
			this.nowIndicatorEls = null;
8495
		}
8496
	},
8497
8498
8499
	/* Selection
8500
	------------------------------------------------------------------------------------------------------------------*/
8501
8502
8503
	// Renders a visual indication of a selection. Overrides the default, which was to simply render a highlight.
8504
	renderSelection: function(span) {
8505
		if (this.view.opt('selectHelper')) { // this setting signals that a mock helper event should be rendered
8506
8507
			// normally acceps an eventLocation, span has a start/end, which is good enough
8508
			this.renderEventLocationHelper(span);
8509
		}
8510
		else {
8511
			this.renderHighlight(span);
8512
		}
8513
	},
8514
8515
8516
	// Unrenders any visual indication of a selection
8517
	unrenderSelection: function() {
8518
		this.unrenderHelper();
8519
		this.unrenderHighlight();
8520
	},
8521
8522
8523
	/* Highlight
8524
	------------------------------------------------------------------------------------------------------------------*/
8525
8526
8527
	renderHighlight: function(span) {
8528
		this.renderHighlightSegs(this.spanToSegs(span));
8529
	},
8530
8531
8532
	unrenderHighlight: function() {
8533
		this.unrenderHighlightSegs();
8534
	}
8535
8536
});
8537
8538
;;
8539
8540
/* Methods for rendering SEGMENTS, pieces of content that live on the view
8541
 ( this file is no longer just for events )
8542
----------------------------------------------------------------------------------------------------------------------*/
8543
8544
TimeGrid.mixin({
8545
8546
	colContainerEls: null, // containers for each column
8547
8548
	// inner-containers for each column where different types of segs live
8549
	fgContainerEls: null,
8550
	bgContainerEls: null,
8551
	helperContainerEls: null,
8552
	highlightContainerEls: null,
8553
	businessContainerEls: null,
8554
8555
	// arrays of different types of displayed segments
8556
	fgSegs: null,
8557
	bgSegs: null,
8558
	helperSegs: null,
8559
	highlightSegs: null,
8560
	businessSegs: null,
8561
8562
8563
	// Renders the DOM that the view's content will live in
8564
	renderContentSkeleton: function() {
8565
		var cellHtml = '';
8566
		var i;
8567
		var skeletonEl;
8568
8569
		for (i = 0; i < this.colCnt; i++) {
8570
			cellHtml +=
8571
				'<td>' +
8572
					'<div class="fc-content-col">' +
8573
						'<div class="fc-event-container fc-helper-container"></div>' +
8574
						'<div class="fc-event-container"></div>' +
8575
						'<div class="fc-highlight-container"></div>' +
8576
						'<div class="fc-bgevent-container"></div>' +
8577
						'<div class="fc-business-container"></div>' +
8578
					'</div>' +
8579
				'</td>';
8580
		}
8581
8582
		skeletonEl = $(
8583
			'<div class="fc-content-skeleton">' +
8584
				'<table>' +
8585
					'<tr>' + cellHtml + '</tr>' +
8586
				'</table>' +
8587
			'</div>'
8588
		);
8589
8590
		this.colContainerEls = skeletonEl.find('.fc-content-col');
8591
		this.helperContainerEls = skeletonEl.find('.fc-helper-container');
8592
		this.fgContainerEls = skeletonEl.find('.fc-event-container:not(.fc-helper-container)');
8593
		this.bgContainerEls = skeletonEl.find('.fc-bgevent-container');
8594
		this.highlightContainerEls = skeletonEl.find('.fc-highlight-container');
8595
		this.businessContainerEls = skeletonEl.find('.fc-business-container');
8596
8597
		this.bookendCells(skeletonEl.find('tr')); // TODO: do this on string level
8598
		this.el.append(skeletonEl);
8599
	},
8600
8601
8602
	/* Foreground Events
8603
	------------------------------------------------------------------------------------------------------------------*/
8604
8605
8606
	renderFgSegs: function(segs) {
8607
		segs = this.renderFgSegsIntoContainers(segs, this.fgContainerEls);
8608
		this.fgSegs = segs;
8609
		return segs; // needed for Grid::renderEvents
8610
	},
8611
8612
8613
	unrenderFgSegs: function() {
8614
		this.unrenderNamedSegs('fgSegs');
8615
	},
8616
8617
8618
	/* Foreground Helper Events
8619
	------------------------------------------------------------------------------------------------------------------*/
8620
8621
8622
	renderHelperSegs: function(segs, sourceSeg) {
8623
		var helperEls = [];
8624
		var i, seg;
8625
		var sourceEl;
8626
8627
		segs = this.renderFgSegsIntoContainers(segs, this.helperContainerEls);
8628
8629
		// Try to make the segment that is in the same row as sourceSeg look the same
8630
		for (i = 0; i < segs.length; i++) {
8631
			seg = segs[i];
8632
			if (sourceSeg && sourceSeg.col === seg.col) {
8633
				sourceEl = sourceSeg.el;
8634
				seg.el.css({
8635
					left: sourceEl.css('left'),
8636
					right: sourceEl.css('right'),
8637
					'margin-left': sourceEl.css('margin-left'),
8638
					'margin-right': sourceEl.css('margin-right')
8639
				});
8640
			}
8641
			helperEls.push(seg.el[0]);
8642
		}
8643
8644
		this.helperSegs = segs;
8645
8646
		return $(helperEls); // must return rendered helpers
8647
	},
8648
8649
8650
	unrenderHelperSegs: function() {
8651
		this.unrenderNamedSegs('helperSegs');
8652
	},
8653
8654
8655
	/* Background Events
8656
	------------------------------------------------------------------------------------------------------------------*/
8657
8658
8659
	renderBgSegs: function(segs) {
8660
		segs = this.renderFillSegEls('bgEvent', segs); // TODO: old fill system
8661
		this.updateSegVerticals(segs);
8662
		this.attachSegsByCol(this.groupSegsByCol(segs), this.bgContainerEls);
8663
		this.bgSegs = segs;
8664
		return segs; // needed for Grid::renderEvents
8665
	},
8666
8667
8668
	unrenderBgSegs: function() {
8669
		this.unrenderNamedSegs('bgSegs');
8670
	},
8671
8672
8673
	/* Highlight
8674
	------------------------------------------------------------------------------------------------------------------*/
8675
8676
8677
	renderHighlightSegs: function(segs) {
8678
		segs = this.renderFillSegEls('highlight', segs); // TODO: old fill system
8679
		this.updateSegVerticals(segs);
8680
		this.attachSegsByCol(this.groupSegsByCol(segs), this.highlightContainerEls);
8681
		this.highlightSegs = segs;
8682
	},
8683
8684
8685
	unrenderHighlightSegs: function() {
8686
		this.unrenderNamedSegs('highlightSegs');
8687
	},
8688
8689
8690
	/* Business Hours
8691
	------------------------------------------------------------------------------------------------------------------*/
8692
8693
8694
	renderBusinessSegs: function(segs) {
8695
		segs = this.renderFillSegEls('businessHours', segs); // TODO: old fill system
8696
		this.updateSegVerticals(segs);
8697
		this.attachSegsByCol(this.groupSegsByCol(segs), this.businessContainerEls);
8698
		this.businessSegs = segs;
8699
	},
8700
8701
8702
	unrenderBusinessSegs: function() {
8703
		this.unrenderNamedSegs('businessSegs');
8704
	},
8705
8706
8707
	/* Seg Rendering Utils
8708
	------------------------------------------------------------------------------------------------------------------*/
8709
8710
8711
	// Given a flat array of segments, return an array of sub-arrays, grouped by each segment's col
8712
	groupSegsByCol: function(segs) {
8713
		var segsByCol = [];
8714
		var i;
8715
8716
		for (i = 0; i < this.colCnt; i++) {
8717
			segsByCol.push([]);
8718
		}
8719
8720
		for (i = 0; i < segs.length; i++) {
8721
			segsByCol[segs[i].col].push(segs[i]);
8722
		}
8723
8724
		return segsByCol;
8725
	},
8726
8727
8728
	// Given segments grouped by column, insert the segments' elements into a parallel array of container
8729
	// elements, each living within a column.
8730
	attachSegsByCol: function(segsByCol, containerEls) {
8731
		var col;
8732
		var segs;
8733
		var i;
8734
8735
		for (col = 0; col < this.colCnt; col++) { // iterate each column grouping
8736
			segs = segsByCol[col];
8737
8738
			for (i = 0; i < segs.length; i++) {
8739
				containerEls.eq(col).append(segs[i].el);
8740
			}
8741
		}
8742
	},
8743
8744
8745
	// Given the name of a property of `this` object, assumed to be an array of segments,
8746
	// loops through each segment and removes from DOM. Will null-out the property afterwards.
8747
	unrenderNamedSegs: function(propName) {
8748
		var segs = this[propName];
8749
		var i;
8750
8751
		if (segs) {
8752
			for (i = 0; i < segs.length; i++) {
8753
				segs[i].el.remove();
8754
			}
8755
			this[propName] = null;
8756
		}
8757
	},
8758
8759
8760
8761
	/* Foreground Event Rendering Utils
8762
	------------------------------------------------------------------------------------------------------------------*/
8763
8764
8765
	// Given an array of foreground segments, render a DOM element for each, computes position,
8766
	// and attaches to the column inner-container elements.
8767
	renderFgSegsIntoContainers: function(segs, containerEls) {
8768
		var segsByCol;
8769
		var col;
8770
8771
		segs = this.renderFgSegEls(segs); // will call fgSegHtml
8772
		segsByCol = this.groupSegsByCol(segs);
8773
8774
		for (col = 0; col < this.colCnt; col++) {
8775
			this.updateFgSegCoords(segsByCol[col]);
8776
		}
8777
8778
		this.attachSegsByCol(segsByCol, containerEls);
8779
8780
		return segs;
8781
	},
8782
8783
8784
	// Renders the HTML for a single event segment's default rendering
8785
	fgSegHtml: function(seg, disableResizing) {
8786
		var view = this.view;
8787
		var event = seg.event;
8788
		var isDraggable = view.isEventDraggable(event);
8789
		var isResizableFromStart = !disableResizing && seg.isStart && view.isEventResizableFromStart(event);
8790
		var isResizableFromEnd = !disableResizing && seg.isEnd && view.isEventResizableFromEnd(event);
8791
		var classes = this.getSegClasses(seg, isDraggable, isResizableFromStart || isResizableFromEnd);
8792
		var skinCss = cssToStr(this.getSegSkinCss(seg));
8793
		var timeText;
8794
		var fullTimeText; // more verbose time text. for the print stylesheet
8795
		var startTimeText; // just the start time text
8796
8797
		classes.unshift('fc-time-grid-event', 'fc-v-event');
8798
8799
		if (view.isMultiDayEvent(event)) { // if the event appears to span more than one day...
8800
			// Don't display time text on segments that run entirely through a day.
8801
			// That would appear as midnight-midnight and would look dumb.
8802
			// Otherwise, display the time text for the *segment's* times (like 6pm-midnight or midnight-10am)
8803
			if (seg.isStart || seg.isEnd) {
8804
				timeText = this.getEventTimeText(seg);
8805
				fullTimeText = this.getEventTimeText(seg, 'LT');
8806
				startTimeText = this.getEventTimeText(seg, null, false); // displayEnd=false
8807
			}
8808
		} else {
8809
			// Display the normal time text for the *event's* times
8810
			timeText = this.getEventTimeText(event);
8811
			fullTimeText = this.getEventTimeText(event, 'LT');
8812
			startTimeText = this.getEventTimeText(event, null, false); // displayEnd=false
8813
		}
8814
8815
		return '<a class="' + classes.join(' ') + '"' +
8816
			(event.url ?
8817
				' href="' + htmlEscape(event.url) + '"' :
8818
				''
8819
				) +
8820
			(skinCss ?
8821
				' style="' + skinCss + '"' :
8822
				''
8823
				) +
8824
			'>' +
8825
				'<div class="fc-content">' +
8826
					(timeText ?
8827
						'<div class="fc-time"' +
8828
						' data-start="' + htmlEscape(startTimeText) + '"' +
8829
						' data-full="' + htmlEscape(fullTimeText) + '"' +
8830
						'>' +
8831
							'<span>' + htmlEscape(timeText) + '</span>' +
8832
						'</div>' :
8833
						''
8834
						) +
8835
					(event.title ?
8836
						'<div class="fc-title">' +
8837
							htmlEscape(event.title) +
8838
						'</div>' :
8839
						''
8840
						) +
8841
				'</div>' +
8842
				'<div class="fc-bg"/>' +
8843
				/* TODO: write CSS for this
8844
				(isResizableFromStart ?
8845
					'<div class="fc-resizer fc-start-resizer" />' :
8846
					''
8847
					) +
8848
				*/
8849
				(isResizableFromEnd ?
8850
					'<div class="fc-resizer fc-end-resizer" />' :
8851
					''
8852
					) +
8853
			'</a>';
8854
	},
8855
8856
8857
	/* Seg Position Utils
8858
	------------------------------------------------------------------------------------------------------------------*/
8859
8860
8861
	// Refreshes the CSS top/bottom coordinates for each segment element.
8862
	// Works when called after initial render, after a window resize/zoom for example.
8863
	updateSegVerticals: function(segs) {
8864
		this.computeSegVerticals(segs);
8865
		this.assignSegVerticals(segs);
8866
	},
8867
8868
8869
	// For each segment in an array, computes and assigns its top and bottom properties
8870
	computeSegVerticals: function(segs) {
8871
		var i, seg;
8872
		var dayDate;
8873
8874
		for (i = 0; i < segs.length; i++) {
8875
			seg = segs[i];
8876
			dayDate = this.dayDates[seg.dayIndex];
8877
8878
			seg.top = this.computeDateTop(seg.start, dayDate);
8879
			seg.bottom = this.computeDateTop(seg.end, dayDate);
8880
		}
8881
	},
8882
8883
8884
	// Given segments that already have their top/bottom properties computed, applies those values to
8885
	// the segments' elements.
8886
	assignSegVerticals: function(segs) {
8887
		var i, seg;
8888
8889
		for (i = 0; i < segs.length; i++) {
8890
			seg = segs[i];
8891
			seg.el.css(this.generateSegVerticalCss(seg));
8892
		}
8893
	},
8894
8895
8896
	// Generates an object with CSS properties for the top/bottom coordinates of a segment element
8897
	generateSegVerticalCss: function(seg) {
8898
		return {
8899
			top: seg.top,
8900
			bottom: -seg.bottom // flipped because needs to be space beyond bottom edge of event container
8901
		};
8902
	},
8903
8904
8905
	/* Foreground Event Positioning Utils
8906
	------------------------------------------------------------------------------------------------------------------*/
8907
8908
8909
	// Given segments that are assumed to all live in the *same column*,
8910
	// compute their verical/horizontal coordinates and assign to their elements.
8911
	updateFgSegCoords: function(segs) {
8912
		this.computeSegVerticals(segs); // horizontals relies on this
8913
		this.computeFgSegHorizontals(segs); // compute horizontal coordinates, z-index's, and reorder the array
8914
		this.assignSegVerticals(segs);
8915
		this.assignFgSegHorizontals(segs);
8916
	},
8917
8918
8919
	// Given an array of segments that are all in the same column, sets the backwardCoord and forwardCoord on each.
8920
	// NOTE: Also reorders the given array by date!
8921
	computeFgSegHorizontals: function(segs) {
8922
		var levels;
8923
		var level0;
8924
		var i;
8925
8926
		this.sortEventSegs(segs); // order by certain criteria
8927
		levels = buildSlotSegLevels(segs);
8928
		computeForwardSlotSegs(levels);
8929
8930
		if ((level0 = levels[0])) {
8931
8932
			for (i = 0; i < level0.length; i++) {
8933
				computeSlotSegPressures(level0[i]);
8934
			}
8935
8936
			for (i = 0; i < level0.length; i++) {
8937
				this.computeFgSegForwardBack(level0[i], 0, 0);
8938
			}
8939
		}
8940
	},
8941
8942
8943
	// Calculate seg.forwardCoord and seg.backwardCoord for the segment, where both values range
8944
	// from 0 to 1. If the calendar is left-to-right, the seg.backwardCoord maps to "left" and
8945
	// seg.forwardCoord maps to "right" (via percentage). Vice-versa if the calendar is right-to-left.
8946
	//
8947
	// The segment might be part of a "series", which means consecutive segments with the same pressure
8948
	// who's width is unknown until an edge has been hit. `seriesBackwardPressure` is the number of
8949
	// segments behind this one in the current series, and `seriesBackwardCoord` is the starting
8950
	// coordinate of the first segment in the series.
8951
	computeFgSegForwardBack: function(seg, seriesBackwardPressure, seriesBackwardCoord) {
8952
		var forwardSegs = seg.forwardSegs;
8953
		var i;
8954
8955
		if (seg.forwardCoord === undefined) { // not already computed
8956
8957
			if (!forwardSegs.length) {
8958
8959
				// if there are no forward segments, this segment should butt up against the edge
8960
				seg.forwardCoord = 1;
8961
			}
8962
			else {
8963
8964
				// sort highest pressure first
8965
				this.sortForwardSegs(forwardSegs);
8966
8967
				// this segment's forwardCoord will be calculated from the backwardCoord of the
8968
				// highest-pressure forward segment.
8969
				this.computeFgSegForwardBack(forwardSegs[0], seriesBackwardPressure + 1, seriesBackwardCoord);
8970
				seg.forwardCoord = forwardSegs[0].backwardCoord;
8971
			}
8972
8973
			// calculate the backwardCoord from the forwardCoord. consider the series
8974
			seg.backwardCoord = seg.forwardCoord -
8975
				(seg.forwardCoord - seriesBackwardCoord) / // available width for series
8976
				(seriesBackwardPressure + 1); // # of segments in the series
8977
8978
			// use this segment's coordinates to computed the coordinates of the less-pressurized
8979
			// forward segments
8980
			for (i=0; i<forwardSegs.length; i++) {
8981
				this.computeFgSegForwardBack(forwardSegs[i], 0, seg.forwardCoord);
8982
			}
8983
		}
8984
	},
8985
8986
8987
	sortForwardSegs: function(forwardSegs) {
8988
		forwardSegs.sort(proxy(this, 'compareForwardSegs'));
8989
	},
8990
8991
8992
	// A cmp function for determining which forward segment to rely on more when computing coordinates.
8993
	compareForwardSegs: function(seg1, seg2) {
8994
		// put higher-pressure first
8995
		return seg2.forwardPressure - seg1.forwardPressure ||
8996
			// put segments that are closer to initial edge first (and favor ones with no coords yet)
8997
			(seg1.backwardCoord || 0) - (seg2.backwardCoord || 0) ||
8998
			// do normal sorting...
8999
			this.compareEventSegs(seg1, seg2);
9000
	},
9001
9002
9003
	// Given foreground event segments that have already had their position coordinates computed,
9004
	// assigns position-related CSS values to their elements.
9005
	assignFgSegHorizontals: function(segs) {
9006
		var i, seg;
9007
9008
		for (i = 0; i < segs.length; i++) {
9009
			seg = segs[i];
9010
			seg.el.css(this.generateFgSegHorizontalCss(seg));
9011
9012
			// if the height is short, add a className for alternate styling
9013
			if (seg.bottom - seg.top < 30) {
9014
				seg.el.addClass('fc-short');
9015
			}
9016
		}
9017
	},
9018
9019
9020
	// Generates an object with CSS properties/values that should be applied to an event segment element.
9021
	// Contains important positioning-related properties that should be applied to any event element, customized or not.
9022
	generateFgSegHorizontalCss: function(seg) {
9023
		var shouldOverlap = this.view.opt('slotEventOverlap');
9024
		var backwardCoord = seg.backwardCoord; // the left side if LTR. the right side if RTL. floating-point
9025
		var forwardCoord = seg.forwardCoord; // the right side if LTR. the left side if RTL. floating-point
9026
		var props = this.generateSegVerticalCss(seg); // get top/bottom first
9027
		var left; // amount of space from left edge, a fraction of the total width
9028
		var right; // amount of space from right edge, a fraction of the total width
9029
9030
		if (shouldOverlap) {
9031
			// double the width, but don't go beyond the maximum forward coordinate (1.0)
9032
			forwardCoord = Math.min(1, backwardCoord + (forwardCoord - backwardCoord) * 2);
9033
		}
9034
9035
		if (this.isRTL) {
9036
			left = 1 - forwardCoord;
9037
			right = backwardCoord;
9038
		}
9039
		else {
9040
			left = backwardCoord;
9041
			right = 1 - forwardCoord;
9042
		}
9043
9044
		props.zIndex = seg.level + 1; // convert from 0-base to 1-based
9045
		props.left = left * 100 + '%';
9046
		props.right = right * 100 + '%';
9047
9048
		if (shouldOverlap && seg.forwardPressure) {
9049
			// add padding to the edge so that forward stacked events don't cover the resizer's icon
9050
			props[this.isRTL ? 'marginLeft' : 'marginRight'] = 10 * 2; // 10 is a guesstimate of the icon's width
9051
		}
9052
9053
		return props;
9054
	}
9055
9056
});
9057
9058
9059
// Builds an array of segments "levels". The first level will be the leftmost tier of segments if the calendar is
9060
// left-to-right, or the rightmost if the calendar is right-to-left. Assumes the segments are already ordered by date.
9061
function buildSlotSegLevels(segs) {
9062
	var levels = [];
9063
	var i, seg;
9064
	var j;
9065
9066
	for (i=0; i<segs.length; i++) {
9067
		seg = segs[i];
9068
9069
		// go through all the levels and stop on the first level where there are no collisions
9070
		for (j=0; j<levels.length; j++) {
9071
			if (!computeSlotSegCollisions(seg, levels[j]).length) {
9072
				break;
9073
			}
9074
		}
9075
9076
		seg.level = j;
9077
9078
		(levels[j] || (levels[j] = [])).push(seg);
9079
	}
9080
9081
	return levels;
9082
}
9083
9084
9085
// For every segment, figure out the other segments that are in subsequent
9086
// levels that also occupy the same vertical space. Accumulate in seg.forwardSegs
9087
function computeForwardSlotSegs(levels) {
9088
	var i, level;
9089
	var j, seg;
9090
	var k;
9091
9092
	for (i=0; i<levels.length; i++) {
9093
		level = levels[i];
9094
9095
		for (j=0; j<level.length; j++) {
9096
			seg = level[j];
9097
9098
			seg.forwardSegs = [];
9099
			for (k=i+1; k<levels.length; k++) {
9100
				computeSlotSegCollisions(seg, levels[k], seg.forwardSegs);
9101
			}
9102
		}
9103
	}
9104
}
9105
9106
9107
// Figure out which path forward (via seg.forwardSegs) results in the longest path until
9108
// the furthest edge is reached. The number of segments in this path will be seg.forwardPressure
9109
function computeSlotSegPressures(seg) {
9110
	var forwardSegs = seg.forwardSegs;
9111
	var forwardPressure = 0;
9112
	var i, forwardSeg;
9113
9114
	if (seg.forwardPressure === undefined) { // not already computed
9115
9116
		for (i=0; i<forwardSegs.length; i++) {
9117
			forwardSeg = forwardSegs[i];
9118
9119
			// figure out the child's maximum forward path
9120
			computeSlotSegPressures(forwardSeg);
9121
9122
			// either use the existing maximum, or use the child's forward pressure
9123
			// plus one (for the forwardSeg itself)
9124
			forwardPressure = Math.max(
9125
				forwardPressure,
9126
				1 + forwardSeg.forwardPressure
9127
			);
9128
		}
9129
9130
		seg.forwardPressure = forwardPressure;
9131
	}
9132
}
9133
9134
9135
// Find all the segments in `otherSegs` that vertically collide with `seg`.
9136
// Append into an optionally-supplied `results` array and return.
9137
function computeSlotSegCollisions(seg, otherSegs, results) {
9138
	results = results || [];
9139
9140
	for (var i=0; i<otherSegs.length; i++) {
9141
		if (isSlotSegCollision(seg, otherSegs[i])) {
9142
			results.push(otherSegs[i]);
9143
		}
9144
	}
9145
9146
	return results;
9147
}
9148
9149
9150
// Do these segments occupy the same vertical space?
9151
function isSlotSegCollision(seg1, seg2) {
9152
	return seg1.bottom > seg2.top && seg1.top < seg2.bottom;
9153
}
9154
9155
;;
9156
9157
/* An abstract class from which other views inherit from
9158
----------------------------------------------------------------------------------------------------------------------*/
9159
9160
var View = FC.View = Model.extend({
9161
9162
	type: null, // subclass' view name (string)
9163
	name: null, // deprecated. use `type` instead
9164
	title: null, // the text that will be displayed in the header's title
9165
9166
	calendar: null, // owner Calendar object
9167
	viewSpec: null,
9168
	options: null, // hash containing all options. already merged with view-specific-options
9169
	el: null, // the view's containing element. set by Calendar
9170
9171
	renderQueue: null,
9172
	batchRenderDepth: 0,
9173
	isDatesRendered: false,
9174
	isEventsRendered: false,
9175
	isBaseRendered: false, // related to viewRender/viewDestroy triggers
9176
9177
	queuedScroll: null,
9178
9179
	isRTL: false,
9180
	isSelected: false, // boolean whether a range of time is user-selected or not
9181
	selectedEvent: null,
9182
9183
	eventOrderSpecs: null, // criteria for ordering events when they have same date/time
9184
9185
	// classNames styled by jqui themes
9186
	widgetHeaderClass: null,
9187
	widgetContentClass: null,
9188
	highlightStateClass: null,
9189
9190
	// for date utils, computed from options
9191
	nextDayThreshold: null,
9192
	isHiddenDayHash: null,
9193
9194
	// now indicator
9195
	isNowIndicatorRendered: null,
9196
	initialNowDate: null, // result first getNow call
9197
	initialNowQueriedMs: null, // ms time the getNow was called
9198
	nowIndicatorTimeoutID: null, // for refresh timing of now indicator
9199
	nowIndicatorIntervalID: null, // "
9200
9201
9202
	constructor: function(calendar, viewSpec) {
9203
		Model.prototype.constructor.call(this);
9204
9205
		this.calendar = calendar;
9206
		this.viewSpec = viewSpec;
9207
9208
		// shortcuts
9209
		this.type = viewSpec.type;
9210
		this.options = viewSpec.options;
9211
9212
		// .name is deprecated
9213
		this.name = this.type;
9214
9215
		this.nextDayThreshold = moment.duration(this.opt('nextDayThreshold'));
9216
		this.initThemingProps();
9217
		this.initHiddenDays();
9218
		this.isRTL = this.opt('isRTL');
9219
9220
		this.eventOrderSpecs = parseFieldSpecs(this.opt('eventOrder'));
9221
9222
		this.renderQueue = this.buildRenderQueue();
9223
		this.initAutoBatchRender();
9224
9225
		this.initialize();
9226
	},
9227
9228
9229
	buildRenderQueue: function() {
9230
		var _this = this;
9231
		var renderQueue = new RenderQueue({
9232
			event: this.opt('eventRenderWait')
9233
		});
9234
9235
		renderQueue.on('start', function() {
9236
			_this.freezeHeight();
9237
			_this.addScroll(_this.queryScroll());
9238
		});
9239
9240
		renderQueue.on('stop', function() {
9241
			_this.thawHeight();
9242
			_this.popScroll();
9243
		});
9244
9245
		return renderQueue;
9246
	},
9247
9248
9249
	initAutoBatchRender: function() {
9250
		var _this = this;
9251
9252
		this.on('before:change', function() {
9253
			_this.startBatchRender();
9254
		});
9255
9256
		this.on('change', function() {
9257
			_this.stopBatchRender();
9258
		});
9259
	},
9260
9261
9262
	startBatchRender: function() {
9263
		if (!(this.batchRenderDepth++)) {
9264
			this.renderQueue.pause();
9265
		}
9266
	},
9267
9268
9269
	stopBatchRender: function() {
9270
		if (!(--this.batchRenderDepth)) {
9271
			this.renderQueue.resume();
9272
		}
9273
	},
9274
9275
9276
	// A good place for subclasses to initialize member variables
9277
	initialize: function() {
9278
		// subclasses can implement
9279
	},
9280
9281
9282
	// Retrieves an option with the given name
9283
	opt: function(name) {
9284
		return this.options[name];
9285
	},
9286
9287
9288
	// Triggers handlers that are view-related. Modifies args before passing to calendar.
9289
	publiclyTrigger: function(name, thisObj) { // arguments beyond thisObj are passed along
9290
		var calendar = this.calendar;
9291
9292
		return calendar.publiclyTrigger.apply(
9293
			calendar,
9294
			[name, thisObj || this].concat(
9295
				Array.prototype.slice.call(arguments, 2), // arguments beyond thisObj
9296
				[ this ] // always make the last argument a reference to the view. TODO: deprecate
9297
			)
9298
		);
9299
	},
9300
9301
9302
	/* Title and Date Formatting
9303
	------------------------------------------------------------------------------------------------------------------*/
9304
9305
9306
	// Sets the view's title property to the most updated computed value
9307
	updateTitle: function() {
9308
		this.title = this.computeTitle();
9309
		this.calendar.setToolbarsTitle(this.title);
9310
	},
9311
9312
9313
	// Computes what the title at the top of the calendar should be for this view
9314
	computeTitle: function() {
9315
		var range;
9316
9317
		// for views that span a large unit of time, show the proper interval, ignoring stray days before and after
9318
		if (/^(year|month)$/.test(this.currentRangeUnit)) {
9319
			range = this.currentRange;
9320
		}
9321
		else { // for day units or smaller, use the actual day range
9322
			range = this.activeRange;
9323
		}
9324
9325
		return this.formatRange(
9326
			{
9327
				// in case currentRange has a time, make sure timezone is correct
9328
				start: this.calendar.applyTimezone(range.start),
9329
				end: this.calendar.applyTimezone(range.end)
9330
			},
9331
			this.opt('titleFormat') || this.computeTitleFormat(),
9332
			this.opt('titleRangeSeparator')
9333
		);
9334
	},
9335
9336
9337
	// Generates the format string that should be used to generate the title for the current date range.
9338
	// Attempts to compute the most appropriate format if not explicitly specified with `titleFormat`.
9339
	computeTitleFormat: function() {
9340
		if (this.currentRangeUnit == 'year') {
9341
			return 'YYYY';
9342
		}
9343
		else if (this.currentRangeUnit == 'month') {
9344
			return this.opt('monthYearFormat'); // like "September 2014"
9345
		}
9346
		else if (this.currentRangeAs('days') > 1) {
9347
			return 'll'; // multi-day range. shorter, like "Sep 9 - 10 2014"
9348
		}
9349
		else {
9350
			return 'LL'; // one day. longer, like "September 9 2014"
9351
		}
9352
	},
9353
9354
9355
	// Utility for formatting a range. Accepts a range object, formatting string, and optional separator.
9356
	// Displays all-day ranges naturally, with an inclusive end. Takes the current isRTL into account.
9357
	// The timezones of the dates within `range` will be respected.
9358
	formatRange: function(range, formatStr, separator) {
9359
		var end = range.end;
9360
9361
		if (!end.hasTime()) { // all-day?
9362
			end = end.clone().subtract(1); // convert to inclusive. last ms of previous day
9363
		}
9364
9365
		return formatRange(range.start, end, formatStr, separator, this.opt('isRTL'));
9366
	},
9367
9368
9369
	getAllDayHtml: function() {
9370
		return this.opt('allDayHtml') || htmlEscape(this.opt('allDayText'));
9371
	},
9372
9373
9374
	/* Navigation
9375
	------------------------------------------------------------------------------------------------------------------*/
9376
9377
9378
	// Generates HTML for an anchor to another view into the calendar.
9379
	// Will either generate an <a> tag or a non-clickable <span> tag, depending on enabled settings.
9380
	// `gotoOptions` can either be a moment input, or an object with the form:
9381
	// { date, type, forceOff }
9382
	// `type` is a view-type like "day" or "week". default value is "day".
9383
	// `attrs` and `innerHtml` are use to generate the rest of the HTML tag.
9384
	buildGotoAnchorHtml: function(gotoOptions, attrs, innerHtml) {
9385
		var date, type, forceOff;
9386
		var finalOptions;
9387
9388
		if ($.isPlainObject(gotoOptions)) {
9389
			date = gotoOptions.date;
9390
			type = gotoOptions.type;
9391
			forceOff = gotoOptions.forceOff;
9392
		}
9393
		else {
9394
			date = gotoOptions; // a single moment input
9395
		}
9396
		date = FC.moment(date); // if a string, parse it
9397
9398
		finalOptions = { // for serialization into the link
9399
			date: date.format('YYYY-MM-DD'),
9400
			type: type || 'day'
9401
		};
9402
9403
		if (typeof attrs === 'string') {
9404
			innerHtml = attrs;
9405
			attrs = null;
9406
		}
9407
9408
		attrs = attrs ? ' ' + attrsToStr(attrs) : ''; // will have a leading space
9409
		innerHtml = innerHtml || '';
9410
9411
		if (!forceOff && this.opt('navLinks')) {
9412
			return '<a' + attrs +
9413
				' data-goto="' + htmlEscape(JSON.stringify(finalOptions)) + '">' +
9414
				innerHtml +
9415
				'</a>';
9416
		}
9417
		else {
9418
			return '<span' + attrs + '>' +
9419
				innerHtml +
9420
				'</span>';
9421
		}
9422
	},
9423
9424
9425
	// Rendering Non-date-related Content
9426
	// -----------------------------------------------------------------------------------------------------------------
9427
9428
9429
	// Sets the container element that the view should render inside of, does global DOM-related initializations,
9430
	// and renders all the non-date-related content inside.
9431
	setElement: function(el) {
9432
		this.el = el;
9433
		this.bindGlobalHandlers();
9434
		this.bindBaseRenderHandlers();
9435
		this.renderSkeleton();
9436
	},
9437
9438
9439
	// Removes the view's container element from the DOM, clearing any content beforehand.
9440
	// Undoes any other DOM-related attachments.
9441
	removeElement: function() {
9442
		this.unsetDate();
9443
		this.unrenderSkeleton();
9444
9445
		this.unbindGlobalHandlers();
9446
		this.unbindBaseRenderHandlers();
9447
9448
		this.el.remove();
9449
		// NOTE: don't null-out this.el in case the View was destroyed within an API callback.
9450
		// We don't null-out the View's other jQuery element references upon destroy,
9451
		//  so we shouldn't kill this.el either.
9452
	},
9453
9454
9455
	// Renders the basic structure of the view before any content is rendered
9456
	renderSkeleton: function() {
9457
		// subclasses should implement
9458
	},
9459
9460
9461
	// Unrenders the basic structure of the view
9462
	unrenderSkeleton: function() {
9463
		// subclasses should implement
9464
	},
9465
9466
9467
	// Date Setting/Unsetting
9468
	// -----------------------------------------------------------------------------------------------------------------
9469
9470
9471
	setDate: function(date) {
9472
		var currentDateProfile = this.get('dateProfile');
9473
		var newDateProfile = this.buildDateProfile(date, null, true); // forceToValid=true
9474
9475
		if (
9476
			!currentDateProfile ||
9477
			!isRangesEqual(currentDateProfile.activeRange, newDateProfile.activeRange)
9478
		) {
9479
			this.set('dateProfile', newDateProfile);
9480
		}
9481
9482
		return newDateProfile.date;
9483
	},
9484
9485
9486
	unsetDate: function() {
9487
		this.unset('dateProfile');
9488
	},
9489
9490
9491
	// Date Rendering
9492
	// -----------------------------------------------------------------------------------------------------------------
9493
9494
9495
	requestDateRender: function(dateProfile) {
9496
		var _this = this;
9497
9498
		this.renderQueue.queue(function() {
9499
			_this.executeDateRender(dateProfile);
9500
		}, 'date', 'init');
9501
	},
9502
9503
9504
	requestDateUnrender: function() {
9505
		var _this = this;
9506
9507
		this.renderQueue.queue(function() {
9508
			_this.executeDateUnrender();
9509
		}, 'date', 'destroy');
9510
	},
9511
9512
9513
	// Event Data
9514
	// -----------------------------------------------------------------------------------------------------------------
9515
9516
9517
	fetchInitialEvents: function(dateProfile) {
9518
		return this.calendar.requestEvents(
9519
			dateProfile.activeRange.start,
9520
			dateProfile.activeRange.end
9521
		);
9522
	},
9523
9524
9525
	bindEventChanges: function() {
9526
		this.listenTo(this.calendar, 'eventsReset', this.resetEvents);
9527
	},
9528
9529
9530
	unbindEventChanges: function() {
9531
		this.stopListeningTo(this.calendar, 'eventsReset');
9532
	},
9533
9534
9535
	setEvents: function(events) {
9536
		this.set('currentEvents', events);
9537
		this.set('hasEvents', true);
9538
	},
9539
9540
9541
	unsetEvents: function() {
9542
		this.unset('currentEvents');
9543
		this.unset('hasEvents');
9544
	},
9545
9546
9547
	resetEvents: function(events) {
9548
		this.startBatchRender();
9549
		this.unsetEvents();
9550
		this.setEvents(events);
9551
		this.stopBatchRender();
9552
	},
9553
9554
9555
	// Event Rendering
9556
	// -----------------------------------------------------------------------------------------------------------------
9557
9558
9559
	requestEventsRender: function(events) {
9560
		var _this = this;
9561
9562
		this.renderQueue.queue(function() {
9563
			_this.executeEventsRender(events);
9564
		}, 'event', 'init');
9565
	},
9566
9567
9568
	requestEventsUnrender: function() {
9569
		var _this = this;
9570
9571
		this.renderQueue.queue(function() {
9572
			_this.executeEventsUnrender();
9573
		}, 'event', 'destroy');
9574
	},
9575
9576
9577
	// Date High-level Rendering
9578
	// -----------------------------------------------------------------------------------------------------------------
9579
9580
9581
	// if dateProfile not specified, uses current
9582
	executeDateRender: function(dateProfile, skipScroll) {
9583
9584
		this.setDateProfileForRendering(dateProfile);
9585
		this.updateTitle();
9586
		this.calendar.updateToolbarButtons();
9587
9588
		if (this.render) {
9589
			this.render(); // TODO: deprecate
9590
		}
9591
9592
		this.renderDates();
9593
		this.updateSize();
9594
		this.renderBusinessHours(); // might need coordinates, so should go after updateSize()
9595
		this.startNowIndicator();
9596
9597
		if (!skipScroll) {
9598
			this.addScroll(this.computeInitialDateScroll());
9599
		}
9600
9601
		this.isDatesRendered = true;
9602
		this.trigger('datesRendered');
9603
	},
9604
9605
9606
	executeDateUnrender: function() {
9607
9608
		this.unselect();
9609
		this.stopNowIndicator();
9610
9611
		this.trigger('before:datesUnrendered');
9612
9613
		this.unrenderBusinessHours();
9614
		this.unrenderDates();
9615
9616
		if (this.destroy) {
9617
			this.destroy(); // TODO: deprecate
9618
		}
9619
9620
		this.isDatesRendered = false;
9621
	},
9622
9623
9624
	// Date Low-level Rendering
9625
	// -----------------------------------------------------------------------------------------------------------------
9626
9627
9628
	// date-cell content only
9629
	renderDates: function() {
9630
		// subclasses should implement
9631
	},
9632
9633
9634
	// date-cell content only
9635
	unrenderDates: function() {
9636
		// subclasses should override
9637
	},
9638
9639
9640
	// Determing when the "meat" of the view is rendered (aka the base)
9641
	// -----------------------------------------------------------------------------------------------------------------
9642
9643
9644
	bindBaseRenderHandlers: function() {
9645
		var _this = this;
9646
9647
		this.on('datesRendered.baseHandler', function() {
9648
			_this.onBaseRender();
9649
		});
9650
9651
		this.on('before:datesUnrendered.baseHandler', function() {
9652
			_this.onBeforeBaseUnrender();
9653
		});
9654
	},
9655
9656
9657
	unbindBaseRenderHandlers: function() {
9658
		this.off('.baseHandler');
9659
	},
9660
9661
9662
	onBaseRender: function() {
9663
		this.applyScreenState();
9664
		this.publiclyTrigger('viewRender', this, this, this.el);
9665
	},
9666
9667
9668
	onBeforeBaseUnrender: function() {
9669
		this.applyScreenState();
9670
		this.publiclyTrigger('viewDestroy', this, this, this.el);
9671
	},
9672
9673
9674
	// Misc view rendering utils
9675
	// -----------------------------------------------------------------------------------------------------------------
9676
9677
9678
	// Binds DOM handlers to elements that reside outside the view container, such as the document
9679
	bindGlobalHandlers: function() {
9680
		this.listenTo(GlobalEmitter.get(), {
9681
			touchstart: this.processUnselect,
9682
			mousedown: this.handleDocumentMousedown
9683
		});
9684
	},
9685
9686
9687
	// Unbinds DOM handlers from elements that reside outside the view container
9688
	unbindGlobalHandlers: function() {
9689
		this.stopListeningTo(GlobalEmitter.get());
9690
	},
9691
9692
9693
	// Initializes internal variables related to theming
9694
	initThemingProps: function() {
9695
		var tm = this.opt('theme') ? 'ui' : 'fc';
9696
9697
		this.widgetHeaderClass = tm + '-widget-header';
9698
		this.widgetContentClass = tm + '-widget-content';
9699
		this.highlightStateClass = tm + '-state-highlight';
9700
	},
9701
9702
9703
	/* Business Hours
9704
	------------------------------------------------------------------------------------------------------------------*/
9705
9706
9707
	// Renders business-hours onto the view. Assumes updateSize has already been called.
9708
	renderBusinessHours: function() {
9709
		// subclasses should implement
9710
	},
9711
9712
9713
	// Unrenders previously-rendered business-hours
9714
	unrenderBusinessHours: function() {
9715
		// subclasses should implement
9716
	},
9717
9718
9719
	/* Now Indicator
9720
	------------------------------------------------------------------------------------------------------------------*/
9721
9722
9723
	// Immediately render the current time indicator and begins re-rendering it at an interval,
9724
	// which is defined by this.getNowIndicatorUnit().
9725
	// TODO: somehow do this for the current whole day's background too
9726
	startNowIndicator: function() {
9727
		var _this = this;
9728
		var unit;
9729
		var update;
9730
		var delay; // ms wait value
9731
9732
		if (this.opt('nowIndicator')) {
9733
			unit = this.getNowIndicatorUnit();
9734
			if (unit) {
9735
				update = proxy(this, 'updateNowIndicator'); // bind to `this`
9736
9737
				this.initialNowDate = this.calendar.getNow();
9738
				this.initialNowQueriedMs = +new Date();
9739
				this.renderNowIndicator(this.initialNowDate);
9740
				this.isNowIndicatorRendered = true;
9741
9742
				// wait until the beginning of the next interval
9743
				delay = this.initialNowDate.clone().startOf(unit).add(1, unit) - this.initialNowDate;
9744
				this.nowIndicatorTimeoutID = setTimeout(function() {
9745
					_this.nowIndicatorTimeoutID = null;
9746
					update();
9747
					delay = +moment.duration(1, unit);
9748
					delay = Math.max(100, delay); // prevent too frequent
9749
					_this.nowIndicatorIntervalID = setInterval(update, delay); // update every interval
9750
				}, delay);
9751
			}
9752
		}
9753
	},
9754
9755
9756
	// rerenders the now indicator, computing the new current time from the amount of time that has passed
9757
	// since the initial getNow call.
9758
	updateNowIndicator: function() {
9759
		if (this.isNowIndicatorRendered) {
9760
			this.unrenderNowIndicator();
9761
			this.renderNowIndicator(
9762
				this.initialNowDate.clone().add(new Date() - this.initialNowQueriedMs) // add ms
9763
			);
9764
		}
9765
	},
9766
9767
9768
	// Immediately unrenders the view's current time indicator and stops any re-rendering timers.
9769
	// Won't cause side effects if indicator isn't rendered.
9770
	stopNowIndicator: function() {
9771
		if (this.isNowIndicatorRendered) {
9772
9773
			if (this.nowIndicatorTimeoutID) {
9774
				clearTimeout(this.nowIndicatorTimeoutID);
9775
				this.nowIndicatorTimeoutID = null;
9776
			}
9777
			if (this.nowIndicatorIntervalID) {
9778
				clearTimeout(this.nowIndicatorIntervalID);
9779
				this.nowIndicatorIntervalID = null;
9780
			}
9781
9782
			this.unrenderNowIndicator();
9783
			this.isNowIndicatorRendered = false;
9784
		}
9785
	},
9786
9787
9788
	// Returns a string unit, like 'second' or 'minute' that defined how often the current time indicator
9789
	// should be refreshed. If something falsy is returned, no time indicator is rendered at all.
9790
	getNowIndicatorUnit: function() {
9791
		// subclasses should implement
9792
	},
9793
9794
9795
	// Renders a current time indicator at the given datetime
9796
	renderNowIndicator: function(date) {
9797
		// subclasses should implement
9798
	},
9799
9800
9801
	// Undoes the rendering actions from renderNowIndicator
9802
	unrenderNowIndicator: function() {
9803
		// subclasses should implement
9804
	},
9805
9806
9807
	/* Dimensions
9808
	------------------------------------------------------------------------------------------------------------------*/
9809
9810
9811
	// Refreshes anything dependant upon sizing of the container element of the grid
9812
	updateSize: function(isResize) {
9813
		var scroll;
9814
9815
		if (isResize) {
9816
			scroll = this.queryScroll();
9817
		}
9818
9819
		this.updateHeight(isResize);
9820
		this.updateWidth(isResize);
9821
		this.updateNowIndicator();
9822
9823
		if (isResize) {
9824
			this.applyScroll(scroll);
9825
		}
9826
	},
9827
9828
9829
	// Refreshes the horizontal dimensions of the calendar
9830
	updateWidth: function(isResize) {
9831
		// subclasses should implement
9832
	},
9833
9834
9835
	// Refreshes the vertical dimensions of the calendar
9836
	updateHeight: function(isResize) {
9837
		var calendar = this.calendar; // we poll the calendar for height information
9838
9839
		this.setHeight(
9840
			calendar.getSuggestedViewHeight(),
9841
			calendar.isHeightAuto()
9842
		);
9843
	},
9844
9845
9846
	// Updates the vertical dimensions of the calendar to the specified height.
9847
	// if `isAuto` is set to true, height becomes merely a suggestion and the view should use its "natural" height.
9848
	setHeight: function(height, isAuto) {
9849
		// subclasses should implement
9850
	},
9851
9852
9853
	/* Scroller
9854
	------------------------------------------------------------------------------------------------------------------*/
9855
9856
9857
	addForcedScroll: function(scroll) {
9858
		this.addScroll(
9859
			$.extend(scroll, { isForced: true })
9860
		);
9861
	},
9862
9863
9864
	addScroll: function(scroll) {
9865
		var queuedScroll = this.queuedScroll || (this.queuedScroll = {});
9866
9867
		if (!queuedScroll.isForced) {
9868
			$.extend(queuedScroll, scroll);
9869
		}
9870
	},
9871
9872
9873
	popScroll: function() {
9874
		this.applyQueuedScroll();
9875
		this.queuedScroll = null;
9876
	},
9877
9878
9879
	applyQueuedScroll: function() {
9880
		if (this.queuedScroll) {
9881
			this.applyScroll(this.queuedScroll);
9882
		}
9883
	},
9884
9885
9886
	queryScroll: function() {
9887
		var scroll = {};
9888
9889
		if (this.isDatesRendered) {
9890
			$.extend(scroll, this.queryDateScroll());
9891
		}
9892
9893
		return scroll;
9894
	},
9895
9896
9897
	applyScroll: function(scroll) {
9898
		if (this.isDatesRendered) {
9899
			this.applyDateScroll(scroll);
9900
		}
9901
	},
9902
9903
9904
	computeInitialDateScroll: function() {
9905
		return {}; // subclasses must implement
9906
	},
9907
9908
9909
	queryDateScroll: function() {
9910
		return {}; // subclasses must implement
9911
	},
9912
9913
9914
	applyDateScroll: function(scroll) {
9915
		; // subclasses must implement
9916
	},
9917
9918
9919
	/* Height Freezing
9920
	------------------------------------------------------------------------------------------------------------------*/
9921
9922
9923
	freezeHeight: function() {
9924
		this.calendar.freezeContentHeight();
9925
	},
9926
9927
9928
	thawHeight: function() {
9929
		this.calendar.thawContentHeight();
9930
	},
9931
9932
9933
	// Event High-level Rendering
9934
	// -----------------------------------------------------------------------------------------------------------------
9935
9936
9937
	executeEventsRender: function(events) {
9938
		this.renderEvents(events);
9939
		this.isEventsRendered = true;
9940
9941
		this.onEventsRender();
9942
	},
9943
9944
9945
	executeEventsUnrender: function() {
9946
		this.onBeforeEventsUnrender();
9947
9948
		if (this.destroyEvents) {
9949
			this.destroyEvents(); // TODO: deprecate
9950
		}
9951
9952
		this.unrenderEvents();
9953
		this.isEventsRendered = false;
9954
	},
9955
9956
9957
	// Event Rendering Triggers
9958
	// -----------------------------------------------------------------------------------------------------------------
9959
9960
9961
	// Signals that all events have been rendered
9962
	onEventsRender: function() {
9963
		this.applyScreenState();
9964
9965
		this.renderedEventSegEach(function(seg) {
9966
			this.publiclyTrigger('eventAfterRender', seg.event, seg.event, seg.el);
9967
		});
9968
		this.publiclyTrigger('eventAfterAllRender');
9969
	},
9970
9971
9972
	// Signals that all event elements are about to be removed
9973
	onBeforeEventsUnrender: function() {
9974
		this.applyScreenState();
9975
9976
		this.renderedEventSegEach(function(seg) {
9977
			this.publiclyTrigger('eventDestroy', seg.event, seg.event, seg.el);
9978
		});
9979
	},
9980
9981
9982
	applyScreenState: function() {
9983
		this.thawHeight();
9984
		this.freezeHeight();
9985
		this.applyQueuedScroll();
9986
	},
9987
9988
9989
	// Event Low-level Rendering
9990
	// -----------------------------------------------------------------------------------------------------------------
9991
9992
9993
	// Renders the events onto the view.
9994
	renderEvents: function(events) {
9995
		// subclasses should implement
9996
	},
9997
9998
9999
	// Removes event elements from the view.
10000
	unrenderEvents: function() {
10001
		// subclasses should implement
10002
	},
10003
10004
10005
	// Event Rendering Utils
10006
	// -----------------------------------------------------------------------------------------------------------------
10007
10008
10009
	// Given an event and the default element used for rendering, returns the element that should actually be used.
10010
	// Basically runs events and elements through the eventRender hook.
10011
	resolveEventEl: function(event, el) {
10012
		var custom = this.publiclyTrigger('eventRender', event, event, el);
10013
10014
		if (custom === false) { // means don't render at all
10015
			el = null;
10016
		}
10017
		else if (custom && custom !== true) {
10018
			el = $(custom);
10019
		}
10020
10021
		return el;
10022
	},
10023
10024
10025
	// Hides all rendered event segments linked to the given event
10026
	showEvent: function(event) {
10027
		this.renderedEventSegEach(function(seg) {
10028
			seg.el.css('visibility', '');
10029
		}, event);
10030
	},
10031
10032
10033
	// Shows all rendered event segments linked to the given event
10034
	hideEvent: function(event) {
10035
		this.renderedEventSegEach(function(seg) {
10036
			seg.el.css('visibility', 'hidden');
10037
		}, event);
10038
	},
10039
10040
10041
	// Iterates through event segments that have been rendered (have an el). Goes through all by default.
10042
	// If the optional `event` argument is specified, only iterates through segments linked to that event.
10043
	// The `this` value of the callback function will be the view.
10044
	renderedEventSegEach: function(func, event) {
10045
		var segs = this.getEventSegs();
10046
		var i;
10047
10048
		for (i = 0; i < segs.length; i++) {
10049
			if (!event || segs[i].event._id === event._id) {
10050
				if (segs[i].el) {
10051
					func.call(this, segs[i]);
10052
				}
10053
			}
10054
		}
10055
	},
10056
10057
10058
	// Retrieves all the rendered segment objects for the view
10059
	getEventSegs: function() {
10060
		// subclasses must implement
10061
		return [];
10062
	},
10063
10064
10065
	/* Event Drag-n-Drop
10066
	------------------------------------------------------------------------------------------------------------------*/
10067
10068
10069
	// Computes if the given event is allowed to be dragged by the user
10070
	isEventDraggable: function(event) {
10071
		return this.isEventStartEditable(event);
10072
	},
10073
10074
10075
	isEventStartEditable: function(event) {
10076
		return firstDefined(
10077
			event.startEditable,
10078
			(event.source || {}).startEditable,
10079
			this.opt('eventStartEditable'),
10080
			this.isEventGenerallyEditable(event)
10081
		);
10082
	},
10083
10084
10085
	isEventGenerallyEditable: function(event) {
10086
		return firstDefined(
10087
			event.editable,
10088
			(event.source || {}).editable,
10089
			this.opt('editable')
10090
		);
10091
	},
10092
10093
10094
	// Must be called when an event in the view is dropped onto new location.
10095
	// `dropLocation` is an object that contains the new zoned start/end/allDay values for the event.
10096
	reportSegDrop: function(seg, dropLocation, largeUnit, el, ev) {
10097
		var calendar = this.calendar;
10098
		var mutateResult = calendar.mutateSeg(seg, dropLocation, largeUnit);
10099
		var undoFunc = function() {
10100
			mutateResult.undo();
10101
			calendar.reportEventChange();
10102
		};
10103
10104
		this.triggerEventDrop(seg.event, mutateResult.dateDelta, undoFunc, el, ev);
10105
		calendar.reportEventChange(); // will rerender events
10106
	},
10107
10108
10109
	// Triggers event-drop handlers that have subscribed via the API
10110
	triggerEventDrop: function(event, dateDelta, undoFunc, el, ev) {
10111
		this.publiclyTrigger('eventDrop', el[0], event, dateDelta, undoFunc, ev, {}); // {} = jqui dummy
10112
	},
10113
10114
10115
	/* External Element Drag-n-Drop
10116
	------------------------------------------------------------------------------------------------------------------*/
10117
10118
10119
	// Must be called when an external element, via jQuery UI, has been dropped onto the calendar.
10120
	// `meta` is the parsed data that has been embedded into the dragging event.
10121
	// `dropLocation` is an object that contains the new zoned start/end/allDay values for the event.
10122
	reportExternalDrop: function(meta, dropLocation, el, ev, ui) {
10123
		var eventProps = meta.eventProps;
10124
		var eventInput;
10125
		var event;
10126
10127
		// Try to build an event object and render it. TODO: decouple the two
10128
		if (eventProps) {
10129
			eventInput = $.extend({}, eventProps, dropLocation);
10130
			event = this.calendar.renderEvent(eventInput, meta.stick)[0]; // renderEvent returns an array
10131
		}
10132
10133
		this.triggerExternalDrop(event, dropLocation, el, ev, ui);
10134
	},
10135
10136
10137
	// Triggers external-drop handlers that have subscribed via the API
10138
	triggerExternalDrop: function(event, dropLocation, el, ev, ui) {
10139
10140
		// trigger 'drop' regardless of whether element represents an event
10141
		this.publiclyTrigger('drop', el[0], dropLocation.start, ev, ui);
10142
10143
		if (event) {
10144
			this.publiclyTrigger('eventReceive', null, event); // signal an external event landed
10145
		}
10146
	},
10147
10148
10149
	/* Drag-n-Drop Rendering (for both events and external elements)
10150
	------------------------------------------------------------------------------------------------------------------*/
10151
10152
10153
	// Renders a visual indication of a event or external-element drag over the given drop zone.
10154
	// If an external-element, seg will be `null`.
10155
	// Must return elements used for any mock events.
10156
	renderDrag: function(dropLocation, seg) {
10157
		// subclasses must implement
10158
	},
10159
10160
10161
	// Unrenders a visual indication of an event or external-element being dragged.
10162
	unrenderDrag: function() {
10163
		// subclasses must implement
10164
	},
10165
10166
10167
	/* Event Resizing
10168
	------------------------------------------------------------------------------------------------------------------*/
10169
10170
10171
	// Computes if the given event is allowed to be resized from its starting edge
10172
	isEventResizableFromStart: function(event) {
10173
		return this.opt('eventResizableFromStart') && this.isEventResizable(event);
10174
	},
10175
10176
10177
	// Computes if the given event is allowed to be resized from its ending edge
10178
	isEventResizableFromEnd: function(event) {
10179
		return this.isEventResizable(event);
10180
	},
10181
10182
10183
	// Computes if the given event is allowed to be resized by the user at all
10184
	isEventResizable: function(event) {
10185
		var source = event.source || {};
10186
10187
		return firstDefined(
10188
			event.durationEditable,
10189
			source.durationEditable,
10190
			this.opt('eventDurationEditable'),
10191
			event.editable,
10192
			source.editable,
10193
			this.opt('editable')
10194
		);
10195
	},
10196
10197
10198
	// Must be called when an event in the view has been resized to a new length
10199
	reportSegResize: function(seg, resizeLocation, largeUnit, el, ev) {
10200
		var calendar = this.calendar;
10201
		var mutateResult = calendar.mutateSeg(seg, resizeLocation, largeUnit);
10202
		var undoFunc = function() {
10203
			mutateResult.undo();
10204
			calendar.reportEventChange();
10205
		};
10206
10207
		this.triggerEventResize(seg.event, mutateResult.durationDelta, undoFunc, el, ev);
10208
		calendar.reportEventChange(); // will rerender events
10209
	},
10210
10211
10212
	// Triggers event-resize handlers that have subscribed via the API
10213
	triggerEventResize: function(event, durationDelta, undoFunc, el, ev) {
10214
		this.publiclyTrigger('eventResize', el[0], event, durationDelta, undoFunc, ev, {}); // {} = jqui dummy
10215
	},
10216
10217
10218
	/* Selection (time range)
10219
	------------------------------------------------------------------------------------------------------------------*/
10220
10221
10222
	// Selects a date span on the view. `start` and `end` are both Moments.
10223
	// `ev` is the native mouse event that begin the interaction.
10224
	select: function(span, ev) {
10225
		this.unselect(ev);
10226
		this.renderSelection(span);
10227
		this.reportSelection(span, ev);
10228
	},
10229
10230
10231
	// Renders a visual indication of the selection
10232
	renderSelection: function(span) {
10233
		// subclasses should implement
10234
	},
10235
10236
10237
	// Called when a new selection is made. Updates internal state and triggers handlers.
10238
	reportSelection: function(span, ev) {
10239
		this.isSelected = true;
10240
		this.triggerSelect(span, ev);
10241
	},
10242
10243
10244
	// Triggers handlers to 'select'
10245
	triggerSelect: function(span, ev) {
10246
		this.publiclyTrigger(
10247
			'select',
10248
			null,
10249
			this.calendar.applyTimezone(span.start), // convert to calendar's tz for external API
10250
			this.calendar.applyTimezone(span.end), // "
10251
			ev
10252
		);
10253
	},
10254
10255
10256
	// Undoes a selection. updates in the internal state and triggers handlers.
10257
	// `ev` is the native mouse event that began the interaction.
10258
	unselect: function(ev) {
10259
		if (this.isSelected) {
10260
			this.isSelected = false;
10261
			if (this.destroySelection) {
10262
				this.destroySelection(); // TODO: deprecate
10263
			}
10264
			this.unrenderSelection();
10265
			this.publiclyTrigger('unselect', null, ev);
10266
		}
10267
	},
10268
10269
10270
	// Unrenders a visual indication of selection
10271
	unrenderSelection: function() {
10272
		// subclasses should implement
10273
	},
10274
10275
10276
	/* Event Selection
10277
	------------------------------------------------------------------------------------------------------------------*/
10278
10279
10280
	selectEvent: function(event) {
10281
		if (!this.selectedEvent || this.selectedEvent !== event) {
10282
			this.unselectEvent();
10283
			this.renderedEventSegEach(function(seg) {
10284
				seg.el.addClass('fc-selected');
10285
			}, event);
10286
			this.selectedEvent = event;
10287
		}
10288
	},
10289
10290
10291
	unselectEvent: function() {
10292
		if (this.selectedEvent) {
10293
			this.renderedEventSegEach(function(seg) {
10294
				seg.el.removeClass('fc-selected');
10295
			}, this.selectedEvent);
10296
			this.selectedEvent = null;
10297
		}
10298
	},
10299
10300
10301
	isEventSelected: function(event) {
10302
		// event references might change on refetchEvents(), while selectedEvent doesn't,
10303
		// so compare IDs
10304
		return this.selectedEvent && this.selectedEvent._id === event._id;
10305
	},
10306
10307
10308
	/* Mouse / Touch Unselecting (time range & event unselection)
10309
	------------------------------------------------------------------------------------------------------------------*/
10310
	// TODO: move consistently to down/start or up/end?
10311
	// TODO: don't kill previous selection if touch scrolling
10312
10313
10314
	handleDocumentMousedown: function(ev) {
10315
		if (isPrimaryMouseButton(ev)) {
10316
			this.processUnselect(ev);
10317
		}
10318
	},
10319
10320
10321
	processUnselect: function(ev) {
10322
		this.processRangeUnselect(ev);
10323
		this.processEventUnselect(ev);
10324
	},
10325
10326
10327
	processRangeUnselect: function(ev) {
10328
		var ignore;
10329
10330
		// is there a time-range selection?
10331
		if (this.isSelected && this.opt('unselectAuto')) {
10332
			// only unselect if the clicked element is not identical to or inside of an 'unselectCancel' element
10333
			ignore = this.opt('unselectCancel');
10334
			if (!ignore || !$(ev.target).closest(ignore).length) {
10335
				this.unselect(ev);
10336
			}
10337
		}
10338
	},
10339
10340
10341
	processEventUnselect: function(ev) {
10342
		if (this.selectedEvent) {
10343
			if (!$(ev.target).closest('.fc-selected').length) {
10344
				this.unselectEvent();
10345
			}
10346
		}
10347
	},
10348
10349
10350
	/* Day Click
10351
	------------------------------------------------------------------------------------------------------------------*/
10352
10353
10354
	// Triggers handlers to 'dayClick'
10355
	// Span has start/end of the clicked area. Only the start is useful.
10356
	triggerDayClick: function(span, dayEl, ev) {
10357
		this.publiclyTrigger(
10358
			'dayClick',
10359
			dayEl,
10360
			this.calendar.applyTimezone(span.start), // convert to calendar's timezone for external API
10361
			ev
10362
		);
10363
	},
10364
10365
10366
	/* Date Utils
10367
	------------------------------------------------------------------------------------------------------------------*/
10368
10369
10370
	// Returns the date range of the full days the given range visually appears to occupy.
10371
	// Returns a new range object.
10372
	computeDayRange: function(range) {
10373
		var startDay = range.start.clone().stripTime(); // the beginning of the day the range starts
10374
		var end = range.end;
10375
		var endDay = null;
10376
		var endTimeMS;
10377
10378
		if (end) {
10379
			endDay = end.clone().stripTime(); // the beginning of the day the range exclusively ends
10380
			endTimeMS = +end.time(); // # of milliseconds into `endDay`
10381
10382
			// If the end time is actually inclusively part of the next day and is equal to or
10383
			// beyond the next day threshold, adjust the end to be the exclusive end of `endDay`.
10384
			// Otherwise, leaving it as inclusive will cause it to exclude `endDay`.
10385
			if (endTimeMS && endTimeMS >= this.nextDayThreshold) {
10386
				endDay.add(1, 'days');
10387
			}
10388
		}
10389
10390
		// If no end was specified, or if it is within `startDay` but not past nextDayThreshold,
10391
		// assign the default duration of one day.
10392
		if (!end || endDay <= startDay) {
10393
			endDay = startDay.clone().add(1, 'days');
10394
		}
10395
10396
		return { start: startDay, end: endDay };
10397
	},
10398
10399
10400
	// Does the given event visually appear to occupy more than one day?
10401
	isMultiDayEvent: function(event) {
10402
		var range = this.computeDayRange(event); // event is range-ish
10403
10404
		return range.end.diff(range.start, 'days') > 1;
10405
	}
10406
10407
});
10408
10409
10410
View.watch('displayingDates', [ 'dateProfile' ], function(deps) {
10411
	this.requestDateRender(deps.dateProfile);
10412
}, function() {
10413
	this.requestDateUnrender();
10414
});
10415
10416
10417
View.watch('initialEvents', [ 'dateProfile' ], function(deps) {
10418
	return this.fetchInitialEvents(deps.dateProfile);
10419
});
10420
10421
10422
View.watch('bindingEvents', [ 'initialEvents' ], function(deps) {
10423
	this.setEvents(deps.initialEvents);
10424
	this.bindEventChanges();
10425
}, function() {
10426
	this.unbindEventChanges();
10427
	this.unsetEvents();
10428
});
10429
10430
10431
View.watch('displayingEvents', [ 'displayingDates', 'hasEvents' ], function() {
10432
	this.requestEventsRender(this.get('currentEvents')); // if there were event mutations after initialEvents
10433
}, function() {
10434
	this.requestEventsUnrender();
10435
});
10436
10437
;;
10438
10439
View.mixin({
10440
10441
	// range the view is formally responsible for.
10442
	// for example, a month view might have 1st-31st, excluding padded dates
10443
	currentRange: null,
10444
	currentRangeUnit: null, // name of largest unit being displayed, like "month" or "week"
10445
10446
	// date range with a rendered skeleton
10447
	// includes not-active days that need some sort of DOM
10448
	renderRange: null,
10449
10450
	// dates that display events and accept drag-n-drop
10451
	activeRange: null,
10452
10453
	// constraint for where prev/next operations can go and where events can be dragged/resized to.
10454
	// an object with optional start and end properties.
10455
	validRange: null,
10456
10457
	// how far the current date will move for a prev/next operation
10458
	dateIncrement: null,
10459
10460
	minTime: null, // Duration object that denotes the first visible time of any given day
10461
	maxTime: null, // Duration object that denotes the exclusive visible end time of any given day
10462
	usesMinMaxTime: false, // whether minTime/maxTime will affect the activeRange. Views must opt-in.
10463
10464
	// DEPRECATED
10465
	start: null, // use activeRange.start
10466
	end: null, // use activeRange.end
10467
	intervalStart: null, // use currentRange.start
10468
	intervalEnd: null, // use currentRange.end
10469
10470
10471
	/* Date Range Computation
10472
	------------------------------------------------------------------------------------------------------------------*/
10473
10474
10475
	setDateProfileForRendering: function(dateProfile) {
10476
		this.currentRange = dateProfile.currentRange;
10477
		this.currentRangeUnit = dateProfile.currentRangeUnit;
10478
		this.renderRange = dateProfile.renderRange;
10479
		this.activeRange = dateProfile.activeRange;
10480
		this.validRange = dateProfile.validRange;
10481
		this.dateIncrement = dateProfile.dateIncrement;
10482
		this.minTime = dateProfile.minTime;
10483
		this.maxTime = dateProfile.maxTime;
10484
10485
		// DEPRECATED, but we need to keep it updated
10486
		this.start = dateProfile.activeRange.start;
10487
		this.end = dateProfile.activeRange.end;
10488
		this.intervalStart = dateProfile.currentRange.start;
10489
		this.intervalEnd = dateProfile.currentRange.end;
10490
	},
10491
10492
10493
	// Builds a structure with info about what the dates/ranges will be for the "prev" view.
10494
	buildPrevDateProfile: function(date) {
10495
		var prevDate = date.clone().startOf(this.currentRangeUnit).subtract(this.dateIncrement);
10496
10497
		return this.buildDateProfile(prevDate, -1);
10498
	},
10499
10500
10501
	// Builds a structure with info about what the dates/ranges will be for the "next" view.
10502
	buildNextDateProfile: function(date) {
10503
		var nextDate = date.clone().startOf(this.currentRangeUnit).add(this.dateIncrement);
10504
10505
		return this.buildDateProfile(nextDate, 1);
10506
	},
10507
10508
10509
	// Builds a structure holding dates/ranges for rendering around the given date.
10510
	// Optional direction param indicates whether the date is being incremented/decremented
10511
	// from its previous value. decremented = -1, incremented = 1 (default).
10512
	buildDateProfile: function(date, direction, forceToValid) {
10513
		var validRange = this.buildValidRange();
10514
		var minTime = null;
10515
		var maxTime = null;
10516
		var currentInfo;
10517
		var renderRange;
10518
		var activeRange;
10519
		var isValid;
10520
10521
		if (forceToValid) {
10522
			date = constrainDate(date, validRange);
10523
		}
10524
10525
		currentInfo = this.buildCurrentRangeInfo(date, direction);
10526
		renderRange = this.buildRenderRange(currentInfo.range, currentInfo.unit);
10527
		activeRange = cloneRange(renderRange);
10528
10529
		if (!this.opt('showNonCurrentDates')) {
10530
			activeRange = constrainRange(activeRange, currentInfo.range);
10531
		}
10532
10533
		minTime = moment.duration(this.opt('minTime'));
10534
		maxTime = moment.duration(this.opt('maxTime'));
10535
		this.adjustActiveRange(activeRange, minTime, maxTime);
10536
10537
		activeRange = constrainRange(activeRange, validRange);
10538
		date = constrainDate(date, activeRange);
10539
10540
		// it's invalid if the originally requested date is not contained,
10541
		// or if the range is completely outside of the valid range.
10542
		isValid = doRangesIntersect(currentInfo.range, validRange);
10543
10544
		return {
10545
			validRange: validRange,
10546
			currentRange: currentInfo.range,
10547
			currentRangeUnit: currentInfo.unit,
10548
			activeRange: activeRange,
10549
			renderRange: renderRange,
10550
			minTime: minTime,
10551
			maxTime: maxTime,
10552
			isValid: isValid,
10553
			date: date,
10554
			dateIncrement: this.buildDateIncrement(currentInfo.duration)
10555
				// pass a fallback (might be null) ^
10556
		};
10557
	},
10558
10559
10560
	// Builds an object with optional start/end properties.
10561
	// Indicates the minimum/maximum dates to display.
10562
	buildValidRange: function() {
10563
		return this.getRangeOption('validRange', this.calendar.getNow()) || {};
10564
	},
10565
10566
10567
	// Builds a structure with info about the "current" range, the range that is
10568
	// highlighted as being the current month for example.
10569
	// See buildDateProfile for a description of `direction`.
10570
	// Guaranteed to have `range` and `unit` properties. `duration` is optional.
10571
	buildCurrentRangeInfo: function(date, direction) {
10572
		var duration = null;
10573
		var unit = null;
10574
		var range = null;
10575
		var dayCount;
10576
10577
		if (this.viewSpec.duration) {
10578
			duration = this.viewSpec.duration;
10579
			unit = this.viewSpec.durationUnit;
10580
			range = this.buildRangeFromDuration(date, direction, duration, unit);
10581
		}
10582
		else if ((dayCount = this.opt('dayCount'))) {
10583
			unit = 'day';
10584
			range = this.buildRangeFromDayCount(date, direction, dayCount);
10585
		}
10586
		else if ((range = this.buildCustomVisibleRange(date))) {
10587
			unit = computeGreatestUnit(range.start, range.end);
10588
		}
10589
		else {
10590
			duration = this.getFallbackDuration();
10591
			unit = computeGreatestUnit(duration);
10592
			range = this.buildRangeFromDuration(date, direction, duration, unit);
10593
		}
10594
10595
		this.normalizeCurrentRange(range, unit); // modifies in-place
10596
10597
		return { duration: duration, unit: unit, range: range };
10598
	},
10599
10600
10601
	getFallbackDuration: function() {
10602
		return moment.duration({ days: 1 });
10603
	},
10604
10605
10606
	// If the range has day units or larger, remove times. Otherwise, ensure times.
10607
	normalizeCurrentRange: function(range, unit) {
10608
10609
		if (/^(year|month|week|day)$/.test(unit)) { // whole-days?
10610
			range.start.stripTime();
10611
			range.end.stripTime();
10612
		}
10613
		else { // needs to have a time?
10614
			if (!range.start.hasTime()) {
10615
				range.start.time(0); // give 00:00 time
10616
			}
10617
			if (!range.end.hasTime()) {
10618
				range.end.time(0); // give 00:00 time
10619
			}
10620
		}
10621
	},
10622
10623
10624
	// Mutates the given activeRange to have time values (un-ambiguate)
10625
	// if the minTime or maxTime causes the range to expand.
10626
	// TODO: eventually activeRange should *always* have times.
10627
	adjustActiveRange: function(range, minTime, maxTime) {
10628
		var hasSpecialTimes = false;
10629
10630
		if (this.usesMinMaxTime) {
10631
10632
			if (minTime < 0) {
10633
				range.start.time(0).add(minTime);
10634
				hasSpecialTimes = true;
10635
			}
10636
10637
			if (maxTime > 24 * 60 * 60 * 1000) { // beyond 24 hours?
10638
				range.end.time(maxTime - (24 * 60 * 60 * 1000));
10639
				hasSpecialTimes = true;
10640
			}
10641
10642
			if (hasSpecialTimes) {
10643
				if (!range.start.hasTime()) {
10644
					range.start.time(0);
10645
				}
10646
				if (!range.end.hasTime()) {
10647
					range.end.time(0);
10648
				}
10649
			}
10650
		}
10651
	},
10652
10653
10654
	// Builds the "current" range when it is specified as an explicit duration.
10655
	// `unit` is the already-computed computeGreatestUnit value of duration.
10656
	buildRangeFromDuration: function(date, direction, duration, unit) {
10657
		var alignment = this.opt('dateAlignment');
10658
		var start = date.clone();
10659
		var end;
10660
		var dateIncrementInput;
10661
		var dateIncrementDuration;
10662
10663
		// if the view displays a single day or smaller
10664
		if (duration.as('days') <= 1) {
10665
			if (this.isHiddenDay(start)) {
10666
				start = this.skipHiddenDays(start, direction);
10667
				start.startOf('day');
10668
			}
10669
		}
10670
10671
		// compute what the alignment should be
10672
		if (!alignment) {
10673
			dateIncrementInput = this.opt('dateIncrement');
10674
10675
			if (dateIncrementInput) {
10676
				dateIncrementDuration = moment.duration(dateIncrementInput);
10677
10678
				// use the smaller of the two units
10679
				if (dateIncrementDuration < duration) {
10680
					alignment = computeDurationGreatestUnit(dateIncrementDuration, dateIncrementInput);
10681
				}
10682
				else {
10683
					alignment = unit;
10684
				}
10685
			}
10686
			else {
10687
				alignment = unit;
10688
			}
10689
		}
10690
10691
		start.startOf(alignment);
10692
		end = start.clone().add(duration);
10693
10694
		return { start: start, end: end };
10695
	},
10696
10697
10698
	// Builds the "current" range when a dayCount is specified.
10699
	buildRangeFromDayCount: function(date, direction, dayCount) {
10700
		var customAlignment = this.opt('dateAlignment');
10701
		var runningCount = 0;
10702
		var start = date.clone();
10703
		var end;
10704
10705
		if (customAlignment) {
10706
			start.startOf(customAlignment);
10707
		}
10708
10709
		start.startOf('day');
10710
		start = this.skipHiddenDays(start, direction);
10711
10712
		end = start.clone();
10713
		do {
10714
			end.add(1, 'day');
10715
			if (!this.isHiddenDay(end)) {
10716
				runningCount++;
10717
			}
10718
		} while (runningCount < dayCount);
10719
10720
		return { start: start, end: end };
10721
	},
10722
10723
10724
	// Builds a normalized range object for the "visible" range,
10725
	// which is a way to define the currentRange and activeRange at the same time.
10726
	buildCustomVisibleRange: function(date) {
10727
		var visibleRange = this.getRangeOption(
10728
			'visibleRange',
10729
			this.calendar.moment(date) // correct zone. also generates new obj that avoids mutations
10730
		);
10731
10732
		if (visibleRange && (!visibleRange.start || !visibleRange.end)) {
10733
			return null;
10734
		}
10735
10736
		return visibleRange;
10737
	},
10738
10739
10740
	// Computes the range that will represent the element/cells for *rendering*,
10741
	// but which may have voided days/times.
10742
	buildRenderRange: function(currentRange, currentRangeUnit) {
10743
		// cut off days in the currentRange that are hidden
10744
		return this.trimHiddenDays(currentRange);
10745
	},
10746
10747
10748
	// Compute the duration value that should be added/substracted to the current date
10749
	// when a prev/next operation happens.
10750
	buildDateIncrement: function(fallback) {
10751
		var dateIncrementInput = this.opt('dateIncrement');
10752
		var customAlignment;
10753
10754
		if (dateIncrementInput) {
10755
			return moment.duration(dateIncrementInput);
10756
		}
10757
		else if ((customAlignment = this.opt('dateAlignment'))) {
10758
			return moment.duration(1, customAlignment);
10759
		}
10760
		else if (fallback) {
10761
			return fallback;
10762
		}
10763
		else {
10764
			return moment.duration({ days: 1 });
10765
		}
10766
	},
10767
10768
10769
	// Remove days from the beginning and end of the range that are computed as hidden.
10770
	trimHiddenDays: function(inputRange) {
10771
		return {
10772
			start: this.skipHiddenDays(inputRange.start),
10773
			end: this.skipHiddenDays(inputRange.end, -1, true) // exclusively move backwards
10774
		};
10775
	},
10776
10777
10778
	// Compute the number of the give units in the "current" range.
10779
	// Will return a floating-point number. Won't round.
10780
	currentRangeAs: function(unit) {
10781
		var currentRange = this.currentRange;
10782
		return currentRange.end.diff(currentRange.start, unit, true);
10783
	},
10784
10785
10786
	// Arguments after name will be forwarded to a hypothetical function value
10787
	// WARNING: passed-in arguments will be given to generator functions as-is and can cause side-effects.
10788
	// Always clone your objects if you fear mutation.
10789
	getRangeOption: function(name) {
10790
		var val = this.opt(name);
10791
10792
		if (typeof val === 'function') {
10793
			val = val.apply(
10794
				null,
10795
				Array.prototype.slice.call(arguments, 1)
10796
			);
10797
		}
10798
10799
		if (val) {
10800
			return this.calendar.parseRange(val);
10801
		}
10802
	},
10803
10804
10805
	/* Hidden Days
10806
	------------------------------------------------------------------------------------------------------------------*/
10807
10808
10809
	// Initializes internal variables related to calculating hidden days-of-week
10810
	initHiddenDays: function() {
10811
		var hiddenDays = this.opt('hiddenDays') || []; // array of day-of-week indices that are hidden
10812
		var isHiddenDayHash = []; // is the day-of-week hidden? (hash with day-of-week-index -> bool)
10813
		var dayCnt = 0;
10814
		var i;
10815
10816
		if (this.opt('weekends') === false) {
10817
			hiddenDays.push(0, 6); // 0=sunday, 6=saturday
10818
		}
10819
10820
		for (i = 0; i < 7; i++) {
10821
			if (
10822
				!(isHiddenDayHash[i] = $.inArray(i, hiddenDays) !== -1)
10823
			) {
10824
				dayCnt++;
10825
			}
10826
		}
10827
10828
		if (!dayCnt) {
10829
			throw 'invalid hiddenDays'; // all days were hidden? bad.
10830
		}
10831
10832
		this.isHiddenDayHash = isHiddenDayHash;
10833
	},
10834
10835
10836
	// Is the current day hidden?
10837
	// `day` is a day-of-week index (0-6), or a Moment
10838
	isHiddenDay: function(day) {
10839
		if (moment.isMoment(day)) {
10840
			day = day.day();
10841
		}
10842
		return this.isHiddenDayHash[day];
10843
	},
10844
10845
10846
	// Incrementing the current day until it is no longer a hidden day, returning a copy.
10847
	// DOES NOT CONSIDER validRange!
10848
	// If the initial value of `date` is not a hidden day, don't do anything.
10849
	// Pass `isExclusive` as `true` if you are dealing with an end date.
10850
	// `inc` defaults to `1` (increment one day forward each time)
10851
	skipHiddenDays: function(date, inc, isExclusive) {
10852
		var out = date.clone();
10853
		inc = inc || 1;
10854
		while (
10855
			this.isHiddenDayHash[(out.day() + (isExclusive ? inc : 0) + 7) % 7]
10856
		) {
10857
			out.add(inc, 'days');
10858
		}
10859
		return out;
10860
	}
10861
10862
});
10863
10864
;;
10865
10866
/*
10867
Embodies a div that has potential scrollbars
10868
*/
10869
var Scroller = FC.Scroller = Class.extend({
10870
10871
	el: null, // the guaranteed outer element
10872
	scrollEl: null, // the element with the scrollbars
10873
	overflowX: null,
10874
	overflowY: null,
10875
10876
10877
	constructor: function(options) {
10878
		options = options || {};
10879
		this.overflowX = options.overflowX || options.overflow || 'auto';
10880
		this.overflowY = options.overflowY || options.overflow || 'auto';
10881
	},
10882
10883
10884
	render: function() {
10885
		this.el = this.renderEl();
10886
		this.applyOverflow();
10887
	},
10888
10889
10890
	renderEl: function() {
10891
		return (this.scrollEl = $('<div class="fc-scroller"></div>'));
10892
	},
10893
10894
10895
	// sets to natural height, unlocks overflow
10896
	clear: function() {
10897
		this.setHeight('auto');
10898
		this.applyOverflow();
10899
	},
10900
10901
10902
	destroy: function() {
10903
		this.el.remove();
10904
	},
10905
10906
10907
	// Overflow
10908
	// -----------------------------------------------------------------------------------------------------------------
10909
10910
10911
	applyOverflow: function() {
10912
		this.scrollEl.css({
10913
			'overflow-x': this.overflowX,
10914
			'overflow-y': this.overflowY
10915
		});
10916
	},
10917
10918
10919
	// Causes any 'auto' overflow values to resolves to 'scroll' or 'hidden'.
10920
	// Useful for preserving scrollbar widths regardless of future resizes.
10921
	// Can pass in scrollbarWidths for optimization.
10922
	lockOverflow: function(scrollbarWidths) {
10923
		var overflowX = this.overflowX;
10924
		var overflowY = this.overflowY;
10925
10926
		scrollbarWidths = scrollbarWidths || this.getScrollbarWidths();
10927
10928
		if (overflowX === 'auto') {
10929
			overflowX = (
10930
					scrollbarWidths.top || scrollbarWidths.bottom || // horizontal scrollbars?
10931
					// OR scrolling pane with massless scrollbars?
10932
					this.scrollEl[0].scrollWidth - 1 > this.scrollEl[0].clientWidth
10933
						// subtract 1 because of IE off-by-one issue
10934
				) ? 'scroll' : 'hidden';
10935
		}
10936
10937
		if (overflowY === 'auto') {
10938
			overflowY = (
10939
					scrollbarWidths.left || scrollbarWidths.right || // vertical scrollbars?
10940
					// OR scrolling pane with massless scrollbars?
10941
					this.scrollEl[0].scrollHeight - 1 > this.scrollEl[0].clientHeight
10942
						// subtract 1 because of IE off-by-one issue
10943
				) ? 'scroll' : 'hidden';
10944
		}
10945
10946
		this.scrollEl.css({ 'overflow-x': overflowX, 'overflow-y': overflowY });
10947
	},
10948
10949
10950
	// Getters / Setters
10951
	// -----------------------------------------------------------------------------------------------------------------
10952
10953
10954
	setHeight: function(height) {
10955
		this.scrollEl.height(height);
10956
	},
10957
10958
10959
	getScrollTop: function() {
10960
		return this.scrollEl.scrollTop();
10961
	},
10962
10963
10964
	setScrollTop: function(top) {
10965
		this.scrollEl.scrollTop(top);
10966
	},
10967
10968
10969
	getClientWidth: function() {
10970
		return this.scrollEl[0].clientWidth;
10971
	},
10972
10973
10974
	getClientHeight: function() {
10975
		return this.scrollEl[0].clientHeight;
10976
	},
10977
10978
10979
	getScrollbarWidths: function() {
10980
		return getScrollbarWidths(this.scrollEl);
10981
	}
10982
10983
});
10984
10985
;;
10986
function Iterator(items) {
10987
    this.items = items || [];
10988
}
10989
10990
10991
/* Calls a method on every item passing the arguments through */
10992
Iterator.prototype.proxyCall = function(methodName) {
10993
    var args = Array.prototype.slice.call(arguments, 1);
10994
    var results = [];
10995
10996
    this.items.forEach(function(item) {
10997
        results.push(item[methodName].apply(item, args));
10998
    });
10999
11000
    return results;
11001
};
11002
11003
;;
11004
11005
/* Toolbar with buttons and title
11006
----------------------------------------------------------------------------------------------------------------------*/
11007
11008
function Toolbar(calendar, toolbarOptions) {
11009
	var t = this;
11010
11011
	// exports
11012
	t.setToolbarOptions = setToolbarOptions;
11013
	t.render = render;
11014
	t.removeElement = removeElement;
11015
	t.updateTitle = updateTitle;
11016
	t.activateButton = activateButton;
11017
	t.deactivateButton = deactivateButton;
11018
	t.disableButton = disableButton;
11019
	t.enableButton = enableButton;
11020
	t.getViewsWithButtons = getViewsWithButtons;
11021
	t.el = null; // mirrors local `el`
11022
11023
	// locals
11024
	var el;
11025
	var viewsWithButtons = [];
11026
	var tm;
11027
11028
	// method to update toolbar-specific options, not calendar-wide options
11029
	function setToolbarOptions(newToolbarOptions) {
11030
		toolbarOptions = newToolbarOptions;
11031
	}
11032
11033
	// can be called repeatedly and will rerender
11034
	function render() {
11035
		var sections = toolbarOptions.layout;
11036
11037
		tm = calendar.opt('theme') ? 'ui' : 'fc';
11038
11039
		if (sections) {
11040
			if (!el) {
11041
				el = this.el = $("<div class='fc-toolbar "+ toolbarOptions.extraClasses + "'/>");
11042
			}
11043
			else {
11044
				el.empty();
11045
			}
11046
			el.append(renderSection('left'))
11047
				.append(renderSection('right'))
11048
				.append(renderSection('center'))
11049
				.append('<div class="fc-clear"/>');
11050
		}
11051
		else {
11052
			removeElement();
11053
		}
11054
	}
11055
11056
11057
	function removeElement() {
11058
		if (el) {
11059
			el.remove();
11060
			el = t.el = null;
11061
		}
11062
	}
11063
11064
11065
	function renderSection(position) {
11066
		var sectionEl = $('<div class="fc-' + position + '"/>');
11067
		var buttonStr = toolbarOptions.layout[position];
11068
		var calendarCustomButtons = calendar.opt('customButtons') || {};
11069
		var calendarButtonText = calendar.opt('buttonText') || {};
11070
11071
		if (buttonStr) {
11072
			$.each(buttonStr.split(' '), function(i) {
11073
				var groupChildren = $();
11074
				var isOnlyButtons = true;
11075
				var groupEl;
11076
11077
				$.each(this.split(','), function(j, buttonName) {
11078
					var customButtonProps;
11079
					var viewSpec;
11080
					var buttonClick;
11081
					var overrideText; // text explicitly set by calendar's constructor options. overcomes icons
11082
					var defaultText;
11083
					var themeIcon;
11084
					var normalIcon;
11085
					var innerHtml;
11086
					var classes;
11087
					var button; // the element
11088
11089
					if (buttonName == 'title') {
11090
						groupChildren = groupChildren.add($('<h2>&nbsp;</h2>')); // we always want it to take up height
11091
						isOnlyButtons = false;
11092
					}
11093
					else {
11094
						if ((customButtonProps = calendarCustomButtons[buttonName])) {
11095
							buttonClick = function(ev) {
11096
								if (customButtonProps.click) {
11097
									customButtonProps.click.call(button[0], ev);
11098
								}
11099
							};
11100
							overrideText = ''; // icons will override text
11101
							defaultText = customButtonProps.text;
11102
						}
11103
						else if ((viewSpec = calendar.getViewSpec(buttonName))) {
11104
							buttonClick = function() {
11105
								calendar.changeView(buttonName);
11106
							};
11107
							viewsWithButtons.push(buttonName);
11108
							overrideText = viewSpec.buttonTextOverride;
11109
							defaultText = viewSpec.buttonTextDefault;
11110
						}
11111
						else if (calendar[buttonName]) { // a calendar method
11112
							buttonClick = function() {
11113
								calendar[buttonName]();
11114
							};
11115
							overrideText = (calendar.overrides.buttonText || {})[buttonName];
11116
							defaultText = calendarButtonText[buttonName]; // everything else is considered default
11117
						}
11118
11119
						if (buttonClick) {
11120
11121
							themeIcon =
11122
								customButtonProps ?
11123
									customButtonProps.themeIcon :
11124
									calendar.opt('themeButtonIcons')[buttonName];
11125
11126
							normalIcon =
11127
								customButtonProps ?
11128
									customButtonProps.icon :
11129
									calendar.opt('buttonIcons')[buttonName];
11130
11131
							if (overrideText) {
11132
								innerHtml = htmlEscape(overrideText);
11133
							}
11134
							else if (themeIcon && calendar.opt('theme')) {
11135
								innerHtml = "<span class='ui-icon ui-icon-" + themeIcon + "'></span>";
11136
							}
11137
							else if (normalIcon && !calendar.opt('theme')) {
11138
								innerHtml = "<span class='fc-icon fc-icon-" + normalIcon + "'></span>";
11139
							}
11140
							else {
11141
								innerHtml = htmlEscape(defaultText);
11142
							}
11143
11144
							classes = [
11145
								'fc-' + buttonName + '-button',
11146
								tm + '-button',
11147
								tm + '-state-default'
11148
							];
11149
11150
							button = $( // type="button" so that it doesn't submit a form
11151
								'<button type="button" class="' + classes.join(' ') + '">' +
11152
									innerHtml +
11153
								'</button>'
11154
								)
11155
								.click(function(ev) {
11156
									// don't process clicks for disabled buttons
11157
									if (!button.hasClass(tm + '-state-disabled')) {
11158
11159
										buttonClick(ev);
11160
11161
										// after the click action, if the button becomes the "active" tab, or disabled,
11162
										// it should never have a hover class, so remove it now.
11163
										if (
11164
											button.hasClass(tm + '-state-active') ||
11165
											button.hasClass(tm + '-state-disabled')
11166
										) {
11167
											button.removeClass(tm + '-state-hover');
11168
										}
11169
									}
11170
								})
11171
								.mousedown(function() {
11172
									// the *down* effect (mouse pressed in).
11173
									// only on buttons that are not the "active" tab, or disabled
11174
									button
11175
										.not('.' + tm + '-state-active')
11176
										.not('.' + tm + '-state-disabled')
11177
										.addClass(tm + '-state-down');
11178
								})
11179
								.mouseup(function() {
11180
									// undo the *down* effect
11181
									button.removeClass(tm + '-state-down');
11182
								})
11183
								.hover(
11184
									function() {
11185
										// the *hover* effect.
11186
										// only on buttons that are not the "active" tab, or disabled
11187
										button
11188
											.not('.' + tm + '-state-active')
11189
											.not('.' + tm + '-state-disabled')
11190
											.addClass(tm + '-state-hover');
11191
									},
11192
									function() {
11193
										// undo the *hover* effect
11194
										button
11195
											.removeClass(tm + '-state-hover')
11196
											.removeClass(tm + '-state-down'); // if mouseleave happens before mouseup
11197
									}
11198
								);
11199
11200
							groupChildren = groupChildren.add(button);
11201
						}
11202
					}
11203
				});
11204
11205
				if (isOnlyButtons) {
11206
					groupChildren
11207
						.first().addClass(tm + '-corner-left').end()
11208
						.last().addClass(tm + '-corner-right').end();
11209
				}
11210
11211
				if (groupChildren.length > 1) {
11212
					groupEl = $('<div/>');
11213
					if (isOnlyButtons) {
11214
						groupEl.addClass('fc-button-group');
11215
					}
11216
					groupEl.append(groupChildren);
11217
					sectionEl.append(groupEl);
11218
				}
11219
				else {
11220
					sectionEl.append(groupChildren); // 1 or 0 children
11221
				}
11222
			});
11223
		}
11224
11225
		return sectionEl;
11226
	}
11227
11228
11229
	function updateTitle(text) {
11230
		if (el) {
11231
			el.find('h2').text(text);
11232
		}
11233
	}
11234
11235
11236
	function activateButton(buttonName) {
11237
		if (el) {
11238
			el.find('.fc-' + buttonName + '-button')
11239
				.addClass(tm + '-state-active');
11240
		}
11241
	}
11242
11243
11244
	function deactivateButton(buttonName) {
11245
		if (el) {
11246
			el.find('.fc-' + buttonName + '-button')
11247
				.removeClass(tm + '-state-active');
11248
		}
11249
	}
11250
11251
11252
	function disableButton(buttonName) {
11253
		if (el) {
11254
			el.find('.fc-' + buttonName + '-button')
11255
				.prop('disabled', true)
11256
				.addClass(tm + '-state-disabled');
11257
		}
11258
	}
11259
11260
11261
	function enableButton(buttonName) {
11262
		if (el) {
11263
			el.find('.fc-' + buttonName + '-button')
11264
				.prop('disabled', false)
11265
				.removeClass(tm + '-state-disabled');
11266
		}
11267
	}
11268
11269
11270
	function getViewsWithButtons() {
11271
		return viewsWithButtons;
11272
	}
11273
11274
}
11275
11276
;;
11277
11278
var Calendar = FC.Calendar = Class.extend(EmitterMixin, {
11279
11280
	view: null, // current View object
11281
	viewsByType: null, // holds all instantiated view instances, current or not
11282
	currentDate: null, // unzoned moment. private (public API should use getDate instead)
11283
	loadingLevel: 0, // number of simultaneous loading tasks
11284
11285
11286
	constructor: function(el, overrides) {
11287
11288
		// declare the current calendar instance relies on GlobalEmitter. needed for garbage collection.
11289
		// unneeded() is called in destroy.
11290
		GlobalEmitter.needed();
11291
11292
		this.el = el;
11293
		this.viewsByType = {};
11294
		this.viewSpecCache = {};
11295
11296
		this.initOptionsInternals(overrides);
11297
		this.initMomentInternals(); // needs to happen after options hash initialized
11298
		this.initCurrentDate();
11299
11300
		EventManager.call(this); // needs options immediately
11301
		this.initialize();
11302
	},
11303
11304
11305
	// Subclasses can override this for initialization logic after the constructor has been called
11306
	initialize: function() {
11307
	},
11308
11309
11310
	// Public API
11311
	// -----------------------------------------------------------------------------------------------------------------
11312
11313
11314
	getCalendar: function() {
11315
		return this;
11316
	},
11317
11318
11319
	getView: function() {
11320
		return this.view;
11321
	},
11322
11323
11324
	publiclyTrigger: function(name, thisObj) {
11325
		var args = Array.prototype.slice.call(arguments, 2);
11326
		var optHandler = this.opt(name);
11327
11328
		thisObj = thisObj || this.el[0];
11329
		this.triggerWith(name, thisObj, args); // Emitter's method
11330
11331
		if (optHandler) {
11332
			return optHandler.apply(thisObj, args);
11333
		}
11334
	},
11335
11336
11337
	// View
11338
	// -----------------------------------------------------------------------------------------------------------------
11339
11340
11341
	// Given a view name for a custom view or a standard view, creates a ready-to-go View object
11342
	instantiateView: function(viewType) {
11343
		var spec = this.getViewSpec(viewType);
11344
11345
		return new spec['class'](this, spec);
11346
	},
11347
11348
11349
	// Returns a boolean about whether the view is okay to instantiate at some point
11350
	isValidViewType: function(viewType) {
11351
		return Boolean(this.getViewSpec(viewType));
11352
	},
11353
11354
11355
	changeView: function(viewName, dateOrRange) {
11356
11357
		if (dateOrRange) {
11358
11359
			if (dateOrRange.start && dateOrRange.end) { // a range
11360
				this.recordOptionOverrides({ // will not rerender
11361
					visibleRange: dateOrRange
11362
				});
11363
			}
11364
			else { // a date
11365
				this.currentDate = this.moment(dateOrRange).stripZone(); // just like gotoDate
11366
			}
11367
		}
11368
11369
		this.renderView(viewName);
11370
	},
11371
11372
11373
	// Forces navigation to a view for the given date.
11374
	// `viewType` can be a specific view name or a generic one like "week" or "day".
11375
	zoomTo: function(newDate, viewType) {
11376
		var spec;
11377
11378
		viewType = viewType || 'day'; // day is default zoom
11379
		spec = this.getViewSpec(viewType) || this.getUnitViewSpec(viewType);
11380
11381
		this.currentDate = newDate.clone();
11382
		this.renderView(spec ? spec.type : null);
11383
	},
11384
11385
11386
	// Current Date
11387
	// -----------------------------------------------------------------------------------------------------------------
11388
11389
11390
	initCurrentDate: function() {
11391
		var defaultDateInput = this.opt('defaultDate');
11392
11393
		// compute the initial ambig-timezone date
11394
		if (defaultDateInput != null) {
11395
			this.currentDate = this.moment(defaultDateInput).stripZone();
11396
		}
11397
		else {
11398
			this.currentDate = this.getNow(); // getNow already returns unzoned
11399
		}
11400
	},
11401
11402
11403
	prev: function() {
11404
		var prevInfo = this.view.buildPrevDateProfile(this.currentDate);
11405
11406
		if (prevInfo.isValid) {
11407
			this.currentDate = prevInfo.date;
11408
			this.renderView();
11409
		}
11410
	},
11411
11412
11413
	next: function() {
11414
		var nextInfo = this.view.buildNextDateProfile(this.currentDate);
11415
11416
		if (nextInfo.isValid) {
11417
			this.currentDate = nextInfo.date;
11418
			this.renderView();
11419
		}
11420
	},
11421
11422
11423
	prevYear: function() {
11424
		this.currentDate.add(-1, 'years');
11425
		this.renderView();
11426
	},
11427
11428
11429
	nextYear: function() {
11430
		this.currentDate.add(1, 'years');
11431
		this.renderView();
11432
	},
11433
11434
11435
	today: function() {
11436
		this.currentDate = this.getNow(); // should deny like prev/next?
11437
		this.renderView();
11438
	},
11439
11440
11441
	gotoDate: function(zonedDateInput) {
11442
		this.currentDate = this.moment(zonedDateInput).stripZone();
11443
		this.renderView();
11444
	},
11445
11446
11447
	incrementDate: function(delta) {
11448
		this.currentDate.add(moment.duration(delta));
11449
		this.renderView();
11450
	},
11451
11452
11453
	// for external API
11454
	getDate: function() {
11455
		return this.applyTimezone(this.currentDate); // infuse the calendar's timezone
11456
	},
11457
11458
11459
	// Loading Triggering
11460
	// -----------------------------------------------------------------------------------------------------------------
11461
11462
11463
	// Should be called when any type of async data fetching begins
11464
	pushLoading: function() {
11465
		if (!(this.loadingLevel++)) {
11466
			this.publiclyTrigger('loading', null, true, this.view);
11467
		}
11468
	},
11469
11470
11471
	// Should be called when any type of async data fetching completes
11472
	popLoading: function() {
11473
		if (!(--this.loadingLevel)) {
11474
			this.publiclyTrigger('loading', null, false, this.view);
11475
		}
11476
	},
11477
11478
11479
	// Selection
11480
	// -----------------------------------------------------------------------------------------------------------------
11481
11482
11483
	// this public method receives start/end dates in any format, with any timezone
11484
	select: function(zonedStartInput, zonedEndInput) {
11485
		this.view.select(
11486
			this.buildSelectSpan.apply(this, arguments)
11487
		);
11488
	},
11489
11490
11491
	unselect: function() { // safe to be called before renderView
11492
		if (this.view) {
11493
			this.view.unselect();
11494
		}
11495
	},
11496
11497
11498
	// Given arguments to the select method in the API, returns a span (unzoned start/end and other info)
11499
	buildSelectSpan: function(zonedStartInput, zonedEndInput) {
11500
		var start = this.moment(zonedStartInput).stripZone();
11501
		var end;
11502
11503
		if (zonedEndInput) {
11504
			end = this.moment(zonedEndInput).stripZone();
11505
		}
11506
		else if (start.hasTime()) {
11507
			end = start.clone().add(this.defaultTimedEventDuration);
11508
		}
11509
		else {
11510
			end = start.clone().add(this.defaultAllDayEventDuration);
11511
		}
11512
11513
		return { start: start, end: end };
11514
	},
11515
11516
11517
	// Misc
11518
	// -----------------------------------------------------------------------------------------------------------------
11519
11520
11521
	// will return `null` if invalid range
11522
	parseRange: function(rangeInput) {
11523
		var start = null;
11524
		var end = null;
11525
11526
		if (rangeInput.start) {
11527
			start = this.moment(rangeInput.start).stripZone();
11528
		}
11529
11530
		if (rangeInput.end) {
11531
			end = this.moment(rangeInput.end).stripZone();
11532
		}
11533
11534
		if (!start && !end) {
11535
			return null;
11536
		}
11537
11538
		if (start && end && end.isBefore(start)) {
11539
			return null;
11540
		}
11541
11542
		return { start: start, end: end };
11543
	},
11544
11545
11546
	rerenderEvents: function() { // API method. destroys old events if previously rendered.
11547
		if (this.elementVisible()) {
11548
			this.reportEventChange(); // will re-trasmit events to the view, causing a rerender
11549
		}
11550
	}
11551
11552
});
11553
11554
;;
11555
/*
11556
Options binding/triggering system.
11557
*/
11558
Calendar.mixin({
11559
11560
	dirDefaults: null, // option defaults related to LTR or RTL
11561
	localeDefaults: null, // option defaults related to current locale
11562
	overrides: null, // option overrides given to the fullCalendar constructor
11563
	dynamicOverrides: null, // options set with dynamic setter method. higher precedence than view overrides.
11564
	optionsModel: null, // all defaults combined with overrides
11565
11566
11567
	initOptionsInternals: function(overrides) {
11568
		this.overrides = $.extend({}, overrides); // make a copy
11569
		this.dynamicOverrides = {};
11570
		this.optionsModel = new Model();
11571
11572
		this.populateOptionsHash();
11573
	},
11574
11575
11576
	// public getter/setter
11577
	option: function(name, value) {
11578
		var newOptionHash;
11579
11580
		if (typeof name === 'string') {
11581
			if (value === undefined) { // getter
11582
				return this.optionsModel.get(name);
11583
			}
11584
			else { // setter for individual option
11585
				newOptionHash = {};
11586
				newOptionHash[name] = value;
11587
				this.setOptions(newOptionHash);
11588
			}
11589
		}
11590
		else if (typeof name === 'object') { // compound setter with object input
11591
			this.setOptions(name);
11592
		}
11593
	},
11594
11595
11596
	// private getter
11597
	opt: function(name) {
11598
		return this.optionsModel.get(name);
11599
	},
11600
11601
11602
	setOptions: function(newOptionHash) {
11603
		var optionCnt = 0;
11604
		var optionName;
11605
11606
		this.recordOptionOverrides(newOptionHash);
11607
11608
		for (optionName in newOptionHash) {
11609
			optionCnt++;
11610
		}
11611
11612
		// special-case handling of single option change.
11613
		// if only one option change, `optionName` will be its name.
11614
		if (optionCnt === 1) {
11615
			if (optionName === 'height' || optionName === 'contentHeight' || optionName === 'aspectRatio') {
11616
				this.updateSize(true); // true = allow recalculation of height
11617
				return;
11618
			}
11619
			else if (optionName === 'defaultDate') {
11620
				return; // can't change date this way. use gotoDate instead
11621
			}
11622
			else if (optionName === 'businessHours') {
11623
				if (this.view) {
11624
					this.view.unrenderBusinessHours();
11625
					this.view.renderBusinessHours();
11626
				}
11627
				return;
11628
			}
11629
			else if (optionName === 'timezone') {
11630
				this.rezoneArrayEventSources();
11631
				this.refetchEvents();
11632
				return;
11633
			}
11634
		}
11635
11636
		// catch-all. rerender the header and footer and rebuild/rerender the current view
11637
		this.renderHeader();
11638
		this.renderFooter();
11639
11640
		// even non-current views will be affected by this option change. do before rerender
11641
		// TODO: detangle
11642
		this.viewsByType = {};
11643
11644
		this.reinitView();
11645
	},
11646
11647
11648
	// Computes the flattened options hash for the calendar and assigns to `this.options`.
11649
	// Assumes this.overrides and this.dynamicOverrides have already been initialized.
11650
	populateOptionsHash: function() {
11651
		var locale, localeDefaults;
11652
		var isRTL, dirDefaults;
11653
		var rawOptions;
11654
11655
		locale = firstDefined( // explicit locale option given?
11656
			this.dynamicOverrides.locale,
11657
			this.overrides.locale
11658
		);
11659
		localeDefaults = localeOptionHash[locale];
11660
		if (!localeDefaults) { // explicit locale option not given or invalid?
11661
			locale = Calendar.defaults.locale;
11662
			localeDefaults = localeOptionHash[locale] || {};
11663
		}
11664
11665
		isRTL = firstDefined( // based on options computed so far, is direction RTL?
11666
			this.dynamicOverrides.isRTL,
11667
			this.overrides.isRTL,
11668
			localeDefaults.isRTL,
11669
			Calendar.defaults.isRTL
11670
		);
11671
		dirDefaults = isRTL ? Calendar.rtlDefaults : {};
11672
11673
		this.dirDefaults = dirDefaults;
11674
		this.localeDefaults = localeDefaults;
11675
11676
		rawOptions = mergeOptions([ // merge defaults and overrides. lowest to highest precedence
11677
			Calendar.defaults, // global defaults
11678
			dirDefaults,
11679
			localeDefaults,
11680
			this.overrides,
11681
			this.dynamicOverrides
11682
		]);
11683
		populateInstanceComputableOptions(rawOptions); // fill in gaps with computed options
11684
11685
		this.optionsModel.reset(rawOptions);
11686
	},
11687
11688
11689
	// stores the new options internally, but does not rerender anything.
11690
	recordOptionOverrides: function(newOptionHash) {
11691
		var optionName;
11692
11693
		for (optionName in newOptionHash) {
11694
			this.dynamicOverrides[optionName] = newOptionHash[optionName];
11695
		}
11696
11697
		this.viewSpecCache = {}; // the dynamic override invalidates the options in this cache, so just clear it
11698
		this.populateOptionsHash(); // this.options needs to be recomputed after the dynamic override
11699
	}
11700
11701
});
11702
11703
;;
11704
11705
Calendar.mixin({
11706
11707
	defaultAllDayEventDuration: null,
11708
	defaultTimedEventDuration: null,
11709
	localeData: null,
11710
11711
11712
	initMomentInternals: function() {
11713
		var _this = this;
11714
11715
		this.defaultAllDayEventDuration = moment.duration(this.opt('defaultAllDayEventDuration'));
11716
		this.defaultTimedEventDuration = moment.duration(this.opt('defaultTimedEventDuration'));
11717
11718
		// Called immediately, and when any of the options change.
11719
		// Happens before any internal objects rebuild or rerender, because this is very core.
11720
		this.optionsModel.watch('buildingMomentLocale', [
11721
			'?locale', '?monthNames', '?monthNamesShort', '?dayNames', '?dayNamesShort',
11722
			'?firstDay', '?weekNumberCalculation'
11723
		], function(opts) {
11724
			var weekNumberCalculation = opts.weekNumberCalculation;
11725
			var firstDay = opts.firstDay;
11726
			var _week;
11727
11728
			// normalize
11729
			if (weekNumberCalculation === 'iso') {
11730
				weekNumberCalculation = 'ISO'; // normalize
11731
			}
11732
11733
			var localeData = createObject( // make a cheap copy
11734
				getMomentLocaleData(opts.locale) // will fall back to en
11735
			);
11736
11737
			if (opts.monthNames) {
11738
				localeData._months = opts.monthNames;
11739
			}
11740
			if (opts.monthNamesShort) {
11741
				localeData._monthsShort = opts.monthNamesShort;
11742
			}
11743
			if (opts.dayNames) {
11744
				localeData._weekdays = opts.dayNames;
11745
			}
11746
			if (opts.dayNamesShort) {
11747
				localeData._weekdaysShort = opts.dayNamesShort;
11748
			}
11749
11750
			if (firstDay == null && weekNumberCalculation === 'ISO') {
11751
				firstDay = 1;
11752
			}
11753
			if (firstDay != null) {
11754
				_week = createObject(localeData._week); // _week: { dow: # }
11755
				_week.dow = firstDay;
11756
				localeData._week = _week;
11757
			}
11758
11759
			if ( // whitelist certain kinds of input
11760
				weekNumberCalculation === 'ISO' ||
11761
				weekNumberCalculation === 'local' ||
11762
				typeof weekNumberCalculation === 'function'
11763
			) {
11764
				localeData._fullCalendar_weekCalc = weekNumberCalculation; // moment-ext will know what to do with it
11765
			}
11766
11767
			_this.localeData = localeData;
11768
11769
			// If the internal current date object already exists, move to new locale.
11770
			// We do NOT need to do this technique for event dates, because this happens when converting to "segments".
11771
			if (_this.currentDate) {
11772
				_this.localizeMoment(_this.currentDate); // sets to localeData
11773
			}
11774
		});
11775
	},
11776
11777
11778
	// Builds a moment using the settings of the current calendar: timezone and locale.
11779
	// Accepts anything the vanilla moment() constructor accepts.
11780
	moment: function() {
11781
		var mom;
11782
11783
		if (this.opt('timezone') === 'local') {
11784
			mom = FC.moment.apply(null, arguments);
11785
11786
			// Force the moment to be local, because FC.moment doesn't guarantee it.
11787
			if (mom.hasTime()) { // don't give ambiguously-timed moments a local zone
11788
				mom.local();
11789
			}
11790
		}
11791
		else if (this.opt('timezone') === 'UTC') {
11792
			mom = FC.moment.utc.apply(null, arguments); // process as UTC
11793
		}
11794
		else {
11795
			mom = FC.moment.parseZone.apply(null, arguments); // let the input decide the zone
11796
		}
11797
11798
		this.localizeMoment(mom); // TODO
11799
11800
		return mom;
11801
	},
11802
11803
11804
	// Updates the given moment's locale settings to the current calendar locale settings.
11805
	localizeMoment: function(mom) {
11806
		mom._locale = this.localeData;
11807
	},
11808
11809
11810
	// Returns a boolean about whether or not the calendar knows how to calculate
11811
	// the timezone offset of arbitrary dates in the current timezone.
11812
	getIsAmbigTimezone: function() {
11813
		return this.opt('timezone') !== 'local' && this.opt('timezone') !== 'UTC';
11814
	},
11815
11816
11817
	// Returns a copy of the given date in the current timezone. Has no effect on dates without times.
11818
	applyTimezone: function(date) {
11819
		if (!date.hasTime()) {
11820
			return date.clone();
11821
		}
11822
11823
		var zonedDate = this.moment(date.toArray());
11824
		var timeAdjust = date.time() - zonedDate.time();
11825
		var adjustedZonedDate;
11826
11827
		// Safari sometimes has problems with this coersion when near DST. Adjust if necessary. (bug #2396)
11828
		if (timeAdjust) { // is the time result different than expected?
11829
			adjustedZonedDate = zonedDate.clone().add(timeAdjust); // add milliseconds
11830
			if (date.time() - adjustedZonedDate.time() === 0) { // does it match perfectly now?
11831
				zonedDate = adjustedZonedDate;
11832
			}
11833
		}
11834
11835
		return zonedDate;
11836
	},
11837
11838
11839
	// Returns a moment for the current date, as defined by the client's computer or from the `now` option.
11840
	// Will return an moment with an ambiguous timezone.
11841
	getNow: function() {
11842
		var now = this.opt('now');
11843
		if (typeof now === 'function') {
11844
			now = now();
11845
		}
11846
		return this.moment(now).stripZone();
11847
	},
11848
11849
11850
	// Produces a human-readable string for the given duration.
11851
	// Side-effect: changes the locale of the given duration.
11852
	humanizeDuration: function(duration) {
11853
		return duration.locale(this.opt('locale')).humanize();
11854
	},
11855
11856
11857
11858
	// Event-Specific Date Utilities. TODO: move
11859
	// -----------------------------------------------------------------------------------------------------------------
11860
11861
11862
	// Get an event's normalized end date. If not present, calculate it from the defaults.
11863
	getEventEnd: function(event) {
11864
		if (event.end) {
11865
			return event.end.clone();
11866
		}
11867
		else {
11868
			return this.getDefaultEventEnd(event.allDay, event.start);
11869
		}
11870
	},
11871
11872
11873
	// Given an event's allDay status and start date, return what its fallback end date should be.
11874
	// TODO: rename to computeDefaultEventEnd
11875
	getDefaultEventEnd: function(allDay, zonedStart) {
11876
		var end = zonedStart.clone();
11877
11878
		if (allDay) {
11879
			end.stripTime().add(this.defaultAllDayEventDuration);
11880
		}
11881
		else {
11882
			end.add(this.defaultTimedEventDuration);
11883
		}
11884
11885
		if (this.getIsAmbigTimezone()) {
11886
			end.stripZone(); // we don't know what the tzo should be
11887
		}
11888
11889
		return end;
11890
	}
11891
11892
});
11893
11894
;;
11895
11896
Calendar.mixin({
11897
11898
	viewSpecCache: null, // cache of view definitions (initialized in Calendar.js)
11899
11900
11901
	// Gets information about how to create a view. Will use a cache.
11902
	getViewSpec: function(viewType) {
11903
		var cache = this.viewSpecCache;
11904
11905
		return cache[viewType] || (cache[viewType] = this.buildViewSpec(viewType));
11906
	},
11907
11908
11909
	// Given a duration singular unit, like "week" or "day", finds a matching view spec.
11910
	// Preference is given to views that have corresponding buttons.
11911
	getUnitViewSpec: function(unit) {
11912
		var viewTypes;
11913
		var i;
11914
		var spec;
11915
11916
		if ($.inArray(unit, unitsDesc) != -1) {
11917
11918
			// put views that have buttons first. there will be duplicates, but oh well
11919
			viewTypes = this.header.getViewsWithButtons(); // TODO: include footer as well?
11920
			$.each(FC.views, function(viewType) { // all views
11921
				viewTypes.push(viewType);
11922
			});
11923
11924
			for (i = 0; i < viewTypes.length; i++) {
11925
				spec = this.getViewSpec(viewTypes[i]);
11926
				if (spec) {
11927
					if (spec.singleUnit == unit) {
11928
						return spec;
11929
					}
11930
				}
11931
			}
11932
		}
11933
	},
11934
11935
11936
	// Builds an object with information on how to create a given view
11937
	buildViewSpec: function(requestedViewType) {
11938
		var viewOverrides = this.overrides.views || {};
11939
		var specChain = []; // for the view. lowest to highest priority
11940
		var defaultsChain = []; // for the view. lowest to highest priority
11941
		var overridesChain = []; // for the view. lowest to highest priority
11942
		var viewType = requestedViewType;
11943
		var spec; // for the view
11944
		var overrides; // for the view
11945
		var durationInput;
11946
		var duration;
11947
		var unit;
11948
11949
		// iterate from the specific view definition to a more general one until we hit an actual View class
11950
		while (viewType) {
11951
			spec = fcViews[viewType];
11952
			overrides = viewOverrides[viewType];
11953
			viewType = null; // clear. might repopulate for another iteration
11954
11955
			if (typeof spec === 'function') { // TODO: deprecate
11956
				spec = { 'class': spec };
11957
			}
11958
11959
			if (spec) {
11960
				specChain.unshift(spec);
11961
				defaultsChain.unshift(spec.defaults || {});
11962
				durationInput = durationInput || spec.duration;
11963
				viewType = viewType || spec.type;
11964
			}
11965
11966
			if (overrides) {
11967
				overridesChain.unshift(overrides); // view-specific option hashes have options at zero-level
11968
				durationInput = durationInput || overrides.duration;
11969
				viewType = viewType || overrides.type;
11970
			}
11971
		}
11972
11973
		spec = mergeProps(specChain);
11974
		spec.type = requestedViewType;
11975
		if (!spec['class']) {
11976
			return false;
11977
		}
11978
11979
		// fall back to top-level `duration` option
11980
		durationInput = durationInput ||
11981
			this.dynamicOverrides.duration ||
11982
			this.overrides.duration;
11983
11984
		if (durationInput) {
11985
			duration = moment.duration(durationInput);
11986
11987
			if (duration.valueOf()) { // valid?
11988
11989
				unit = computeDurationGreatestUnit(duration, durationInput);
11990
11991
				spec.duration = duration;
11992
				spec.durationUnit = unit;
11993
11994
				// view is a single-unit duration, like "week" or "day"
11995
				// incorporate options for this. lowest priority
11996
				if (duration.as(unit) === 1) {
11997
					spec.singleUnit = unit;
11998
					overridesChain.unshift(viewOverrides[unit] || {});
11999
				}
12000
			}
12001
		}
12002
12003
		spec.defaults = mergeOptions(defaultsChain);
12004
		spec.overrides = mergeOptions(overridesChain);
12005
12006
		this.buildViewSpecOptions(spec);
12007
		this.buildViewSpecButtonText(spec, requestedViewType);
12008
12009
		return spec;
12010
	},
12011
12012
12013
	// Builds and assigns a view spec's options object from its already-assigned defaults and overrides
12014
	buildViewSpecOptions: function(spec) {
12015
		spec.options = mergeOptions([ // lowest to highest priority
12016
			Calendar.defaults, // global defaults
12017
			spec.defaults, // view's defaults (from ViewSubclass.defaults)
12018
			this.dirDefaults,
12019
			this.localeDefaults, // locale and dir take precedence over view's defaults!
12020
			this.overrides, // calendar's overrides (options given to constructor)
12021
			spec.overrides, // view's overrides (view-specific options)
12022
			this.dynamicOverrides // dynamically set via setter. highest precedence
12023
		]);
12024
		populateInstanceComputableOptions(spec.options);
12025
	},
12026
12027
12028
	// Computes and assigns a view spec's buttonText-related options
12029
	buildViewSpecButtonText: function(spec, requestedViewType) {
12030
12031
		// given an options object with a possible `buttonText` hash, lookup the buttonText for the
12032
		// requested view, falling back to a generic unit entry like "week" or "day"
12033
		function queryButtonText(options) {
12034
			var buttonText = options.buttonText || {};
12035
			return buttonText[requestedViewType] ||
12036
				// view can decide to look up a certain key
12037
				(spec.buttonTextKey ? buttonText[spec.buttonTextKey] : null) ||
12038
				// a key like "month"
12039
				(spec.singleUnit ? buttonText[spec.singleUnit] : null);
12040
		}
12041
12042
		// highest to lowest priority
12043
		spec.buttonTextOverride =
12044
			queryButtonText(this.dynamicOverrides) ||
12045
			queryButtonText(this.overrides) || // constructor-specified buttonText lookup hash takes precedence
12046
			spec.overrides.buttonText; // `buttonText` for view-specific options is a string
12047
12048
		// highest to lowest priority. mirrors buildViewSpecOptions
12049
		spec.buttonTextDefault =
12050
			queryButtonText(this.localeDefaults) ||
12051
			queryButtonText(this.dirDefaults) ||
12052
			spec.defaults.buttonText || // a single string. from ViewSubclass.defaults
12053
			queryButtonText(Calendar.defaults) ||
12054
			(spec.duration ? this.humanizeDuration(spec.duration) : null) || // like "3 days"
12055
			requestedViewType; // fall back to given view name
12056
	}
12057
12058
});
12059
12060
;;
12061
12062
Calendar.mixin({
12063
12064
	el: null,
12065
	contentEl: null,
12066
	suggestedViewHeight: null,
12067
	windowResizeProxy: null,
12068
	ignoreWindowResize: 0,
12069
12070
12071
	render: function() {
12072
		if (!this.contentEl) {
12073
			this.initialRender();
12074
		}
12075
		else if (this.elementVisible()) {
12076
			// mainly for the public API
12077
			this.calcSize();
12078
			this.renderView();
12079
		}
12080
	},
12081
12082
12083
	initialRender: function() {
12084
		var _this = this;
12085
		var el = this.el;
12086
12087
		el.addClass('fc');
12088
12089
		// event delegation for nav links
12090
		el.on('click.fc', 'a[data-goto]', function(ev) {
12091
			var anchorEl = $(this);
12092
			var gotoOptions = anchorEl.data('goto'); // will automatically parse JSON
12093
			var date = _this.moment(gotoOptions.date);
12094
			var viewType = gotoOptions.type;
12095
12096
			// property like "navLinkDayClick". might be a string or a function
12097
			var customAction = _this.view.opt('navLink' + capitaliseFirstLetter(viewType) + 'Click');
12098
12099
			if (typeof customAction === 'function') {
12100
				customAction(date, ev);
12101
			}
12102
			else {
12103
				if (typeof customAction === 'string') {
12104
					viewType = customAction;
12105
				}
12106
				_this.zoomTo(date, viewType);
12107
			}
12108
		});
12109
12110
		// called immediately, and upon option change
12111
		this.optionsModel.watch('applyingThemeClasses', [ '?theme' ], function(opts) {
12112
			el.toggleClass('ui-widget', opts.theme);
12113
			el.toggleClass('fc-unthemed', !opts.theme);
12114
		});
12115
12116
		// called immediately, and upon option change.
12117
		// HACK: locale often affects isRTL, so we explicitly listen to that too.
12118
		this.optionsModel.watch('applyingDirClasses', [ '?isRTL', '?locale' ], function(opts) {
12119
			el.toggleClass('fc-ltr', !opts.isRTL);
12120
			el.toggleClass('fc-rtl', opts.isRTL);
12121
		});
12122
12123
		this.contentEl = $("<div class='fc-view-container'/>").prependTo(el);
12124
12125
		this.initToolbars();
12126
		this.renderHeader();
12127
		this.renderFooter();
12128
		this.renderView(this.opt('defaultView'));
12129
12130
		if (this.opt('handleWindowResize')) {
12131
			$(window).resize(
12132
				this.windowResizeProxy = debounce( // prevents rapid calls
12133
					this.windowResize.bind(this),
12134
					this.opt('windowResizeDelay')
12135
				)
12136
			);
12137
		}
12138
	},
12139
12140
12141
	destroy: function() {
12142
12143
		if (this.view) {
12144
			this.view.removeElement();
12145
12146
			// NOTE: don't null-out this.view in case API methods are called after destroy.
12147
			// It is still the "current" view, just not rendered.
12148
		}
12149
12150
		this.toolbarsManager.proxyCall('removeElement');
12151
		this.contentEl.remove();
12152
		this.el.removeClass('fc fc-ltr fc-rtl fc-unthemed ui-widget');
12153
12154
		this.el.off('.fc'); // unbind nav link handlers
12155
12156
		if (this.windowResizeProxy) {
12157
			$(window).unbind('resize', this.windowResizeProxy);
12158
			this.windowResizeProxy = null;
12159
		}
12160
12161
		GlobalEmitter.unneeded();
12162
	},
12163
12164
12165
	elementVisible: function() {
12166
		return this.el.is(':visible');
12167
	},
12168
12169
12170
12171
	// View Rendering
12172
	// -----------------------------------------------------------------------------------
12173
12174
12175
	// Renders a view because of a date change, view-type change, or for the first time.
12176
	// If not given a viewType, keep the current view but render different dates.
12177
	// Accepts an optional scroll state to restore to.
12178
	renderView: function(viewType, forcedScroll) {
12179
12180
		this.ignoreWindowResize++;
12181
12182
		var needsClearView = this.view && viewType && this.view.type !== viewType;
12183
12184
		// if viewType is changing, remove the old view's rendering
12185
		if (needsClearView) {
12186
			this.freezeContentHeight(); // prevent a scroll jump when view element is removed
12187
			this.clearView();
12188
		}
12189
12190
		// if viewType changed, or the view was never created, create a fresh view
12191
		if (!this.view && viewType) {
12192
			this.view =
12193
				this.viewsByType[viewType] ||
12194
				(this.viewsByType[viewType] = this.instantiateView(viewType));
12195
12196
			this.view.setElement(
12197
				$("<div class='fc-view fc-" + viewType + "-view' />").appendTo(this.contentEl)
12198
			);
12199
			this.toolbarsManager.proxyCall('activateButton', viewType);
12200
		}
12201
12202
		if (this.view) {
12203
12204
			if (forcedScroll) {
12205
				this.view.addForcedScroll(forcedScroll);
12206
			}
12207
12208
			if (this.elementVisible()) {
12209
				this.currentDate = this.view.setDate(this.currentDate);
12210
			}
12211
		}
12212
12213
		if (needsClearView) {
12214
			this.thawContentHeight();
12215
		}
12216
12217
		this.ignoreWindowResize--;
12218
	},
12219
12220
12221
	// Unrenders the current view and reflects this change in the Header.
12222
	// Unregsiters the `view`, but does not remove from viewByType hash.
12223
	clearView: function() {
12224
		this.toolbarsManager.proxyCall('deactivateButton', this.view.type);
12225
		this.view.removeElement();
12226
		this.view = null;
12227
	},
12228
12229
12230
	// Destroys the view, including the view object. Then, re-instantiates it and renders it.
12231
	// Maintains the same scroll state.
12232
	// TODO: maintain any other user-manipulated state.
12233
	reinitView: function() {
12234
		this.ignoreWindowResize++;
12235
		this.freezeContentHeight();
12236
12237
		var viewType = this.view.type;
12238
		var scrollState = this.view.queryScroll();
12239
		this.clearView();
12240
		this.calcSize();
12241
		this.renderView(viewType, scrollState);
12242
12243
		this.thawContentHeight();
12244
		this.ignoreWindowResize--;
12245
	},
12246
12247
12248
	// Resizing
12249
	// -----------------------------------------------------------------------------------
12250
12251
12252
	getSuggestedViewHeight: function() {
12253
		if (this.suggestedViewHeight === null) {
12254
			this.calcSize();
12255
		}
12256
		return this.suggestedViewHeight;
12257
	},
12258
12259
12260
	isHeightAuto: function() {
12261
		return this.opt('contentHeight') === 'auto' || this.opt('height') === 'auto';
12262
	},
12263
12264
12265
	updateSize: function(shouldRecalc) {
12266
		if (this.elementVisible()) {
12267
12268
			if (shouldRecalc) {
12269
				this._calcSize();
12270
			}
12271
12272
			this.ignoreWindowResize++;
12273
			this.view.updateSize(true); // isResize=true. will poll getSuggestedViewHeight() and isHeightAuto()
12274
			this.ignoreWindowResize--;
12275
12276
			return true; // signal success
12277
		}
12278
	},
12279
12280
12281
	calcSize: function() {
12282
		if (this.elementVisible()) {
12283
			this._calcSize();
12284
		}
12285
	},
12286
12287
12288
	_calcSize: function() { // assumes elementVisible
12289
		var contentHeightInput = this.opt('contentHeight');
12290
		var heightInput = this.opt('height');
12291
12292
		if (typeof contentHeightInput === 'number') { // exists and not 'auto'
12293
			this.suggestedViewHeight = contentHeightInput;
12294
		}
12295
		else if (typeof contentHeightInput === 'function') { // exists and is a function
12296
			this.suggestedViewHeight = contentHeightInput();
12297
		}
12298
		else if (typeof heightInput === 'number') { // exists and not 'auto'
12299
			this.suggestedViewHeight = heightInput - this.queryToolbarsHeight();
12300
		}
12301
		else if (typeof heightInput === 'function') { // exists and is a function
12302
			this.suggestedViewHeight = heightInput() - this.queryToolbarsHeight();
12303
		}
12304
		else if (heightInput === 'parent') { // set to height of parent element
12305
			this.suggestedViewHeight = this.el.parent().height() - this.queryToolbarsHeight();
12306
		}
12307
		else {
12308
			this.suggestedViewHeight = Math.round(
12309
				this.contentEl.width() /
12310
				Math.max(this.opt('aspectRatio'), .5)
12311
			);
12312
		}
12313
	},
12314
12315
12316
	windowResize: function(ev) {
12317
		if (
12318
			!this.ignoreWindowResize &&
12319
			ev.target === window && // so we don't process jqui "resize" events that have bubbled up
12320
			this.view.renderRange // view has already been rendered
12321
		) {
12322
			if (this.updateSize(true)) {
12323
				this.view.publiclyTrigger('windowResize', this.el[0]);
12324
			}
12325
		}
12326
	},
12327
12328
12329
	/* Height "Freezing"
12330
	-----------------------------------------------------------------------------*/
12331
12332
12333
	freezeContentHeight: function() {
12334
		this.contentEl.css({
12335
			width: '100%',
12336
			height: this.contentEl.height(),
12337
			overflow: 'hidden'
12338
		});
12339
	},
12340
12341
12342
	thawContentHeight: function() {
12343
		this.contentEl.css({
12344
			width: '',
12345
			height: '',
12346
			overflow: ''
12347
		});
12348
	}
12349
12350
});
12351
12352
;;
12353
12354
Calendar.mixin({
12355
12356
	header: null,
12357
	footer: null,
12358
	toolbarsManager: null,
12359
12360
12361
	initToolbars: function() {
12362
		this.header = new Toolbar(this, this.computeHeaderOptions());
12363
		this.footer = new Toolbar(this, this.computeFooterOptions());
12364
		this.toolbarsManager = new Iterator([ this.header, this.footer ]);
12365
	},
12366
12367
12368
	computeHeaderOptions: function() {
12369
		return {
12370
			extraClasses: 'fc-header-toolbar',
12371
			layout: this.opt('header')
12372
		};
12373
	},
12374
12375
12376
	computeFooterOptions: function() {
12377
		return {
12378
			extraClasses: 'fc-footer-toolbar',
12379
			layout: this.opt('footer')
12380
		};
12381
	},
12382
12383
12384
	// can be called repeatedly and Header will rerender
12385
	renderHeader: function() {
12386
		var header = this.header;
12387
12388
		header.setToolbarOptions(this.computeHeaderOptions());
12389
		header.render();
12390
12391
		if (header.el) {
12392
			this.el.prepend(header.el);
12393
		}
12394
	},
12395
12396
12397
	// can be called repeatedly and Footer will rerender
12398
	renderFooter: function() {
12399
		var footer = this.footer;
12400
12401
		footer.setToolbarOptions(this.computeFooterOptions());
12402
		footer.render();
12403
12404
		if (footer.el) {
12405
			this.el.append(footer.el);
12406
		}
12407
	},
12408
12409
12410
	setToolbarsTitle: function(title) {
12411
		this.toolbarsManager.proxyCall('updateTitle', title);
12412
	},
12413
12414
12415
	updateToolbarButtons: function() {
12416
		var now = this.getNow();
12417
		var view = this.view;
12418
		var todayInfo = view.buildDateProfile(now);
12419
		var prevInfo = view.buildPrevDateProfile(this.currentDate);
12420
		var nextInfo = view.buildNextDateProfile(this.currentDate);
12421
12422
		this.toolbarsManager.proxyCall(
12423
			(todayInfo.isValid && !isDateWithinRange(now, view.currentRange)) ?
12424
				'enableButton' :
12425
				'disableButton',
12426
			'today'
12427
		);
12428
12429
		this.toolbarsManager.proxyCall(
12430
			prevInfo.isValid ?
12431
				'enableButton' :
12432
				'disableButton',
12433
			'prev'
12434
		);
12435
12436
		this.toolbarsManager.proxyCall(
12437
			nextInfo.isValid ?
12438
				'enableButton' :
12439
				'disableButton',
12440
			'next'
12441
		);
12442
	},
12443
12444
12445
	queryToolbarsHeight: function() {
12446
		return this.toolbarsManager.items.reduce(function(accumulator, toolbar) {
12447
			var toolbarHeight = toolbar.el ? toolbar.el.outerHeight(true) : 0; // includes margin
12448
			return accumulator + toolbarHeight;
12449
		}, 0);
12450
	}
12451
12452
});
12453
12454
;;
12455
12456
Calendar.defaults = {
12457
12458
	titleRangeSeparator: ' \u2013 ', // en dash
12459
	monthYearFormat: 'MMMM YYYY', // required for en. other locales rely on datepicker computable option
12460
12461
	defaultTimedEventDuration: '02:00:00',
12462
	defaultAllDayEventDuration: { days: 1 },
12463
	forceEventDuration: false,
12464
	nextDayThreshold: '09:00:00', // 9am
12465
12466
	// display
12467
	defaultView: 'month',
12468
	aspectRatio: 1.35,
12469
	header: {
12470
		left: 'title',
12471
		center: '',
12472
		right: 'today prev,next'
12473
	},
12474
	weekends: true,
12475
	weekNumbers: false,
12476
12477
	weekNumberTitle: 'W',
12478
	weekNumberCalculation: 'local',
12479
	
12480
	//editable: false,
12481
12482
	//nowIndicator: false,
12483
12484
	scrollTime: '06:00:00',
12485
	minTime: '00:00:00',
12486
	maxTime: '24:00:00',
12487
	showNonCurrentDates: true,
12488
	
12489
	// event ajax
12490
	lazyFetching: true,
12491
	startParam: 'start',
12492
	endParam: 'end',
12493
	timezoneParam: 'timezone',
12494
12495
	timezone: false,
12496
12497
	//allDayDefault: undefined,
12498
12499
	// locale
12500
	isRTL: false,
12501
	buttonText: {
12502
		prev: "prev",
12503
		next: "next",
12504
		prevYear: "prev year",
12505
		nextYear: "next year",
12506
		year: 'year', // TODO: locale files need to specify this
12507
		today: 'today',
12508
		month: 'month',
12509
		week: 'week',
12510
		day: 'day'
12511
	},
12512
12513
	buttonIcons: {
12514
		prev: 'left-single-arrow',
12515
		next: 'right-single-arrow',
12516
		prevYear: 'left-double-arrow',
12517
		nextYear: 'right-double-arrow'
12518
	},
12519
12520
	allDayText: 'all-day',
12521
	
12522
	// jquery-ui theming
12523
	theme: false,
12524
	themeButtonIcons: {
12525
		prev: 'circle-triangle-w',
12526
		next: 'circle-triangle-e',
12527
		prevYear: 'seek-prev',
12528
		nextYear: 'seek-next'
12529
	},
12530
12531
	//eventResizableFromStart: false,
12532
	dragOpacity: .75,
12533
	dragRevertDuration: 500,
12534
	dragScroll: true,
12535
	
12536
	//selectable: false,
12537
	unselectAuto: true,
12538
	//selectMinDistance: 0,
12539
	
12540
	dropAccept: '*',
12541
12542
	eventOrder: 'title',
12543
	//eventRenderWait: null,
12544
12545
	eventLimit: false,
12546
	eventLimitText: 'more',
12547
	eventLimitClick: 'popover',
12548
	dayPopoverFormat: 'LL',
12549
	
12550
	handleWindowResize: true,
12551
	windowResizeDelay: 100, // milliseconds before an updateSize happens
12552
12553
	longPressDelay: 1000
12554
	
12555
};
12556
12557
12558
Calendar.englishDefaults = { // used by locale.js
12559
	dayPopoverFormat: 'dddd, MMMM D'
12560
};
12561
12562
12563
Calendar.rtlDefaults = { // right-to-left defaults
12564
	header: { // TODO: smarter solution (first/center/last ?)
12565
		left: 'next,prev today',
12566
		center: '',
12567
		right: 'title'
12568
	},
12569
	buttonIcons: {
12570
		prev: 'right-single-arrow',
12571
		next: 'left-single-arrow',
12572
		prevYear: 'right-double-arrow',
12573
		nextYear: 'left-double-arrow'
12574
	},
12575
	themeButtonIcons: {
12576
		prev: 'circle-triangle-e',
12577
		next: 'circle-triangle-w',
12578
		nextYear: 'seek-prev',
12579
		prevYear: 'seek-next'
12580
	}
12581
};
12582
12583
;;
12584
12585
var localeOptionHash = FC.locales = {}; // initialize and expose
12586
12587
12588
// TODO: document the structure and ordering of a FullCalendar locale file
12589
12590
12591
// Initialize jQuery UI datepicker translations while using some of the translations
12592
// Will set this as the default locales for datepicker.
12593
FC.datepickerLocale = function(localeCode, dpLocaleCode, dpOptions) {
12594
12595
	// get the FullCalendar internal option hash for this locale. create if necessary
12596
	var fcOptions = localeOptionHash[localeCode] || (localeOptionHash[localeCode] = {});
12597
12598
	// transfer some simple options from datepicker to fc
12599
	fcOptions.isRTL = dpOptions.isRTL;
12600
	fcOptions.weekNumberTitle = dpOptions.weekHeader;
12601
12602
	// compute some more complex options from datepicker
12603
	$.each(dpComputableOptions, function(name, func) {
12604
		fcOptions[name] = func(dpOptions);
12605
	});
12606
12607
	// is jQuery UI Datepicker is on the page?
12608
	if ($.datepicker) {
12609
12610
		// Register the locale data.
12611
		// FullCalendar and MomentJS use locale codes like "pt-br" but Datepicker
12612
		// does it like "pt-BR" or if it doesn't have the locale, maybe just "pt".
12613
		// Make an alias so the locale can be referenced either way.
12614
		$.datepicker.regional[dpLocaleCode] =
12615
			$.datepicker.regional[localeCode] = // alias
12616
				dpOptions;
12617
12618
		// Alias 'en' to the default locale data. Do this every time.
12619
		$.datepicker.regional.en = $.datepicker.regional[''];
12620
12621
		// Set as Datepicker's global defaults.
12622
		$.datepicker.setDefaults(dpOptions);
12623
	}
12624
};
12625
12626
12627
// Sets FullCalendar-specific translations. Will set the locales as the global default.
12628
FC.locale = function(localeCode, newFcOptions) {
12629
	var fcOptions;
12630
	var momOptions;
12631
12632
	// get the FullCalendar internal option hash for this locale. create if necessary
12633
	fcOptions = localeOptionHash[localeCode] || (localeOptionHash[localeCode] = {});
12634
12635
	// provided new options for this locales? merge them in
12636
	if (newFcOptions) {
12637
		fcOptions = localeOptionHash[localeCode] = mergeOptions([ fcOptions, newFcOptions ]);
12638
	}
12639
12640
	// compute locale options that weren't defined.
12641
	// always do this. newFcOptions can be undefined when initializing from i18n file,
12642
	// so no way to tell if this is an initialization or a default-setting.
12643
	momOptions = getMomentLocaleData(localeCode); // will fall back to en
12644
	$.each(momComputableOptions, function(name, func) {
12645
		if (fcOptions[name] == null) {
12646
			fcOptions[name] = func(momOptions, fcOptions);
12647
		}
12648
	});
12649
12650
	// set it as the default locale for FullCalendar
12651
	Calendar.defaults.locale = localeCode;
12652
};
12653
12654
12655
// NOTE: can't guarantee any of these computations will run because not every locale has datepicker
12656
// configs, so make sure there are English fallbacks for these in the defaults file.
12657
var dpComputableOptions = {
12658
12659
	buttonText: function(dpOptions) {
12660
		return {
12661
			// the translations sometimes wrongly contain HTML entities
12662
			prev: stripHtmlEntities(dpOptions.prevText),
12663
			next: stripHtmlEntities(dpOptions.nextText),
12664
			today: stripHtmlEntities(dpOptions.currentText)
12665
		};
12666
	},
12667
12668
	// Produces format strings like "MMMM YYYY" -> "September 2014"
12669
	monthYearFormat: function(dpOptions) {
12670
		return dpOptions.showMonthAfterYear ?
12671
			'YYYY[' + dpOptions.yearSuffix + '] MMMM' :
12672
			'MMMM YYYY[' + dpOptions.yearSuffix + ']';
12673
	}
12674
12675
};
12676
12677
var momComputableOptions = {
12678
12679
	// Produces format strings like "ddd M/D" -> "Fri 9/15"
12680
	dayOfMonthFormat: function(momOptions, fcOptions) {
12681
		var format = momOptions.longDateFormat('l'); // for the format like "M/D/YYYY"
12682
12683
		// strip the year off the edge, as well as other misc non-whitespace chars
12684
		format = format.replace(/^Y+[^\w\s]*|[^\w\s]*Y+$/g, '');
12685
12686
		if (fcOptions.isRTL) {
12687
			format += ' ddd'; // for RTL, add day-of-week to end
12688
		}
12689
		else {
12690
			format = 'ddd ' + format; // for LTR, add day-of-week to beginning
12691
		}
12692
		return format;
12693
	},
12694
12695
	// Produces format strings like "h:mma" -> "6:00pm"
12696
	mediumTimeFormat: function(momOptions) { // can't be called `timeFormat` because collides with option
12697
		return momOptions.longDateFormat('LT')
12698
			.replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand
12699
	},
12700
12701
	// Produces format strings like "h(:mm)a" -> "6pm" / "6:30pm"
12702
	smallTimeFormat: function(momOptions) {
12703
		return momOptions.longDateFormat('LT')
12704
			.replace(':mm', '(:mm)')
12705
			.replace(/(\Wmm)$/, '($1)') // like above, but for foreign locales
12706
			.replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand
12707
	},
12708
12709
	// Produces format strings like "h(:mm)t" -> "6p" / "6:30p"
12710
	extraSmallTimeFormat: function(momOptions) {
12711
		return momOptions.longDateFormat('LT')
12712
			.replace(':mm', '(:mm)')
12713
			.replace(/(\Wmm)$/, '($1)') // like above, but for foreign locales
12714
			.replace(/\s*a$/i, 't'); // convert to AM/PM/am/pm to lowercase one-letter. remove any spaces beforehand
12715
	},
12716
12717
	// Produces format strings like "ha" / "H" -> "6pm" / "18"
12718
	hourFormat: function(momOptions) {
12719
		return momOptions.longDateFormat('LT')
12720
			.replace(':mm', '')
12721
			.replace(/(\Wmm)$/, '') // like above, but for foreign locales
12722
			.replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand
12723
	},
12724
12725
	// Produces format strings like "h:mm" -> "6:30" (with no AM/PM)
12726
	noMeridiemTimeFormat: function(momOptions) {
12727
		return momOptions.longDateFormat('LT')
12728
			.replace(/\s*a$/i, ''); // remove trailing AM/PM
12729
	}
12730
12731
};
12732
12733
12734
// options that should be computed off live calendar options (considers override options)
12735
// TODO: best place for this? related to locale?
12736
// TODO: flipping text based on isRTL is a bad idea because the CSS `direction` might want to handle it
12737
var instanceComputableOptions = {
12738
12739
	// Produces format strings for results like "Mo 16"
12740
	smallDayDateFormat: function(options) {
12741
		return options.isRTL ?
12742
			'D dd' :
12743
			'dd D';
12744
	},
12745
12746
	// Produces format strings for results like "Wk 5"
12747
	weekFormat: function(options) {
12748
		return options.isRTL ?
12749
			'w[ ' + options.weekNumberTitle + ']' :
12750
			'[' + options.weekNumberTitle + ' ]w';
12751
	},
12752
12753
	// Produces format strings for results like "Wk5"
12754
	smallWeekFormat: function(options) {
12755
		return options.isRTL ?
12756
			'w[' + options.weekNumberTitle + ']' :
12757
			'[' + options.weekNumberTitle + ']w';
12758
	}
12759
12760
};
12761
12762
// TODO: make these computable properties in optionsModel
12763
function populateInstanceComputableOptions(options) {
12764
	$.each(instanceComputableOptions, function(name, func) {
12765
		if (options[name] == null) {
12766
			options[name] = func(options);
12767
		}
12768
	});
12769
}
12770
12771
12772
// Returns moment's internal locale data. If doesn't exist, returns English.
12773
function getMomentLocaleData(localeCode) {
12774
	return moment.localeData(localeCode) || moment.localeData('en');
12775
}
12776
12777
12778
// Initialize English by forcing computation of moment-derived options.
12779
// Also, sets it as the default.
12780
FC.locale('en', Calendar.englishDefaults);
12781
12782
;;
12783
12784
FC.sourceNormalizers = [];
12785
FC.sourceFetchers = [];
12786
12787
var ajaxDefaults = {
12788
	dataType: 'json',
12789
	cache: false
12790
};
12791
12792
var eventGUID = 1;
12793
12794
12795
function EventManager() { // assumed to be a calendar
12796
	var t = this;
12797
12798
12799
	// exports
12800
	t.requestEvents = requestEvents;
12801
	t.reportEventChange = reportEventChange;
12802
	t.isFetchNeeded = isFetchNeeded;
12803
	t.fetchEvents = fetchEvents;
12804
	t.fetchEventSources = fetchEventSources;
12805
	t.refetchEvents = refetchEvents;
12806
	t.refetchEventSources = refetchEventSources;
12807
	t.getEventSources = getEventSources;
12808
	t.getEventSourceById = getEventSourceById;
12809
	t.addEventSource = addEventSource;
12810
	t.removeEventSource = removeEventSource;
12811
	t.removeEventSources = removeEventSources;
12812
	t.updateEvent = updateEvent;
12813
	t.updateEvents = updateEvents;
12814
	t.renderEvent = renderEvent;
12815
	t.renderEvents = renderEvents;
12816
	t.removeEvents = removeEvents;
12817
	t.clientEvents = clientEvents;
12818
	t.mutateEvent = mutateEvent;
12819
	t.normalizeEventDates = normalizeEventDates;
12820
	t.normalizeEventTimes = normalizeEventTimes;
12821
12822
12823
	// locals
12824
	var stickySource = { events: [] };
12825
	var sources = [ stickySource ];
12826
	var rangeStart, rangeEnd;
12827
	var pendingSourceCnt = 0; // outstanding fetch requests, max one per source
12828
	var cache = []; // holds events that have already been expanded
12829
	var prunedCache; // like cache, but only events that intersect with rangeStart/rangeEnd
12830
12831
12832
	$.each(
12833
		(t.opt('events') ? [ t.opt('events') ] : []).concat(t.opt('eventSources') || []),
12834
		function(i, sourceInput) {
12835
			var source = buildEventSource(sourceInput);
12836
			if (source) {
12837
				sources.push(source);
12838
			}
12839
		}
12840
	);
12841
12842
12843
12844
	function requestEvents(start, end) {
12845
		if (!t.opt('lazyFetching') || isFetchNeeded(start, end)) {
12846
			return fetchEvents(start, end);
12847
		}
12848
		else {
12849
			return Promise.resolve(prunedCache);
12850
		}
12851
	}
12852
12853
12854
	function reportEventChange() {
12855
		prunedCache = filterEventsWithinRange(cache);
12856
		t.trigger('eventsReset', prunedCache);
12857
	}
12858
12859
12860
	function filterEventsWithinRange(events) {
12861
		var filteredEvents = [];
12862
		var i, event;
12863
12864
		for (i = 0; i < events.length; i++) {
12865
			event = events[i];
12866
12867
			if (
12868
				event.start.clone().stripZone() < rangeEnd &&
12869
				t.getEventEnd(event).stripZone() > rangeStart
12870
			) {
12871
				filteredEvents.push(event);
12872
			}
12873
		}
12874
12875
		return filteredEvents;
12876
	}
12877
12878
12879
	t.getEventCache = function() {
12880
		return cache;
12881
	};
12882
12883
12884
12885
	/* Fetching
12886
	-----------------------------------------------------------------------------*/
12887
12888
12889
	// start and end are assumed to be unzoned
12890
	function isFetchNeeded(start, end) {
12891
		return !rangeStart || // nothing has been fetched yet?
12892
			start < rangeStart || end > rangeEnd; // is part of the new range outside of the old range?
12893
	}
12894
12895
12896
	function fetchEvents(start, end) {
12897
		rangeStart = start;
12898
		rangeEnd = end;
12899
		return refetchEvents();
12900
	}
12901
12902
12903
	// poorly named. fetches all sources with current `rangeStart` and `rangeEnd`.
12904
	function refetchEvents() {
12905
		return fetchEventSources(sources, 'reset');
12906
	}
12907
12908
12909
	// poorly named. fetches a subset of event sources.
12910
	function refetchEventSources(matchInputs) {
12911
		return fetchEventSources(getEventSourcesByMatchArray(matchInputs));
12912
	}
12913
12914
12915
	// expects an array of event source objects (the originals, not copies)
12916
	// `specialFetchType` is an optimization parameter that affects purging of the event cache.
12917
	function fetchEventSources(specificSources, specialFetchType) {
12918
		var i, source;
12919
12920
		if (specialFetchType === 'reset') {
12921
			cache = [];
12922
		}
12923
		else if (specialFetchType !== 'add') {
12924
			cache = excludeEventsBySources(cache, specificSources);
12925
		}
12926
12927
		for (i = 0; i < specificSources.length; i++) {
12928
			source = specificSources[i];
12929
12930
			// already-pending sources have already been accounted for in pendingSourceCnt
12931
			if (source._status !== 'pending') {
12932
				pendingSourceCnt++;
12933
			}
12934
12935
			source._fetchId = (source._fetchId || 0) + 1;
12936
			source._status = 'pending';
12937
		}
12938
12939
		for (i = 0; i < specificSources.length; i++) {
12940
			source = specificSources[i];
12941
			tryFetchEventSource(source, source._fetchId);
12942
		}
12943
12944
		if (pendingSourceCnt) {
12945
			return Promise.construct(function(resolve) {
12946
				t.one('eventsReceived', resolve); // will send prunedCache
12947
			});
12948
		}
12949
		else { // executed all synchronously, or no sources at all
12950
			return Promise.resolve(prunedCache);
12951
		}
12952
	}
12953
12954
12955
	// fetches an event source and processes its result ONLY if it is still the current fetch.
12956
	// caller is responsible for incrementing pendingSourceCnt first.
12957
	function tryFetchEventSource(source, fetchId) {
12958
		_fetchEventSource(source, function(eventInputs) {
12959
			var isArraySource = $.isArray(source.events);
12960
			var i, eventInput;
12961
			var abstractEvent;
12962
12963
			if (
12964
				// is this the source's most recent fetch?
12965
				// if not, rely on an upcoming fetch of this source to decrement pendingSourceCnt
12966
				fetchId === source._fetchId &&
12967
				// event source no longer valid?
12968
				source._status !== 'rejected'
12969
			) {
12970
				source._status = 'resolved';
12971
12972
				if (eventInputs) {
12973
					for (i = 0; i < eventInputs.length; i++) {
12974
						eventInput = eventInputs[i];
12975
12976
						if (isArraySource) { // array sources have already been convert to Event Objects
12977
							abstractEvent = eventInput;
12978
						}
12979
						else {
12980
							abstractEvent = buildEventFromInput(eventInput, source);
12981
						}
12982
12983
						if (abstractEvent) { // not false (an invalid event)
12984
							cache.push.apply( // append
12985
								cache,
12986
								expandEvent(abstractEvent) // add individual expanded events to the cache
12987
							);
12988
						}
12989
					}
12990
				}
12991
12992
				decrementPendingSourceCnt();
12993
			}
12994
		});
12995
	}
12996
12997
12998
	function rejectEventSource(source) {
12999
		var wasPending = source._status === 'pending';
13000
13001
		source._status = 'rejected';
13002
13003
		if (wasPending) {
13004
			decrementPendingSourceCnt();
13005
		}
13006
	}
13007
13008
13009
	function decrementPendingSourceCnt() {
13010
		pendingSourceCnt--;
13011
		if (!pendingSourceCnt) {
13012
			reportEventChange(cache); // updates prunedCache
13013
			t.trigger('eventsReceived', prunedCache);
13014
		}
13015
	}
13016
13017
13018
	function _fetchEventSource(source, callback) {
13019
		var i;
13020
		var fetchers = FC.sourceFetchers;
13021
		var res;
13022
13023
		for (i=0; i<fetchers.length; i++) {
13024
			res = fetchers[i].call(
13025
				t, // this, the Calendar object
13026
				source,
13027
				rangeStart.clone(),
13028
				rangeEnd.clone(),
13029
				t.opt('timezone'),
13030
				callback
13031
			);
13032
13033
			if (res === true) {
13034
				// the fetcher is in charge. made its own async request
13035
				return;
13036
			}
13037
			else if (typeof res == 'object') {
13038
				// the fetcher returned a new source. process it
13039
				_fetchEventSource(res, callback);
13040
				return;
13041
			}
13042
		}
13043
13044
		var events = source.events;
13045
		if (events) {
13046
			if ($.isFunction(events)) {
13047
				t.pushLoading();
13048
				events.call(
13049
					t, // this, the Calendar object
13050
					rangeStart.clone(),
13051
					rangeEnd.clone(),
13052
					t.opt('timezone'),
13053
					function(events) {
13054
						callback(events);
13055
						t.popLoading();
13056
					}
13057
				);
13058
			}
13059
			else if ($.isArray(events)) {
13060
				callback(events);
13061
			}
13062
			else {
13063
				callback();
13064
			}
13065
		}else{
13066
			var url = source.url;
13067
			if (url) {
13068
				var success = source.success;
13069
				var error = source.error;
13070
				var complete = source.complete;
13071
13072
				// retrieve any outbound GET/POST $.ajax data from the options
13073
				var customData;
13074
				if ($.isFunction(source.data)) {
13075
					// supplied as a function that returns a key/value object
13076
					customData = source.data();
13077
				}
13078
				else {
13079
					// supplied as a straight key/value object
13080
					customData = source.data;
13081
				}
13082
13083
				// use a copy of the custom data so we can modify the parameters
13084
				// and not affect the passed-in object.
13085
				var data = $.extend({}, customData || {});
13086
13087
				var startParam = firstDefined(source.startParam, t.opt('startParam'));
13088
				var endParam = firstDefined(source.endParam, t.opt('endParam'));
13089
				var timezoneParam = firstDefined(source.timezoneParam, t.opt('timezoneParam'));
13090
13091
				if (startParam) {
13092
					data[startParam] = rangeStart.format();
13093
				}
13094
				if (endParam) {
13095
					data[endParam] = rangeEnd.format();
13096
				}
13097
				if (t.opt('timezone') && t.opt('timezone') != 'local') {
13098
					data[timezoneParam] = t.opt('timezone');
13099
				}
13100
13101
				t.pushLoading();
13102
				$.ajax($.extend({}, ajaxDefaults, source, {
13103
					data: data,
13104
					success: function(events) {
13105
						events = events || [];
13106
						var res = applyAll(success, this, arguments);
13107
						if ($.isArray(res)) {
13108
							events = res;
13109
						}
13110
						callback(events);
13111
					},
13112
					error: function() {
13113
						applyAll(error, this, arguments);
13114
						callback();
13115
					},
13116
					complete: function() {
13117
						applyAll(complete, this, arguments);
13118
						t.popLoading();
13119
					}
13120
				}));
13121
			}else{
13122
				callback();
13123
			}
13124
		}
13125
	}
13126
13127
13128
13129
	/* Sources
13130
	-----------------------------------------------------------------------------*/
13131
13132
13133
	function addEventSource(sourceInput) {
13134
		var source = buildEventSource(sourceInput);
13135
		if (source) {
13136
			sources.push(source);
13137
			fetchEventSources([ source ], 'add'); // will eventually call reportEventChange
13138
		}
13139
	}
13140
13141
13142
	function buildEventSource(sourceInput) { // will return undefined if invalid source
13143
		var normalizers = FC.sourceNormalizers;
13144
		var source;
13145
		var i;
13146
13147
		if ($.isFunction(sourceInput) || $.isArray(sourceInput)) {
13148
			source = { events: sourceInput };
13149
		}
13150
		else if (typeof sourceInput === 'string') {
13151
			source = { url: sourceInput };
13152
		}
13153
		else if (typeof sourceInput === 'object') {
13154
			source = $.extend({}, sourceInput); // shallow copy
13155
		}
13156
13157
		if (source) {
13158
13159
			// TODO: repeat code, same code for event classNames
13160
			if (source.className) {
13161
				if (typeof source.className === 'string') {
13162
					source.className = source.className.split(/\s+/);
13163
				}
13164
				// otherwise, assumed to be an array
13165
			}
13166
			else {
13167
				source.className = [];
13168
			}
13169
13170
			// for array sources, we convert to standard Event Objects up front
13171
			if ($.isArray(source.events)) {
13172
				source.origArray = source.events; // for removeEventSource
13173
				source.events = $.map(source.events, function(eventInput) {
13174
					return buildEventFromInput(eventInput, source);
13175
				});
13176
			}
13177
13178
			for (i=0; i<normalizers.length; i++) {
13179
				normalizers[i].call(t, source);
13180
			}
13181
13182
			return source;
13183
		}
13184
	}
13185
13186
13187
	function removeEventSource(matchInput) {
13188
		removeSpecificEventSources(
13189
			getEventSourcesByMatch(matchInput)
13190
		);
13191
	}
13192
13193
13194
	// if called with no arguments, removes all.
13195
	function removeEventSources(matchInputs) {
13196
		if (matchInputs == null) {
13197
			removeSpecificEventSources(sources, true); // isAll=true
13198
		}
13199
		else {
13200
			removeSpecificEventSources(
13201
				getEventSourcesByMatchArray(matchInputs)
13202
			);
13203
		}
13204
	}
13205
13206
13207
	function removeSpecificEventSources(targetSources, isAll) {
13208
		var i;
13209
13210
		// cancel pending requests
13211
		for (i = 0; i < targetSources.length; i++) {
13212
			rejectEventSource(targetSources[i]);
13213
		}
13214
13215
		if (isAll) { // an optimization
13216
			sources = [];
13217
			cache = [];
13218
		}
13219
		else {
13220
			// remove from persisted source list
13221
			sources = $.grep(sources, function(source) {
13222
				for (i = 0; i < targetSources.length; i++) {
13223
					if (source === targetSources[i]) {
13224
						return false; // exclude
13225
					}
13226
				}
13227
				return true; // include
13228
			});
13229
13230
			cache = excludeEventsBySources(cache, targetSources);
13231
		}
13232
13233
		reportEventChange();
13234
	}
13235
13236
13237
	function getEventSources() {
13238
		return sources.slice(1); // returns a shallow copy of sources with stickySource removed
13239
	}
13240
13241
13242
	function getEventSourceById(id) {
13243
		return $.grep(sources, function(source) {
13244
			return source.id && source.id === id;
13245
		})[0];
13246
	}
13247
13248
13249
	// like getEventSourcesByMatch, but accepts multple match criteria (like multiple IDs)
13250
	function getEventSourcesByMatchArray(matchInputs) {
13251
13252
		// coerce into an array
13253
		if (!matchInputs) {
13254
			matchInputs = [];
13255
		}
13256
		else if (!$.isArray(matchInputs)) {
13257
			matchInputs = [ matchInputs ];
13258
		}
13259
13260
		var matchingSources = [];
13261
		var i;
13262
13263
		// resolve raw inputs to real event source objects
13264
		for (i = 0; i < matchInputs.length; i++) {
13265
			matchingSources.push.apply( // append
13266
				matchingSources,
13267
				getEventSourcesByMatch(matchInputs[i])
13268
			);
13269
		}
13270
13271
		return matchingSources;
13272
	}
13273
13274
13275
	// matchInput can either by a real event source object, an ID, or the function/URL for the source.
13276
	// returns an array of matching source objects.
13277
	function getEventSourcesByMatch(matchInput) {
13278
		var i, source;
13279
13280
		// given an proper event source object
13281
		for (i = 0; i < sources.length; i++) {
13282
			source = sources[i];
13283
			if (source === matchInput) {
13284
				return [ source ];
13285
			}
13286
		}
13287
13288
		// an ID match
13289
		source = getEventSourceById(matchInput);
13290
		if (source) {
13291
			return [ source ];
13292
		}
13293
13294
		return $.grep(sources, function(source) {
13295
			return isSourcesEquivalent(matchInput, source);
13296
		});
13297
	}
13298
13299
13300
	function isSourcesEquivalent(source1, source2) {
13301
		return source1 && source2 && getSourcePrimitive(source1) == getSourcePrimitive(source2);
13302
	}
13303
13304
13305
	function getSourcePrimitive(source) {
13306
		return (
13307
			(typeof source === 'object') ? // a normalized event source?
13308
				(source.origArray || source.googleCalendarId || source.url || source.events) : // get the primitive
13309
				null
13310
		) ||
13311
		source; // the given argument *is* the primitive
13312
	}
13313
13314
13315
	// util
13316
	// returns a filtered array without events that are part of any of the given sources
13317
	function excludeEventsBySources(specificEvents, specificSources) {
13318
		return $.grep(specificEvents, function(event) {
13319
			for (var i = 0; i < specificSources.length; i++) {
13320
				if (event.source === specificSources[i]) {
13321
					return false; // exclude
13322
				}
13323
			}
13324
			return true; // keep
13325
		});
13326
	}
13327
13328
13329
13330
	/* Manipulation
13331
	-----------------------------------------------------------------------------*/
13332
13333
13334
	// Only ever called from the externally-facing API
13335
	function updateEvent(event) {
13336
		updateEvents([ event ]);
13337
	}
13338
13339
13340
	// Only ever called from the externally-facing API
13341
	function updateEvents(events) {
13342
		var i, event;
13343
13344
		for (i = 0; i < events.length; i++) {
13345
			event = events[i];
13346
13347
			// massage start/end values, even if date string values
13348
			event.start = t.moment(event.start);
13349
			if (event.end) {
13350
				event.end = t.moment(event.end);
13351
			}
13352
			else {
13353
				event.end = null;
13354
			}
13355
13356
			mutateEvent(event, getMiscEventProps(event)); // will handle start/end/allDay normalization
13357
		}
13358
13359
		reportEventChange(); // reports event modifications (so we can redraw)
13360
	}
13361
13362
13363
	// Returns a hash of misc event properties that should be copied over to related events.
13364
	function getMiscEventProps(event) {
13365
		var props = {};
13366
13367
		$.each(event, function(name, val) {
13368
			if (isMiscEventPropName(name)) {
13369
				if (val !== undefined && isAtomic(val)) { // a defined non-object
13370
					props[name] = val;
13371
				}
13372
			}
13373
		});
13374
13375
		return props;
13376
	}
13377
13378
	// non-date-related, non-id-related, non-secret
13379
	function isMiscEventPropName(name) {
13380
		return !/^_|^(id|allDay|start|end)$/.test(name);
13381
	}
13382
13383
13384
	// returns the expanded events that were created
13385
	function renderEvent(eventInput, stick) {
13386
		return renderEvents([ eventInput ], stick);
13387
	}
13388
13389
13390
	// returns the expanded events that were created
13391
	function renderEvents(eventInputs, stick) {
13392
		var renderedEvents = [];
13393
		var renderableEvents;
13394
		var abstractEvent;
13395
		var i, j, event;
13396
13397
		for (i = 0; i < eventInputs.length; i++) {
13398
			abstractEvent = buildEventFromInput(eventInputs[i]);
13399
13400
			if (abstractEvent) { // not false (a valid input)
13401
				renderableEvents = expandEvent(abstractEvent);
13402
13403
				for (j = 0; j < renderableEvents.length; j++) {
13404
					event = renderableEvents[j];
13405
13406
					if (!event.source) {
13407
						if (stick) {
13408
							stickySource.events.push(event);
13409
							event.source = stickySource;
13410
						}
13411
						cache.push(event);
13412
					}
13413
				}
13414
13415
				renderedEvents = renderedEvents.concat(renderableEvents);
13416
			}
13417
		}
13418
13419
		if (renderedEvents.length) { // any new events rendered?
13420
			reportEventChange();
13421
		}
13422
13423
		return renderedEvents;
13424
	}
13425
13426
13427
	function removeEvents(filter) {
13428
		var eventID;
13429
		var i;
13430
13431
		if (filter == null) { // null or undefined. remove all events
13432
			filter = function() { return true; }; // will always match
13433
		}
13434
		else if (!$.isFunction(filter)) { // an event ID
13435
			eventID = filter + '';
13436
			filter = function(event) {
13437
				return event._id == eventID;
13438
			};
13439
		}
13440
13441
		// Purge event(s) from our local cache
13442
		cache = $.grep(cache, filter, true); // inverse=true
13443
13444
		// Remove events from array sources.
13445
		// This works because they have been converted to official Event Objects up front.
13446
		// (and as a result, event._id has been calculated).
13447
		for (i=0; i<sources.length; i++) {
13448
			if ($.isArray(sources[i].events)) {
13449
				sources[i].events = $.grep(sources[i].events, filter, true);
13450
			}
13451
		}
13452
13453
		reportEventChange();
13454
	}
13455
13456
13457
	function clientEvents(filter) {
13458
		if ($.isFunction(filter)) {
13459
			return $.grep(cache, filter);
13460
		}
13461
		else if (filter != null) { // not null, not undefined. an event ID
13462
			filter += '';
13463
			return $.grep(cache, function(e) {
13464
				return e._id == filter;
13465
			});
13466
		}
13467
		return cache; // else, return all
13468
	}
13469
13470
13471
	// Makes sure all array event sources have their internal event objects
13472
	// converted over to the Calendar's current timezone.
13473
	t.rezoneArrayEventSources = function() {
13474
		var i;
13475
		var events;
13476
		var j;
13477
13478
		for (i = 0; i < sources.length; i++) {
13479
			events = sources[i].events;
13480
			if ($.isArray(events)) {
13481
13482
				for (j = 0; j < events.length; j++) {
13483
					rezoneEventDates(events[j]);
13484
				}
13485
			}
13486
		}
13487
	};
13488
13489
	function rezoneEventDates(event) {
13490
		event.start = t.moment(event.start);
13491
		if (event.end) {
13492
			event.end = t.moment(event.end);
13493
		}
13494
		backupEventDates(event);
13495
	}
13496
13497
13498
	/* Event Normalization
13499
	-----------------------------------------------------------------------------*/
13500
13501
13502
	// Given a raw object with key/value properties, returns an "abstract" Event object.
13503
	// An "abstract" event is an event that, if recurring, will not have been expanded yet.
13504
	// Will return `false` when input is invalid.
13505
	// `source` is optional
13506
	function buildEventFromInput(input, source) {
13507
		var calendarEventDataTransform = t.opt('eventDataTransform');
13508
		var out = {};
13509
		var start, end;
13510
		var allDay;
13511
13512
		if (calendarEventDataTransform) {
13513
			input = calendarEventDataTransform(input);
13514
		}
13515
		if (source && source.eventDataTransform) {
13516
			input = source.eventDataTransform(input);
13517
		}
13518
13519
		// Copy all properties over to the resulting object.
13520
		// The special-case properties will be copied over afterwards.
13521
		$.extend(out, input);
13522
13523
		if (source) {
13524
			out.source = source;
13525
		}
13526
13527
		out._id = input._id || (input.id === undefined ? '_fc' + eventGUID++ : input.id + '');
13528
13529
		if (input.className) {
13530
			if (typeof input.className == 'string') {
13531
				out.className = input.className.split(/\s+/);
13532
			}
13533
			else { // assumed to be an array
13534
				out.className = input.className;
13535
			}
13536
		}
13537
		else {
13538
			out.className = [];
13539
		}
13540
13541
		start = input.start || input.date; // "date" is an alias for "start"
13542
		end = input.end;
13543
13544
		// parse as a time (Duration) if applicable
13545
		if (isTimeString(start)) {
13546
			start = moment.duration(start);
13547
		}
13548
		if (isTimeString(end)) {
13549
			end = moment.duration(end);
13550
		}
13551
13552
		if (input.dow || moment.isDuration(start) || moment.isDuration(end)) {
13553
13554
			// the event is "abstract" (recurring) so don't calculate exact start/end dates just yet
13555
			out.start = start ? moment.duration(start) : null; // will be a Duration or null
13556
			out.end = end ? moment.duration(end) : null; // will be a Duration or null
13557
			out._recurring = true; // our internal marker
13558
		}
13559
		else {
13560
13561
			if (start) {
13562
				start = t.moment(start);
13563
				if (!start.isValid()) {
13564
					return false;
13565
				}
13566
			}
13567
13568
			if (end) {
13569
				end = t.moment(end);
13570
				if (!end.isValid()) {
13571
					end = null; // let defaults take over
13572
				}
13573
			}
13574
13575
			allDay = input.allDay;
13576
			if (allDay === undefined) { // still undefined? fallback to default
13577
				allDay = firstDefined(
13578
					source ? source.allDayDefault : undefined,
13579
					t.opt('allDayDefault')
13580
				);
13581
				// still undefined? normalizeEventDates will calculate it
13582
			}
13583
13584
			assignDatesToEvent(start, end, allDay, out);
13585
		}
13586
13587
		t.normalizeEvent(out); // hook for external use. a prototype method
13588
13589
		return out;
13590
	}
13591
	t.buildEventFromInput = buildEventFromInput;
13592
13593
13594
	// Normalizes and assigns the given dates to the given partially-formed event object.
13595
	// NOTE: mutates the given start/end moments. does not make a copy.
13596
	function assignDatesToEvent(start, end, allDay, event) {
13597
		event.start = start;
13598
		event.end = end;
13599
		event.allDay = allDay;
13600
		normalizeEventDates(event);
13601
		backupEventDates(event);
13602
	}
13603
13604
13605
	// Ensures proper values for allDay/start/end. Accepts an Event object, or a plain object with event-ish properties.
13606
	// NOTE: Will modify the given object.
13607
	function normalizeEventDates(eventProps) {
13608
13609
		normalizeEventTimes(eventProps);
13610
13611
		if (eventProps.end && !eventProps.end.isAfter(eventProps.start)) {
13612
			eventProps.end = null;
13613
		}
13614
13615
		if (!eventProps.end) {
13616
			if (t.opt('forceEventDuration')) {
13617
				eventProps.end = t.getDefaultEventEnd(eventProps.allDay, eventProps.start);
13618
			}
13619
			else {
13620
				eventProps.end = null;
13621
			}
13622
		}
13623
	}
13624
13625
13626
	// Ensures the allDay property exists and the timeliness of the start/end dates are consistent
13627
	function normalizeEventTimes(eventProps) {
13628
		if (eventProps.allDay == null) {
13629
			eventProps.allDay = !(eventProps.start.hasTime() || (eventProps.end && eventProps.end.hasTime()));
13630
		}
13631
13632
		if (eventProps.allDay) {
13633
			eventProps.start.stripTime();
13634
			if (eventProps.end) {
13635
				// TODO: consider nextDayThreshold here? If so, will require a lot of testing and adjustment
13636
				eventProps.end.stripTime();
13637
			}
13638
		}
13639
		else {
13640
			if (!eventProps.start.hasTime()) {
13641
				eventProps.start = t.applyTimezone(eventProps.start.time(0)); // will assign a 00:00 time
13642
			}
13643
			if (eventProps.end && !eventProps.end.hasTime()) {
13644
				eventProps.end = t.applyTimezone(eventProps.end.time(0)); // will assign a 00:00 time
13645
			}
13646
		}
13647
	}
13648
13649
13650
	// If the given event is a recurring event, break it down into an array of individual instances.
13651
	// If not a recurring event, return an array with the single original event.
13652
	// If given a falsy input (probably because of a failed buildEventFromInput call), returns an empty array.
13653
	// HACK: can override the recurring window by providing custom rangeStart/rangeEnd (for businessHours).
13654
	function expandEvent(abstractEvent, _rangeStart, _rangeEnd) {
13655
		var events = [];
13656
		var dowHash;
13657
		var dow;
13658
		var i;
13659
		var date;
13660
		var startTime, endTime;
13661
		var start, end;
13662
		var event;
13663
13664
		_rangeStart = _rangeStart || rangeStart;
13665
		_rangeEnd = _rangeEnd || rangeEnd;
13666
13667
		if (abstractEvent) {
13668
			if (abstractEvent._recurring) {
13669
13670
				// make a boolean hash as to whether the event occurs on each day-of-week
13671
				if ((dow = abstractEvent.dow)) {
13672
					dowHash = {};
13673
					for (i = 0; i < dow.length; i++) {
13674
						dowHash[dow[i]] = true;
13675
					}
13676
				}
13677
13678
				// iterate through every day in the current range
13679
				date = _rangeStart.clone().stripTime(); // holds the date of the current day
13680
				while (date.isBefore(_rangeEnd)) {
13681
13682
					if (!dowHash || dowHash[date.day()]) { // if everyday, or this particular day-of-week
13683
13684
						startTime = abstractEvent.start; // the stored start and end properties are times (Durations)
13685
						endTime = abstractEvent.end; // "
13686
						start = date.clone();
13687
						end = null;
13688
13689
						if (startTime) {
13690
							start = start.time(startTime);
13691
						}
13692
						if (endTime) {
13693
							end = date.clone().time(endTime);
13694
						}
13695
13696
						event = $.extend({}, abstractEvent); // make a copy of the original
13697
						assignDatesToEvent(
13698
							start, end,
13699
							!startTime && !endTime, // allDay?
13700
							event
13701
						);
13702
						events.push(event);
13703
					}
13704
13705
					date.add(1, 'days');
13706
				}
13707
			}
13708
			else {
13709
				events.push(abstractEvent); // return the original event. will be a one-item array
13710
			}
13711
		}
13712
13713
		return events;
13714
	}
13715
	t.expandEvent = expandEvent;
13716
13717
13718
13719
	/* Event Modification Math
13720
	-----------------------------------------------------------------------------------------*/
13721
13722
13723
	// Modifies an event and all related events by applying the given properties.
13724
	// Special date-diffing logic is used for manipulation of dates.
13725
	// If `props` does not contain start/end dates, the updated values are assumed to be the event's current start/end.
13726
	// All date comparisons are done against the event's pristine _start and _end dates.
13727
	// Returns an object with delta information and a function to undo all operations.
13728
	// For making computations in a granularity greater than day/time, specify largeUnit.
13729
	// NOTE: The given `newProps` might be mutated for normalization purposes.
13730
	function mutateEvent(event, newProps, largeUnit) {
13731
		var miscProps = {};
13732
		var oldProps;
13733
		var clearEnd;
13734
		var startDelta;
13735
		var endDelta;
13736
		var durationDelta;
13737
		var undoFunc;
13738
13739
		// diffs the dates in the appropriate way, returning a duration
13740
		function diffDates(date1, date0) { // date1 - date0
13741
			if (largeUnit) {
13742
				return diffByUnit(date1, date0, largeUnit);
13743
			}
13744
			else if (newProps.allDay) {
13745
				return diffDay(date1, date0);
13746
			}
13747
			else {
13748
				return diffDayTime(date1, date0);
13749
			}
13750
		}
13751
13752
		newProps = newProps || {};
13753
13754
		// normalize new date-related properties
13755
		if (!newProps.start) {
13756
			newProps.start = event.start.clone();
13757
		}
13758
		if (newProps.end === undefined) {
13759
			newProps.end = event.end ? event.end.clone() : null;
13760
		}
13761
		if (newProps.allDay == null) { // is null or undefined?
13762
			newProps.allDay = event.allDay;
13763
		}
13764
		normalizeEventDates(newProps);
13765
13766
		// create normalized versions of the original props to compare against
13767
		// need a real end value, for diffing
13768
		oldProps = {
13769
			start: event._start.clone(),
13770
			end: event._end ? event._end.clone() : t.getDefaultEventEnd(event._allDay, event._start),
13771
			allDay: newProps.allDay // normalize the dates in the same regard as the new properties
13772
		};
13773
		normalizeEventDates(oldProps);
13774
13775
		// need to clear the end date if explicitly changed to null
13776
		clearEnd = event._end !== null && newProps.end === null;
13777
13778
		// compute the delta for moving the start date
13779
		startDelta = diffDates(newProps.start, oldProps.start);
13780
13781
		// compute the delta for moving the end date
13782
		if (newProps.end) {
13783
			endDelta = diffDates(newProps.end, oldProps.end);
13784
			durationDelta = endDelta.subtract(startDelta);
13785
		}
13786
		else {
13787
			durationDelta = null;
13788
		}
13789
13790
		// gather all non-date-related properties
13791
		$.each(newProps, function(name, val) {
13792
			if (isMiscEventPropName(name)) {
13793
				if (val !== undefined) {
13794
					miscProps[name] = val;
13795
				}
13796
			}
13797
		});
13798
13799
		// apply the operations to the event and all related events
13800
		undoFunc = mutateEvents(
13801
			clientEvents(event._id), // get events with this ID
13802
			clearEnd,
13803
			newProps.allDay,
13804
			startDelta,
13805
			durationDelta,
13806
			miscProps
13807
		);
13808
13809
		return {
13810
			dateDelta: startDelta,
13811
			durationDelta: durationDelta,
13812
			undo: undoFunc
13813
		};
13814
	}
13815
13816
13817
	// Modifies an array of events in the following ways (operations are in order):
13818
	// - clear the event's `end`
13819
	// - convert the event to allDay
13820
	// - add `dateDelta` to the start and end
13821
	// - add `durationDelta` to the event's duration
13822
	// - assign `miscProps` to the event
13823
	//
13824
	// Returns a function that can be called to undo all the operations.
13825
	//
13826
	// TODO: don't use so many closures. possible memory issues when lots of events with same ID.
13827
	//
13828
	function mutateEvents(events, clearEnd, allDay, dateDelta, durationDelta, miscProps) {
13829
		var isAmbigTimezone = t.getIsAmbigTimezone();
13830
		var undoFunctions = [];
13831
13832
		// normalize zero-length deltas to be null
13833
		if (dateDelta && !dateDelta.valueOf()) { dateDelta = null; }
13834
		if (durationDelta && !durationDelta.valueOf()) { durationDelta = null; }
13835
13836
		$.each(events, function(i, event) {
13837
			var oldProps;
13838
			var newProps;
13839
13840
			// build an object holding all the old values, both date-related and misc.
13841
			// for the undo function.
13842
			oldProps = {
13843
				start: event.start.clone(),
13844
				end: event.end ? event.end.clone() : null,
13845
				allDay: event.allDay
13846
			};
13847
			$.each(miscProps, function(name) {
13848
				oldProps[name] = event[name];
13849
			});
13850
13851
			// new date-related properties. work off the original date snapshot.
13852
			// ok to use references because they will be thrown away when backupEventDates is called.
13853
			newProps = {
13854
				start: event._start,
13855
				end: event._end,
13856
				allDay: allDay // normalize the dates in the same regard as the new properties
13857
			};
13858
			normalizeEventDates(newProps); // massages start/end/allDay
13859
13860
			// strip or ensure the end date
13861
			if (clearEnd) {
13862
				newProps.end = null;
13863
			}
13864
			else if (durationDelta && !newProps.end) { // the duration translation requires an end date
13865
				newProps.end = t.getDefaultEventEnd(newProps.allDay, newProps.start);
13866
			}
13867
13868
			if (dateDelta) {
13869
				newProps.start.add(dateDelta);
13870
				if (newProps.end) {
13871
					newProps.end.add(dateDelta);
13872
				}
13873
			}
13874
13875
			if (durationDelta) {
13876
				newProps.end.add(durationDelta); // end already ensured above
13877
			}
13878
13879
			// if the dates have changed, and we know it is impossible to recompute the
13880
			// timezone offsets, strip the zone.
13881
			if (
13882
				isAmbigTimezone &&
13883
				!newProps.allDay &&
13884
				(dateDelta || durationDelta)
13885
			) {
13886
				newProps.start.stripZone();
13887
				if (newProps.end) {
13888
					newProps.end.stripZone();
13889
				}
13890
			}
13891
13892
			$.extend(event, miscProps, newProps); // copy over misc props, then date-related props
13893
			backupEventDates(event); // regenerate internal _start/_end/_allDay
13894
13895
			undoFunctions.push(function() {
13896
				$.extend(event, oldProps);
13897
				backupEventDates(event); // regenerate internal _start/_end/_allDay
13898
			});
13899
		});
13900
13901
		return function() {
13902
			for (var i = 0; i < undoFunctions.length; i++) {
13903
				undoFunctions[i]();
13904
			}
13905
		};
13906
	}
13907
13908
}
13909
13910
13911
// returns an undo function
13912
Calendar.prototype.mutateSeg = function(seg, newProps) {
13913
	return this.mutateEvent(seg.event, newProps);
13914
};
13915
13916
13917
// hook for external libs to manipulate event properties upon creation.
13918
// should manipulate the event in-place.
13919
Calendar.prototype.normalizeEvent = function(event) {
13920
};
13921
13922
13923
// Does the given span (start, end, and other location information)
13924
// fully contain the other?
13925
Calendar.prototype.spanContainsSpan = function(outerSpan, innerSpan) {
13926
	var eventStart = outerSpan.start.clone().stripZone();
13927
	var eventEnd = this.getEventEnd(outerSpan).stripZone();
13928
13929
	return innerSpan.start >= eventStart && innerSpan.end <= eventEnd;
13930
};
13931
13932
13933
// Returns a list of events that the given event should be compared against when being considered for a move to
13934
// the specified span. Attached to the Calendar's prototype because EventManager is a mixin for a Calendar.
13935
Calendar.prototype.getPeerEvents = function(span, event) {
13936
	var cache = this.getEventCache();
13937
	var peerEvents = [];
13938
	var i, otherEvent;
13939
13940
	for (i = 0; i < cache.length; i++) {
13941
		otherEvent = cache[i];
13942
		if (
13943
			!event ||
13944
			event._id !== otherEvent._id // don't compare the event to itself or other related [repeating] events
13945
		) {
13946
			peerEvents.push(otherEvent);
13947
		}
13948
	}
13949
13950
	return peerEvents;
13951
};
13952
13953
13954
// updates the "backup" properties, which are preserved in order to compute diffs later on.
13955
function backupEventDates(event) {
13956
	event._allDay = event.allDay;
13957
	event._start = event.start.clone();
13958
	event._end = event.end ? event.end.clone() : null;
13959
}
13960
13961
13962
/* Overlapping / Constraining
13963
-----------------------------------------------------------------------------------------*/
13964
13965
13966
// Determines if the given event can be relocated to the given span (unzoned start/end with other misc data)
13967
Calendar.prototype.isEventSpanAllowed = function(span, event) {
13968
	var source = event.source || {};
13969
	var eventAllowFunc = this.opt('eventAllow');
13970
13971
	var constraint = firstDefined(
13972
		event.constraint,
13973
		source.constraint,
13974
		this.opt('eventConstraint')
13975
	);
13976
13977
	var overlap = firstDefined(
13978
		event.overlap,
13979
		source.overlap,
13980
		this.opt('eventOverlap')
13981
	);
13982
13983
	return this.isSpanAllowed(span, constraint, overlap, event) &&
13984
		(!eventAllowFunc || eventAllowFunc(span, event) !== false);
13985
};
13986
13987
13988
// Determines if an external event can be relocated to the given span (unzoned start/end with other misc data)
13989
Calendar.prototype.isExternalSpanAllowed = function(eventSpan, eventLocation, eventProps) {
13990
	var eventInput;
13991
	var event;
13992
13993
	// note: very similar logic is in View's reportExternalDrop
13994
	if (eventProps) {
13995
		eventInput = $.extend({}, eventProps, eventLocation);
13996
		event = this.expandEvent(
13997
			this.buildEventFromInput(eventInput)
13998
		)[0];
13999
	}
14000
14001
	if (event) {
14002
		return this.isEventSpanAllowed(eventSpan, event);
14003
	}
14004
	else { // treat it as a selection
14005
14006
		return this.isSelectionSpanAllowed(eventSpan);
14007
	}
14008
};
14009
14010
14011
// Determines the given span (unzoned start/end with other misc data) can be selected.
14012
Calendar.prototype.isSelectionSpanAllowed = function(span) {
14013
	var selectAllowFunc = this.opt('selectAllow');
14014
14015
	return this.isSpanAllowed(span, this.opt('selectConstraint'), this.opt('selectOverlap')) &&
14016
		(!selectAllowFunc || selectAllowFunc(span) !== false);
14017
};
14018
14019
14020
// Returns true if the given span (caused by an event drop/resize or a selection) is allowed to exist
14021
// according to the constraint/overlap settings.
14022
// `event` is not required if checking a selection.
14023
Calendar.prototype.isSpanAllowed = function(span, constraint, overlap, event) {
14024
	var constraintEvents;
14025
	var anyContainment;
14026
	var peerEvents;
14027
	var i, peerEvent;
14028
	var peerOverlap;
14029
14030
	// the range must be fully contained by at least one of produced constraint events
14031
	if (constraint != null) {
14032
14033
		// not treated as an event! intermediate data structure
14034
		// TODO: use ranges in the future
14035
		constraintEvents = this.constraintToEvents(constraint);
14036
		if (constraintEvents) { // not invalid
14037
14038
			anyContainment = false;
14039
			for (i = 0; i < constraintEvents.length; i++) {
14040
				if (this.spanContainsSpan(constraintEvents[i], span)) {
14041
					anyContainment = true;
14042
					break;
14043
				}
14044
			}
14045
14046
			if (!anyContainment) {
14047
				return false;
14048
			}
14049
		}
14050
	}
14051
14052
	peerEvents = this.getPeerEvents(span, event);
14053
14054
	for (i = 0; i < peerEvents.length; i++)  {
14055
		peerEvent = peerEvents[i];
14056
14057
		// there needs to be an actual intersection before disallowing anything
14058
		if (this.eventIntersectsRange(peerEvent, span)) {
14059
14060
			// evaluate overlap for the given range and short-circuit if necessary
14061
			if (overlap === false) {
14062
				return false;
14063
			}
14064
			// if the event's overlap is a test function, pass the peer event in question as the first param
14065
			else if (typeof overlap === 'function' && !overlap(peerEvent, event)) {
14066
				return false;
14067
			}
14068
14069
			// if we are computing if the given range is allowable for an event, consider the other event's
14070
			// EventObject-specific or Source-specific `overlap` property
14071
			if (event) {
14072
				peerOverlap = firstDefined(
14073
					peerEvent.overlap,
14074
					(peerEvent.source || {}).overlap
14075
					// we already considered the global `eventOverlap`
14076
				);
14077
				if (peerOverlap === false) {
14078
					return false;
14079
				}
14080
				// if the peer event's overlap is a test function, pass the subject event as the first param
14081
				if (typeof peerOverlap === 'function' && !peerOverlap(event, peerEvent)) {
14082
					return false;
14083
				}
14084
			}
14085
		}
14086
	}
14087
14088
	return true;
14089
};
14090
14091
14092
// Given an event input from the API, produces an array of event objects. Possible event inputs:
14093
// 'businessHours'
14094
// An event ID (number or string)
14095
// An object with specific start/end dates or a recurring event (like what businessHours accepts)
14096
Calendar.prototype.constraintToEvents = function(constraintInput) {
14097
14098
	if (constraintInput === 'businessHours') {
14099
		return this.getCurrentBusinessHourEvents();
14100
	}
14101
14102
	if (typeof constraintInput === 'object') {
14103
		if (constraintInput.start != null) { // needs to be event-like input
14104
			return this.expandEvent(this.buildEventFromInput(constraintInput));
14105
		}
14106
		else {
14107
			return null; // invalid
14108
		}
14109
	}
14110
14111
	return this.clientEvents(constraintInput); // probably an ID
14112
};
14113
14114
14115
// Does the event's date range intersect with the given range?
14116
// start/end already assumed to have stripped zones :(
14117
Calendar.prototype.eventIntersectsRange = function(event, range) {
14118
	var eventStart = event.start.clone().stripZone();
14119
	var eventEnd = this.getEventEnd(event).stripZone();
14120
14121
	return range.start < eventEnd && range.end > eventStart;
14122
};
14123
14124
14125
/* Business Hours
14126
-----------------------------------------------------------------------------------------*/
14127
14128
var BUSINESS_HOUR_EVENT_DEFAULTS = {
14129
	id: '_fcBusinessHours', // will relate events from different calls to expandEvent
14130
	start: '09:00',
14131
	end: '17:00',
14132
	dow: [ 1, 2, 3, 4, 5 ], // monday - friday
14133
	rendering: 'inverse-background'
14134
	// classNames are defined in businessHoursSegClasses
14135
};
14136
14137
// Return events objects for business hours within the current view.
14138
// Abuse of our event system :(
14139
Calendar.prototype.getCurrentBusinessHourEvents = function(wholeDay) {
14140
	return this.computeBusinessHourEvents(wholeDay, this.opt('businessHours'));
14141
};
14142
14143
// Given a raw input value from options, return events objects for business hours within the current view.
14144
Calendar.prototype.computeBusinessHourEvents = function(wholeDay, input) {
14145
	if (input === true) {
14146
		return this.expandBusinessHourEvents(wholeDay, [ {} ]);
14147
	}
14148
	else if ($.isPlainObject(input)) {
14149
		return this.expandBusinessHourEvents(wholeDay, [ input ]);
14150
	}
14151
	else if ($.isArray(input)) {
14152
		return this.expandBusinessHourEvents(wholeDay, input, true);
14153
	}
14154
	else {
14155
		return [];
14156
	}
14157
};
14158
14159
// inputs expected to be an array of objects.
14160
// if ignoreNoDow is true, will ignore entries that don't specify a day-of-week (dow) key.
14161
Calendar.prototype.expandBusinessHourEvents = function(wholeDay, inputs, ignoreNoDow) {
14162
	var view = this.getView();
14163
	var events = [];
14164
	var i, input;
14165
14166
	for (i = 0; i < inputs.length; i++) {
14167
		input = inputs[i];
14168
14169
		if (ignoreNoDow && !input.dow) {
14170
			continue;
14171
		}
14172
14173
		// give defaults. will make a copy
14174
		input = $.extend({}, BUSINESS_HOUR_EVENT_DEFAULTS, input);
14175
14176
		// if a whole-day series is requested, clear the start/end times
14177
		if (wholeDay) {
14178
			input.start = null;
14179
			input.end = null;
14180
		}
14181
14182
		events.push.apply(events, // append
14183
			this.expandEvent(
14184
				this.buildEventFromInput(input),
14185
				view.activeRange.start,
14186
				view.activeRange.end
14187
			)
14188
		);
14189
	}
14190
14191
	return events;
14192
};
14193
14194
;;
14195
14196
/* An abstract class for the "basic" views, as well as month view. Renders one or more rows of day cells.
14197
----------------------------------------------------------------------------------------------------------------------*/
14198
// It is a manager for a DayGrid subcomponent, which does most of the heavy lifting.
14199
// It is responsible for managing width/height.
14200
14201
var BasicView = FC.BasicView = View.extend({
14202
14203
	scroller: null,
14204
14205
	dayGridClass: DayGrid, // class the dayGrid will be instantiated from (overridable by subclasses)
14206
	dayGrid: null, // the main subcomponent that does most of the heavy lifting
14207
14208
	dayNumbersVisible: false, // display day numbers on each day cell?
14209
	colWeekNumbersVisible: false, // display week numbers along the side?
14210
	cellWeekNumbersVisible: false, // display week numbers in day cell?
14211
14212
	weekNumberWidth: null, // width of all the week-number cells running down the side
14213
14214
	headContainerEl: null, // div that hold's the dayGrid's rendered date header
14215
	headRowEl: null, // the fake row element of the day-of-week header
14216
14217
14218
	initialize: function() {
14219
		this.dayGrid = this.instantiateDayGrid();
14220
14221
		this.scroller = new Scroller({
14222
			overflowX: 'hidden',
14223
			overflowY: 'auto'
14224
		});
14225
	},
14226
14227
14228
	// Generates the DayGrid object this view needs. Draws from this.dayGridClass
14229
	instantiateDayGrid: function() {
14230
		// generate a subclass on the fly with BasicView-specific behavior
14231
		// TODO: cache this subclass
14232
		var subclass = this.dayGridClass.extend(basicDayGridMethods);
14233
14234
		return new subclass(this);
14235
	},
14236
14237
14238
	// Computes the date range that will be rendered.
14239
	buildRenderRange: function(currentRange, currentRangeUnit) {
14240
		var renderRange = View.prototype.buildRenderRange.apply(this, arguments);
14241
14242
		// year and month views should be aligned with weeks. this is already done for week
14243
		if (/^(year|month)$/.test(currentRangeUnit)) {
14244
			renderRange.start.startOf('week');
14245
14246
			// make end-of-week if not already
14247
			if (renderRange.end.weekday()) {
14248
				renderRange.end.add(1, 'week').startOf('week'); // exclusively move backwards
14249
			}
14250
		}
14251
14252
		return this.trimHiddenDays(renderRange);
14253
	},
14254
14255
14256
	// Renders the view into `this.el`, which should already be assigned
14257
	renderDates: function() {
14258
14259
		this.dayGrid.breakOnWeeks = /year|month|week/.test(this.currentRangeUnit); // do before Grid::setRange
14260
		this.dayGrid.setRange(this.renderRange);
14261
14262
		this.dayNumbersVisible = this.dayGrid.rowCnt > 1; // TODO: make grid responsible
14263
		if (this.opt('weekNumbers')) {
14264
			if (this.opt('weekNumbersWithinDays')) {
14265
				this.cellWeekNumbersVisible = true;
14266
				this.colWeekNumbersVisible = false;
14267
			}
14268
			else {
14269
				this.cellWeekNumbersVisible = false;
14270
				this.colWeekNumbersVisible = true;
14271
			};
14272
		}
14273
		this.dayGrid.numbersVisible = this.dayNumbersVisible ||
14274
			this.cellWeekNumbersVisible || this.colWeekNumbersVisible;
14275
14276
		this.el.addClass('fc-basic-view').html(this.renderSkeletonHtml());
14277
		this.renderHead();
14278
14279
		this.scroller.render();
14280
		var dayGridContainerEl = this.scroller.el.addClass('fc-day-grid-container');
14281
		var dayGridEl = $('<div class="fc-day-grid" />').appendTo(dayGridContainerEl);
14282
		this.el.find('.fc-body > tr > td').append(dayGridContainerEl);
14283
14284
		this.dayGrid.setElement(dayGridEl);
14285
		this.dayGrid.renderDates(this.hasRigidRows());
14286
	},
14287
14288
14289
	// render the day-of-week headers
14290
	renderHead: function() {
14291
		this.headContainerEl =
14292
			this.el.find('.fc-head-container')
14293
				.html(this.dayGrid.renderHeadHtml());
14294
		this.headRowEl = this.headContainerEl.find('.fc-row');
14295
	},
14296
14297
14298
	// Unrenders the content of the view. Since we haven't separated skeleton rendering from date rendering,
14299
	// always completely kill the dayGrid's rendering.
14300
	unrenderDates: function() {
14301
		this.dayGrid.unrenderDates();
14302
		this.dayGrid.removeElement();
14303
		this.scroller.destroy();
14304
	},
14305
14306
14307
	renderBusinessHours: function() {
14308
		this.dayGrid.renderBusinessHours();
14309
	},
14310
14311
14312
	unrenderBusinessHours: function() {
14313
		this.dayGrid.unrenderBusinessHours();
14314
	},
14315
14316
14317
	// Builds the HTML skeleton for the view.
14318
	// The day-grid component will render inside of a container defined by this HTML.
14319
	renderSkeletonHtml: function() {
14320
		return '' +
14321
			'<table>' +
14322
				'<thead class="fc-head">' +
14323
					'<tr>' +
14324
						'<td class="fc-head-container ' + this.widgetHeaderClass + '"></td>' +
14325
					'</tr>' +
14326
				'</thead>' +
14327
				'<tbody class="fc-body">' +
14328
					'<tr>' +
14329
						'<td class="' + this.widgetContentClass + '"></td>' +
14330
					'</tr>' +
14331
				'</tbody>' +
14332
			'</table>';
14333
	},
14334
14335
14336
	// Generates an HTML attribute string for setting the width of the week number column, if it is known
14337
	weekNumberStyleAttr: function() {
14338
		if (this.weekNumberWidth !== null) {
14339
			return 'style="width:' + this.weekNumberWidth + 'px"';
14340
		}
14341
		return '';
14342
	},
14343
14344
14345
	// Determines whether each row should have a constant height
14346
	hasRigidRows: function() {
14347
		var eventLimit = this.opt('eventLimit');
14348
		return eventLimit && typeof eventLimit !== 'number';
14349
	},
14350
14351
14352
	/* Dimensions
14353
	------------------------------------------------------------------------------------------------------------------*/
14354
14355
14356
	// Refreshes the horizontal dimensions of the view
14357
	updateWidth: function() {
14358
		if (this.colWeekNumbersVisible) {
14359
			// Make sure all week number cells running down the side have the same width.
14360
			// Record the width for cells created later.
14361
			this.weekNumberWidth = matchCellWidths(
14362
				this.el.find('.fc-week-number')
14363
			);
14364
		}
14365
	},
14366
14367
14368
	// Adjusts the vertical dimensions of the view to the specified values
14369
	setHeight: function(totalHeight, isAuto) {
14370
		var eventLimit = this.opt('eventLimit');
14371
		var scrollerHeight;
14372
		var scrollbarWidths;
14373
14374
		// reset all heights to be natural
14375
		this.scroller.clear();
14376
		uncompensateScroll(this.headRowEl);
14377
14378
		this.dayGrid.removeSegPopover(); // kill the "more" popover if displayed
14379
14380
		// is the event limit a constant level number?
14381
		if (eventLimit && typeof eventLimit === 'number') {
14382
			this.dayGrid.limitRows(eventLimit); // limit the levels first so the height can redistribute after
14383
		}
14384
14385
		// distribute the height to the rows
14386
		// (totalHeight is a "recommended" value if isAuto)
14387
		scrollerHeight = this.computeScrollerHeight(totalHeight);
14388
		this.setGridHeight(scrollerHeight, isAuto);
14389
14390
		// is the event limit dynamically calculated?
14391
		if (eventLimit && typeof eventLimit !== 'number') {
14392
			this.dayGrid.limitRows(eventLimit); // limit the levels after the grid's row heights have been set
14393
		}
14394
14395
		if (!isAuto) { // should we force dimensions of the scroll container?
14396
14397
			this.scroller.setHeight(scrollerHeight);
14398
			scrollbarWidths = this.scroller.getScrollbarWidths();
14399
14400
			if (scrollbarWidths.left || scrollbarWidths.right) { // using scrollbars?
14401
14402
				compensateScroll(this.headRowEl, scrollbarWidths);
14403
14404
				// doing the scrollbar compensation might have created text overflow which created more height. redo
14405
				scrollerHeight = this.computeScrollerHeight(totalHeight);
14406
				this.scroller.setHeight(scrollerHeight);
14407
			}
14408
14409
			// guarantees the same scrollbar widths
14410
			this.scroller.lockOverflow(scrollbarWidths);
14411
		}
14412
	},
14413
14414
14415
	// given a desired total height of the view, returns what the height of the scroller should be
14416
	computeScrollerHeight: function(totalHeight) {
14417
		return totalHeight -
14418
			subtractInnerElHeight(this.el, this.scroller.el); // everything that's NOT the scroller
14419
	},
14420
14421
14422
	// Sets the height of just the DayGrid component in this view
14423
	setGridHeight: function(height, isAuto) {
14424
		if (isAuto) {
14425
			undistributeHeight(this.dayGrid.rowEls); // let the rows be their natural height with no expanding
14426
		}
14427
		else {
14428
			distributeHeight(this.dayGrid.rowEls, height, true); // true = compensate for height-hogging rows
14429
		}
14430
	},
14431
14432
14433
	/* Scroll
14434
	------------------------------------------------------------------------------------------------------------------*/
14435
14436
14437
	computeInitialDateScroll: function() {
14438
		return { top: 0 };
14439
	},
14440
14441
14442
	queryDateScroll: function() {
14443
		return { top: this.scroller.getScrollTop() };
14444
	},
14445
14446
14447
	applyDateScroll: function(scroll) {
14448
		if (scroll.top !== undefined) {
14449
			this.scroller.setScrollTop(scroll.top);
14450
		}
14451
	},
14452
14453
14454
	/* Hit Areas
14455
	------------------------------------------------------------------------------------------------------------------*/
14456
	// forward all hit-related method calls to dayGrid
14457
14458
14459
	hitsNeeded: function() {
14460
		this.dayGrid.hitsNeeded();
14461
	},
14462
14463
14464
	hitsNotNeeded: function() {
14465
		this.dayGrid.hitsNotNeeded();
14466
	},
14467
14468
14469
	prepareHits: function() {
14470
		this.dayGrid.prepareHits();
14471
	},
14472
14473
14474
	releaseHits: function() {
14475
		this.dayGrid.releaseHits();
14476
	},
14477
14478
14479
	queryHit: function(left, top) {
14480
		return this.dayGrid.queryHit(left, top);
14481
	},
14482
14483
14484
	getHitSpan: function(hit) {
14485
		return this.dayGrid.getHitSpan(hit);
14486
	},
14487
14488
14489
	getHitEl: function(hit) {
14490
		return this.dayGrid.getHitEl(hit);
14491
	},
14492
14493
14494
	/* Events
14495
	------------------------------------------------------------------------------------------------------------------*/
14496
14497
14498
	// Renders the given events onto the view and populates the segments array
14499
	renderEvents: function(events) {
14500
		this.dayGrid.renderEvents(events);
14501
14502
		this.updateHeight(); // must compensate for events that overflow the row
14503
	},
14504
14505
14506
	// Retrieves all segment objects that are rendered in the view
14507
	getEventSegs: function() {
14508
		return this.dayGrid.getEventSegs();
14509
	},
14510
14511
14512
	// Unrenders all event elements and clears internal segment data
14513
	unrenderEvents: function() {
14514
		this.dayGrid.unrenderEvents();
14515
14516
		// we DON'T need to call updateHeight() because
14517
		// a renderEvents() call always happens after this, which will eventually call updateHeight()
14518
	},
14519
14520
14521
	/* Dragging (for both events and external elements)
14522
	------------------------------------------------------------------------------------------------------------------*/
14523
14524
14525
	// A returned value of `true` signals that a mock "helper" event has been rendered.
14526
	renderDrag: function(dropLocation, seg) {
14527
		return this.dayGrid.renderDrag(dropLocation, seg);
14528
	},
14529
14530
14531
	unrenderDrag: function() {
14532
		this.dayGrid.unrenderDrag();
14533
	},
14534
14535
14536
	/* Selection
14537
	------------------------------------------------------------------------------------------------------------------*/
14538
14539
14540
	// Renders a visual indication of a selection
14541
	renderSelection: function(span) {
14542
		this.dayGrid.renderSelection(span);
14543
	},
14544
14545
14546
	// Unrenders a visual indications of a selection
14547
	unrenderSelection: function() {
14548
		this.dayGrid.unrenderSelection();
14549
	}
14550
14551
});
14552
14553
14554
// Methods that will customize the rendering behavior of the BasicView's dayGrid
14555
var basicDayGridMethods = {
14556
14557
14558
	// Generates the HTML that will go before the day-of week header cells
14559
	renderHeadIntroHtml: function() {
14560
		var view = this.view;
14561
14562
		if (view.colWeekNumbersVisible) {
14563
			return '' +
14564
				'<th class="fc-week-number ' + view.widgetHeaderClass + '" ' + view.weekNumberStyleAttr() + '>' +
14565
					'<span>' + // needed for matchCellWidths
14566
						htmlEscape(view.opt('weekNumberTitle')) +
14567
					'</span>' +
14568
				'</th>';
14569
		}
14570
14571
		return '';
14572
	},
14573
14574
14575
	// Generates the HTML that will go before content-skeleton cells that display the day/week numbers
14576
	renderNumberIntroHtml: function(row) {
14577
		var view = this.view;
14578
		var weekStart = this.getCellDate(row, 0);
14579
14580
		if (view.colWeekNumbersVisible) {
14581
			return '' +
14582
				'<td class="fc-week-number" ' + view.weekNumberStyleAttr() + '>' +
14583
					view.buildGotoAnchorHtml( // aside from link, important for matchCellWidths
14584
						{ date: weekStart, type: 'week', forceOff: this.colCnt === 1 },
14585
						weekStart.format('w') // inner HTML
14586
					) +
14587
				'</td>';
14588
		}
14589
14590
		return '';
14591
	},
14592
14593
14594
	// Generates the HTML that goes before the day bg cells for each day-row
14595
	renderBgIntroHtml: function() {
14596
		var view = this.view;
14597
14598
		if (view.colWeekNumbersVisible) {
14599
			return '<td class="fc-week-number ' + view.widgetContentClass + '" ' +
14600
				view.weekNumberStyleAttr() + '></td>';
14601
		}
14602
14603
		return '';
14604
	},
14605
14606
14607
	// Generates the HTML that goes before every other type of row generated by DayGrid.
14608
	// Affects helper-skeleton and highlight-skeleton rows.
14609
	renderIntroHtml: function() {
14610
		var view = this.view;
14611
14612
		if (view.colWeekNumbersVisible) {
14613
			return '<td class="fc-week-number" ' + view.weekNumberStyleAttr() + '></td>';
14614
		}
14615
14616
		return '';
14617
	}
14618
14619
};
14620
14621
;;
14622
14623
/* A month view with day cells running in rows (one-per-week) and columns
14624
----------------------------------------------------------------------------------------------------------------------*/
14625
14626
var MonthView = FC.MonthView = BasicView.extend({
14627
14628
14629
	// Computes the date range that will be rendered.
14630
	buildRenderRange: function() {
14631
		var renderRange = BasicView.prototype.buildRenderRange.apply(this, arguments);
14632
		var rowCnt;
14633
14634
		// ensure 6 weeks
14635
		if (this.isFixedWeeks()) {
14636
			rowCnt = Math.ceil( // could be partial weeks due to hiddenDays
14637
				renderRange.end.diff(renderRange.start, 'weeks', true) // dontRound=true
14638
			);
14639
			renderRange.end.add(6 - rowCnt, 'weeks');
14640
		}
14641
14642
		return renderRange;
14643
	},
14644
14645
14646
	// Overrides the default BasicView behavior to have special multi-week auto-height logic
14647
	setGridHeight: function(height, isAuto) {
14648
14649
		// if auto, make the height of each row the height that it would be if there were 6 weeks
14650
		if (isAuto) {
14651
			height *= this.rowCnt / 6;
14652
		}
14653
14654
		distributeHeight(this.dayGrid.rowEls, height, !isAuto); // if auto, don't compensate for height-hogging rows
14655
	},
14656
14657
14658
	isFixedWeeks: function() {
14659
		return this.opt('fixedWeekCount');
14660
	}
14661
14662
});
14663
14664
;;
14665
14666
fcViews.basic = {
14667
	'class': BasicView
14668
};
14669
14670
fcViews.basicDay = {
14671
	type: 'basic',
14672
	duration: { days: 1 }
14673
};
14674
14675
fcViews.basicWeek = {
14676
	type: 'basic',
14677
	duration: { weeks: 1 }
14678
};
14679
14680
fcViews.month = {
14681
	'class': MonthView,
14682
	duration: { months: 1 }, // important for prev/next
14683
	defaults: {
14684
		fixedWeekCount: true
14685
	}
14686
};
14687
;;
14688
14689
/* An abstract class for all agenda-related views. Displays one more columns with time slots running vertically.
14690
----------------------------------------------------------------------------------------------------------------------*/
14691
// Is a manager for the TimeGrid subcomponent and possibly the DayGrid subcomponent (if allDaySlot is on).
14692
// Responsible for managing width/height.
14693
14694
var AgendaView = FC.AgendaView = View.extend({
14695
14696
	scroller: null,
14697
14698
	timeGridClass: TimeGrid, // class used to instantiate the timeGrid. subclasses can override
14699
	timeGrid: null, // the main time-grid subcomponent of this view
14700
14701
	dayGridClass: DayGrid, // class used to instantiate the dayGrid. subclasses can override
14702
	dayGrid: null, // the "all-day" subcomponent. if all-day is turned off, this will be null
14703
14704
	axisWidth: null, // the width of the time axis running down the side
14705
14706
	headContainerEl: null, // div that hold's the timeGrid's rendered date header
14707
	noScrollRowEls: null, // set of fake row elements that must compensate when scroller has scrollbars
14708
14709
	// when the time-grid isn't tall enough to occupy the given height, we render an <hr> underneath
14710
	bottomRuleEl: null,
14711
14712
	// indicates that minTime/maxTime affects rendering
14713
	usesMinMaxTime: true,
14714
14715
14716
	initialize: function() {
14717
		this.timeGrid = this.instantiateTimeGrid();
14718
14719
		if (this.opt('allDaySlot')) { // should we display the "all-day" area?
14720
			this.dayGrid = this.instantiateDayGrid(); // the all-day subcomponent of this view
14721
		}
14722
14723
		this.scroller = new Scroller({
14724
			overflowX: 'hidden',
14725
			overflowY: 'auto'
14726
		});
14727
	},
14728
14729
14730
	// Instantiates the TimeGrid object this view needs. Draws from this.timeGridClass
14731
	instantiateTimeGrid: function() {
14732
		var subclass = this.timeGridClass.extend(agendaTimeGridMethods);
14733
14734
		return new subclass(this);
14735
	},
14736
14737
14738
	// Instantiates the DayGrid object this view might need. Draws from this.dayGridClass
14739
	instantiateDayGrid: function() {
14740
		var subclass = this.dayGridClass.extend(agendaDayGridMethods);
14741
14742
		return new subclass(this);
14743
	},
14744
14745
14746
	/* Rendering
14747
	------------------------------------------------------------------------------------------------------------------*/
14748
14749
14750
	// Renders the view into `this.el`, which has already been assigned
14751
	renderDates: function() {
14752
14753
		this.timeGrid.setRange(this.renderRange);
14754
14755
		if (this.dayGrid) {
14756
			this.dayGrid.setRange(this.renderRange);
14757
		}
14758
14759
		this.el.addClass('fc-agenda-view').html(this.renderSkeletonHtml());
14760
		this.renderHead();
14761
14762
		this.scroller.render();
14763
		var timeGridWrapEl = this.scroller.el.addClass('fc-time-grid-container');
14764
		var timeGridEl = $('<div class="fc-time-grid" />').appendTo(timeGridWrapEl);
14765
		this.el.find('.fc-body > tr > td').append(timeGridWrapEl);
14766
14767
		this.timeGrid.setElement(timeGridEl);
14768
		this.timeGrid.renderDates();
14769
14770
		// the <hr> that sometimes displays under the time-grid
14771
		this.bottomRuleEl = $('<hr class="fc-divider ' + this.widgetHeaderClass + '"/>')
14772
			.appendTo(this.timeGrid.el); // inject it into the time-grid
14773
14774
		if (this.dayGrid) {
14775
			this.dayGrid.setElement(this.el.find('.fc-day-grid'));
14776
			this.dayGrid.renderDates();
14777
14778
			// have the day-grid extend it's coordinate area over the <hr> dividing the two grids
14779
			this.dayGrid.bottomCoordPadding = this.dayGrid.el.next('hr').outerHeight();
14780
		}
14781
14782
		this.noScrollRowEls = this.el.find('.fc-row:not(.fc-scroller *)'); // fake rows not within the scroller
14783
	},
14784
14785
14786
	// render the day-of-week headers
14787
	renderHead: function() {
14788
		this.headContainerEl =
14789
			this.el.find('.fc-head-container')
14790
				.html(this.timeGrid.renderHeadHtml());
14791
	},
14792
14793
14794
	// Unrenders the content of the view. Since we haven't separated skeleton rendering from date rendering,
14795
	// always completely kill each grid's rendering.
14796
	unrenderDates: function() {
14797
		this.timeGrid.unrenderDates();
14798
		this.timeGrid.removeElement();
14799
14800
		if (this.dayGrid) {
14801
			this.dayGrid.unrenderDates();
14802
			this.dayGrid.removeElement();
14803
		}
14804
14805
		this.scroller.destroy();
14806
	},
14807
14808
14809
	// Builds the HTML skeleton for the view.
14810
	// The day-grid and time-grid components will render inside containers defined by this HTML.
14811
	renderSkeletonHtml: function() {
14812
		return '' +
14813
			'<table>' +
14814
				'<thead class="fc-head">' +
14815
					'<tr>' +
14816
						'<td class="fc-head-container ' + this.widgetHeaderClass + '"></td>' +
14817
					'</tr>' +
14818
				'</thead>' +
14819
				'<tbody class="fc-body">' +
14820
					'<tr>' +
14821
						'<td class="' + this.widgetContentClass + '">' +
14822
							(this.dayGrid ?
14823
								'<div class="fc-day-grid"/>' +
14824
								'<hr class="fc-divider ' + this.widgetHeaderClass + '"/>' :
14825
								''
14826
								) +
14827
						'</td>' +
14828
					'</tr>' +
14829
				'</tbody>' +
14830
			'</table>';
14831
	},
14832
14833
14834
	// Generates an HTML attribute string for setting the width of the axis, if it is known
14835
	axisStyleAttr: function() {
14836
		if (this.axisWidth !== null) {
14837
			 return 'style="width:' + this.axisWidth + 'px"';
14838
		}
14839
		return '';
14840
	},
14841
14842
14843
	/* Business Hours
14844
	------------------------------------------------------------------------------------------------------------------*/
14845
14846
14847
	renderBusinessHours: function() {
14848
		this.timeGrid.renderBusinessHours();
14849
14850
		if (this.dayGrid) {
14851
			this.dayGrid.renderBusinessHours();
14852
		}
14853
	},
14854
14855
14856
	unrenderBusinessHours: function() {
14857
		this.timeGrid.unrenderBusinessHours();
14858
14859
		if (this.dayGrid) {
14860
			this.dayGrid.unrenderBusinessHours();
14861
		}
14862
	},
14863
14864
14865
	/* Now Indicator
14866
	------------------------------------------------------------------------------------------------------------------*/
14867
14868
14869
	getNowIndicatorUnit: function() {
14870
		return this.timeGrid.getNowIndicatorUnit();
14871
	},
14872
14873
14874
	renderNowIndicator: function(date) {
14875
		this.timeGrid.renderNowIndicator(date);
14876
	},
14877
14878
14879
	unrenderNowIndicator: function() {
14880
		this.timeGrid.unrenderNowIndicator();
14881
	},
14882
14883
14884
	/* Dimensions
14885
	------------------------------------------------------------------------------------------------------------------*/
14886
14887
14888
	updateSize: function(isResize) {
14889
		this.timeGrid.updateSize(isResize);
14890
14891
		View.prototype.updateSize.call(this, isResize); // call the super-method
14892
	},
14893
14894
14895
	// Refreshes the horizontal dimensions of the view
14896
	updateWidth: function() {
14897
		// make all axis cells line up, and record the width so newly created axis cells will have it
14898
		this.axisWidth = matchCellWidths(this.el.find('.fc-axis'));
14899
	},
14900
14901
14902
	// Adjusts the vertical dimensions of the view to the specified values
14903
	setHeight: function(totalHeight, isAuto) {
14904
		var eventLimit;
14905
		var scrollerHeight;
14906
		var scrollbarWidths;
14907
14908
		// reset all dimensions back to the original state
14909
		this.bottomRuleEl.hide(); // .show() will be called later if this <hr> is necessary
14910
		this.scroller.clear(); // sets height to 'auto' and clears overflow
14911
		uncompensateScroll(this.noScrollRowEls);
14912
14913
		// limit number of events in the all-day area
14914
		if (this.dayGrid) {
14915
			this.dayGrid.removeSegPopover(); // kill the "more" popover if displayed
14916
14917
			eventLimit = this.opt('eventLimit');
14918
			if (eventLimit && typeof eventLimit !== 'number') {
14919
				eventLimit = AGENDA_ALL_DAY_EVENT_LIMIT; // make sure "auto" goes to a real number
14920
			}
14921
			if (eventLimit) {
14922
				this.dayGrid.limitRows(eventLimit);
14923
			}
14924
		}
14925
14926
		if (!isAuto) { // should we force dimensions of the scroll container?
14927
14928
			scrollerHeight = this.computeScrollerHeight(totalHeight);
14929
			this.scroller.setHeight(scrollerHeight);
14930
			scrollbarWidths = this.scroller.getScrollbarWidths();
14931
14932
			if (scrollbarWidths.left || scrollbarWidths.right) { // using scrollbars?
14933
14934
				// make the all-day and header rows lines up
14935
				compensateScroll(this.noScrollRowEls, scrollbarWidths);
14936
14937
				// the scrollbar compensation might have changed text flow, which might affect height, so recalculate
14938
				// and reapply the desired height to the scroller.
14939
				scrollerHeight = this.computeScrollerHeight(totalHeight);
14940
				this.scroller.setHeight(scrollerHeight);
14941
			}
14942
14943
			// guarantees the same scrollbar widths
14944
			this.scroller.lockOverflow(scrollbarWidths);
14945
14946
			// if there's any space below the slats, show the horizontal rule.
14947
			// this won't cause any new overflow, because lockOverflow already called.
14948
			if (this.timeGrid.getTotalSlatHeight() < scrollerHeight) {
14949
				this.bottomRuleEl.show();
14950
			}
14951
		}
14952
	},
14953
14954
14955
	// given a desired total height of the view, returns what the height of the scroller should be
14956
	computeScrollerHeight: function(totalHeight) {
14957
		return totalHeight -
14958
			subtractInnerElHeight(this.el, this.scroller.el); // everything that's NOT the scroller
14959
	},
14960
14961
14962
	/* Scroll
14963
	------------------------------------------------------------------------------------------------------------------*/
14964
14965
14966
	// Computes the initial pre-configured scroll state prior to allowing the user to change it
14967
	computeInitialDateScroll: function() {
14968
		var scrollTime = moment.duration(this.opt('scrollTime'));
14969
		var top = this.timeGrid.computeTimeTop(scrollTime);
14970
14971
		// zoom can give weird floating-point values. rather scroll a little bit further
14972
		top = Math.ceil(top);
14973
14974
		if (top) {
14975
			top++; // to overcome top border that slots beyond the first have. looks better
14976
		}
14977
14978
		return { top: top };
14979
	},
14980
14981
14982
	queryDateScroll: function() {
14983
		return { top: this.scroller.getScrollTop() };
14984
	},
14985
14986
14987
	applyDateScroll: function(scroll) {
14988
		if (scroll.top !== undefined) {
14989
			this.scroller.setScrollTop(scroll.top);
14990
		}
14991
	},
14992
14993
14994
	/* Hit Areas
14995
	------------------------------------------------------------------------------------------------------------------*/
14996
	// forward all hit-related method calls to the grids (dayGrid might not be defined)
14997
14998
14999
	hitsNeeded: function() {
15000
		this.timeGrid.hitsNeeded();
15001
		if (this.dayGrid) {
15002
			this.dayGrid.hitsNeeded();
15003
		}
15004
	},
15005
15006
15007
	hitsNotNeeded: function() {
15008
		this.timeGrid.hitsNotNeeded();
15009
		if (this.dayGrid) {
15010
			this.dayGrid.hitsNotNeeded();
15011
		}
15012
	},
15013
15014
15015
	prepareHits: function() {
15016
		this.timeGrid.prepareHits();
15017
		if (this.dayGrid) {
15018
			this.dayGrid.prepareHits();
15019
		}
15020
	},
15021
15022
15023
	releaseHits: function() {
15024
		this.timeGrid.releaseHits();
15025
		if (this.dayGrid) {
15026
			this.dayGrid.releaseHits();
15027
		}
15028
	},
15029
15030
15031
	queryHit: function(left, top) {
15032
		var hit = this.timeGrid.queryHit(left, top);
15033
15034
		if (!hit && this.dayGrid) {
15035
			hit = this.dayGrid.queryHit(left, top);
15036
		}
15037
15038
		return hit;
15039
	},
15040
15041
15042
	getHitSpan: function(hit) {
15043
		// TODO: hit.component is set as a hack to identify where the hit came from
15044
		return hit.component.getHitSpan(hit);
15045
	},
15046
15047
15048
	getHitEl: function(hit) {
15049
		// TODO: hit.component is set as a hack to identify where the hit came from
15050
		return hit.component.getHitEl(hit);
15051
	},
15052
15053
15054
	/* Events
15055
	------------------------------------------------------------------------------------------------------------------*/
15056
15057
15058
	// Renders events onto the view and populates the View's segment array
15059
	renderEvents: function(events) {
15060
		var dayEvents = [];
15061
		var timedEvents = [];
15062
		var daySegs = [];
15063
		var timedSegs;
15064
		var i;
15065
15066
		// separate the events into all-day and timed
15067
		for (i = 0; i < events.length; i++) {
15068
			if (events[i].allDay) {
15069
				dayEvents.push(events[i]);
15070
			}
15071
			else {
15072
				timedEvents.push(events[i]);
15073
			}
15074
		}
15075
15076
		// render the events in the subcomponents
15077
		timedSegs = this.timeGrid.renderEvents(timedEvents);
15078
		if (this.dayGrid) {
15079
			daySegs = this.dayGrid.renderEvents(dayEvents);
15080
		}
15081
15082
		// the all-day area is flexible and might have a lot of events, so shift the height
15083
		this.updateHeight();
15084
	},
15085
15086
15087
	// Retrieves all segment objects that are rendered in the view
15088
	getEventSegs: function() {
15089
		return this.timeGrid.getEventSegs().concat(
15090
			this.dayGrid ? this.dayGrid.getEventSegs() : []
15091
		);
15092
	},
15093
15094
15095
	// Unrenders all event elements and clears internal segment data
15096
	unrenderEvents: function() {
15097
15098
		// unrender the events in the subcomponents
15099
		this.timeGrid.unrenderEvents();
15100
		if (this.dayGrid) {
15101
			this.dayGrid.unrenderEvents();
15102
		}
15103
15104
		// we DON'T need to call updateHeight() because
15105
		// a renderEvents() call always happens after this, which will eventually call updateHeight()
15106
	},
15107
15108
15109
	/* Dragging (for events and external elements)
15110
	------------------------------------------------------------------------------------------------------------------*/
15111
15112
15113
	// A returned value of `true` signals that a mock "helper" event has been rendered.
15114
	renderDrag: function(dropLocation, seg) {
15115
		if (dropLocation.start.hasTime()) {
15116
			return this.timeGrid.renderDrag(dropLocation, seg);
15117
		}
15118
		else if (this.dayGrid) {
15119
			return this.dayGrid.renderDrag(dropLocation, seg);
15120
		}
15121
	},
15122
15123
15124
	unrenderDrag: function() {
15125
		this.timeGrid.unrenderDrag();
15126
		if (this.dayGrid) {
15127
			this.dayGrid.unrenderDrag();
15128
		}
15129
	},
15130
15131
15132
	/* Selection
15133
	------------------------------------------------------------------------------------------------------------------*/
15134
15135
15136
	// Renders a visual indication of a selection
15137
	renderSelection: function(span) {
15138
		if (span.start.hasTime() || span.end.hasTime()) {
15139
			this.timeGrid.renderSelection(span);
15140
		}
15141
		else if (this.dayGrid) {
15142
			this.dayGrid.renderSelection(span);
15143
		}
15144
	},
15145
15146
15147
	// Unrenders a visual indications of a selection
15148
	unrenderSelection: function() {
15149
		this.timeGrid.unrenderSelection();
15150
		if (this.dayGrid) {
15151
			this.dayGrid.unrenderSelection();
15152
		}
15153
	}
15154
15155
});
15156
15157
15158
// Methods that will customize the rendering behavior of the AgendaView's timeGrid
15159
// TODO: move into TimeGrid
15160
var agendaTimeGridMethods = {
15161
15162
15163
	// Generates the HTML that will go before the day-of week header cells
15164
	renderHeadIntroHtml: function() {
15165
		var view = this.view;
15166
		var weekText;
15167
15168
		if (view.opt('weekNumbers')) {
15169
			weekText = this.start.format(view.opt('smallWeekFormat'));
15170
15171
			return '' +
15172
				'<th class="fc-axis fc-week-number ' + view.widgetHeaderClass + '" ' + view.axisStyleAttr() + '>' +
15173
					view.buildGotoAnchorHtml( // aside from link, important for matchCellWidths
15174
						{ date: this.start, type: 'week', forceOff: this.colCnt > 1 },
15175
						htmlEscape(weekText) // inner HTML
15176
					) +
15177
				'</th>';
15178
		}
15179
		else {
15180
			return '<th class="fc-axis ' + view.widgetHeaderClass + '" ' + view.axisStyleAttr() + '></th>';
15181
		}
15182
	},
15183
15184
15185
	// Generates the HTML that goes before the bg of the TimeGrid slot area. Long vertical column.
15186
	renderBgIntroHtml: function() {
15187
		var view = this.view;
15188
15189
		return '<td class="fc-axis ' + view.widgetContentClass + '" ' + view.axisStyleAttr() + '></td>';
15190
	},
15191
15192
15193
	// Generates the HTML that goes before all other types of cells.
15194
	// Affects content-skeleton, helper-skeleton, highlight-skeleton for both the time-grid and day-grid.
15195
	renderIntroHtml: function() {
15196
		var view = this.view;
15197
15198
		return '<td class="fc-axis" ' + view.axisStyleAttr() + '></td>';
15199
	}
15200
15201
};
15202
15203
15204
// Methods that will customize the rendering behavior of the AgendaView's dayGrid
15205
var agendaDayGridMethods = {
15206
15207
15208
	// Generates the HTML that goes before the all-day cells
15209
	renderBgIntroHtml: function() {
15210
		var view = this.view;
15211
15212
		return '' +
15213
			'<td class="fc-axis ' + view.widgetContentClass + '" ' + view.axisStyleAttr() + '>' +
15214
				'<span>' + // needed for matchCellWidths
15215
					view.getAllDayHtml() +
15216
				'</span>' +
15217
			'</td>';
15218
	},
15219
15220
15221
	// Generates the HTML that goes before all other types of cells.
15222
	// Affects content-skeleton, helper-skeleton, highlight-skeleton for both the time-grid and day-grid.
15223
	renderIntroHtml: function() {
15224
		var view = this.view;
15225
15226
		return '<td class="fc-axis" ' + view.axisStyleAttr() + '></td>';
15227
	}
15228
15229
};
15230
15231
;;
15232
15233
var AGENDA_ALL_DAY_EVENT_LIMIT = 5;
15234
15235
// potential nice values for the slot-duration and interval-duration
15236
// from largest to smallest
15237
var AGENDA_STOCK_SUB_DURATIONS = [
15238
	{ hours: 1 },
15239
	{ minutes: 30 },
15240
	{ minutes: 15 },
15241
	{ seconds: 30 },
15242
	{ seconds: 15 }
15243
];
15244
15245
fcViews.agenda = {
15246
	'class': AgendaView,
15247
	defaults: {
15248
		allDaySlot: true,
15249
		slotDuration: '00:30:00',
15250
		slotEventOverlap: true // a bad name. confused with overlap/constraint system
15251
	}
15252
};
15253
15254
fcViews.agendaDay = {
15255
	type: 'agenda',
15256
	duration: { days: 1 }
15257
};
15258
15259
fcViews.agendaWeek = {
15260
	type: 'agenda',
15261
	duration: { weeks: 1 }
15262
};
15263
;;
15264
15265
/*
15266
Responsible for the scroller, and forwarding event-related actions into the "grid"
15267
*/
15268
var ListView = View.extend({
15269
15270
	grid: null,
15271
	scroller: null,
15272
15273
	initialize: function() {
15274
		this.grid = new ListViewGrid(this);
15275
		this.scroller = new Scroller({
15276
			overflowX: 'hidden',
15277
			overflowY: 'auto'
15278
		});
15279
	},
15280
15281
	renderSkeleton: function() {
15282
		this.el.addClass(
15283
			'fc-list-view ' +
15284
			this.widgetContentClass
15285
		);
15286
15287
		this.scroller.render();
15288
		this.scroller.el.appendTo(this.el);
15289
15290
		this.grid.setElement(this.scroller.scrollEl);
15291
	},
15292
15293
	unrenderSkeleton: function() {
15294
		this.scroller.destroy(); // will remove the Grid too
15295
	},
15296
15297
	setHeight: function(totalHeight, isAuto) {
15298
		this.scroller.setHeight(this.computeScrollerHeight(totalHeight));
15299
	},
15300
15301
	computeScrollerHeight: function(totalHeight) {
15302
		return totalHeight -
15303
			subtractInnerElHeight(this.el, this.scroller.el); // everything that's NOT the scroller
15304
	},
15305
15306
	renderDates: function() {
15307
		this.grid.setRange(this.renderRange); // needs to process range-related options
15308
	},
15309
15310
	renderEvents: function(events) {
15311
		this.grid.renderEvents(events);
15312
	},
15313
15314
	unrenderEvents: function() {
15315
		this.grid.unrenderEvents();
15316
	},
15317
15318
	isEventResizable: function(event) {
15319
		return false;
15320
	},
15321
15322
	isEventDraggable: function(event) {
15323
		return false;
15324
	}
15325
15326
});
15327
15328
/*
15329
Responsible for event rendering and user-interaction.
15330
Its "el" is the inner-content of the above view's scroller.
15331
*/
15332
var ListViewGrid = Grid.extend({
15333
15334
	segSelector: '.fc-list-item', // which elements accept event actions
15335
	hasDayInteractions: false, // no day selection or day clicking
15336
15337
	// slices by day
15338
	spanToSegs: function(span) {
15339
		var view = this.view;
15340
		var dayStart = view.renderRange.start.clone().time(0); // timed, so segs get times!
15341
		var dayIndex = 0;
15342
		var seg;
15343
		var segs = [];
15344
15345
		while (dayStart < view.renderRange.end) {
15346
15347
			seg = intersectRanges(span, {
15348
				start: dayStart,
15349
				end: dayStart.clone().add(1, 'day')
15350
			});
15351
15352
			if (seg) {
15353
				seg.dayIndex = dayIndex;
15354
				segs.push(seg);
15355
			}
15356
15357
			dayStart.add(1, 'day');
15358
			dayIndex++;
15359
15360
			// detect when span won't go fully into the next day,
15361
			// and mutate the latest seg to the be the end.
15362
			if (
15363
				seg && !seg.isEnd && span.end.hasTime() &&
15364
				span.end < dayStart.clone().add(this.view.nextDayThreshold)
15365
			) {
15366
				seg.end = span.end.clone();
15367
				seg.isEnd = true;
15368
				break;
15369
			}
15370
		}
15371
15372
		return segs;
15373
	},
15374
15375
	// like "4:00am"
15376
	computeEventTimeFormat: function() {
15377
		return this.view.opt('mediumTimeFormat');
15378
	},
15379
15380
	// for events with a url, the whole <tr> should be clickable,
15381
	// but it's impossible to wrap with an <a> tag. simulate this.
15382
	handleSegClick: function(seg, ev) {
15383
		var url;
15384
15385
		Grid.prototype.handleSegClick.apply(this, arguments); // super. might prevent the default action
15386
15387
		// not clicking on or within an <a> with an href
15388
		if (!$(ev.target).closest('a[href]').length) {
15389
			url = seg.event.url;
15390
			if (url && !ev.isDefaultPrevented()) { // jsEvent not cancelled in handler
15391
				window.location.href = url; // simulate link click
15392
			}
15393
		}
15394
	},
15395
15396
	// returns list of foreground segs that were actually rendered
15397
	renderFgSegs: function(segs) {
15398
		segs = this.renderFgSegEls(segs); // might filter away hidden events
15399
15400
		if (!segs.length) {
15401
			this.renderEmptyMessage();
15402
		}
15403
		else {
15404
			this.renderSegList(segs);
15405
		}
15406
15407
		return segs;
15408
	},
15409
15410
	renderEmptyMessage: function() {
15411
		this.el.html(
15412
			'<div class="fc-list-empty-wrap2">' + // TODO: try less wraps
15413
			'<div class="fc-list-empty-wrap1">' +
15414
			'<div class="fc-list-empty">' +
15415
				htmlEscape(this.view.opt('noEventsMessage')) +
15416
			'</div>' +
15417
			'</div>' +
15418
			'</div>'
15419
		);
15420
	},
15421
15422
	// render the event segments in the view
15423
	renderSegList: function(allSegs) {
15424
		var segsByDay = this.groupSegsByDay(allSegs); // sparse array
15425
		var dayIndex;
15426
		var daySegs;
15427
		var i;
15428
		var tableEl = $('<table class="fc-list-table"><tbody/></table>');
15429
		var tbodyEl = tableEl.find('tbody');
15430
15431
		for (dayIndex = 0; dayIndex < segsByDay.length; dayIndex++) {
15432
			daySegs = segsByDay[dayIndex];
15433
			if (daySegs) { // sparse array, so might be undefined
15434
15435
				// append a day header
15436
				tbodyEl.append(this.dayHeaderHtml(
15437
					this.view.renderRange.start.clone().add(dayIndex, 'days')
15438
				));
15439
15440
				this.sortEventSegs(daySegs);
15441
15442
				for (i = 0; i < daySegs.length; i++) {
15443
					tbodyEl.append(daySegs[i].el); // append event row
15444
				}
15445
			}
15446
		}
15447
15448
		this.el.empty().append(tableEl);
15449
	},
15450
15451
	// Returns a sparse array of arrays, segs grouped by their dayIndex
15452
	groupSegsByDay: function(segs) {
15453
		var segsByDay = []; // sparse array
15454
		var i, seg;
15455
15456
		for (i = 0; i < segs.length; i++) {
15457
			seg = segs[i];
15458
			(segsByDay[seg.dayIndex] || (segsByDay[seg.dayIndex] = []))
15459
				.push(seg);
15460
		}
15461
15462
		return segsByDay;
15463
	},
15464
15465
	// generates the HTML for the day headers that live amongst the event rows
15466
	dayHeaderHtml: function(dayDate) {
15467
		var view = this.view;
15468
		var mainFormat = view.opt('listDayFormat');
15469
		var altFormat = view.opt('listDayAltFormat');
15470
15471
		return '<tr class="fc-list-heading" data-date="' + dayDate.format('YYYY-MM-DD') + '">' +
15472
			'<td class="' + view.widgetHeaderClass + '" colspan="3">' +
15473
				(mainFormat ?
15474
					view.buildGotoAnchorHtml(
15475
						dayDate,
15476
						{ 'class': 'fc-list-heading-main' },
15477
						htmlEscape(dayDate.format(mainFormat)) // inner HTML
15478
					) :
15479
					'') +
15480
				(altFormat ?
15481
					view.buildGotoAnchorHtml(
15482
						dayDate,
15483
						{ 'class': 'fc-list-heading-alt' },
15484
						htmlEscape(dayDate.format(altFormat)) // inner HTML
15485
					) :
15486
					'') +
15487
			'</td>' +
15488
		'</tr>';
15489
	},
15490
15491
	// generates the HTML for a single event row
15492
	fgSegHtml: function(seg) {
15493
		var view = this.view;
15494
		var classes = [ 'fc-list-item' ].concat(this.getSegCustomClasses(seg));
15495
		var bgColor = this.getSegBackgroundColor(seg);
15496
		var event = seg.event;
15497
		var url = event.url;
15498
		var timeHtml;
15499
15500
		if (event.allDay) {
15501
			timeHtml = view.getAllDayHtml();
15502
		}
15503
		else if (view.isMultiDayEvent(event)) { // if the event appears to span more than one day
15504
			if (seg.isStart || seg.isEnd) { // outer segment that probably lasts part of the day
15505
				timeHtml = htmlEscape(this.getEventTimeText(seg));
15506
			}
15507
			else { // inner segment that lasts the whole day
15508
				timeHtml = view.getAllDayHtml();
15509
			}
15510
		}
15511
		else {
15512
			// Display the normal time text for the *event's* times
15513
			timeHtml = htmlEscape(this.getEventTimeText(event));
15514
		}
15515
15516
		if (url) {
15517
			classes.push('fc-has-url');
15518
		}
15519
15520
		return '<tr class="' + classes.join(' ') + '">' +
15521
			(this.displayEventTime ?
15522
				'<td class="fc-list-item-time ' + view.widgetContentClass + '">' +
15523
					(timeHtml || '') +
15524
				'</td>' :
15525
				'') +
15526
			'<td class="fc-list-item-marker ' + view.widgetContentClass + '">' +
15527
				'<span class="fc-event-dot"' +
15528
				(bgColor ?
15529
					' style="background-color:' + bgColor + '"' :
15530
					'') +
15531
				'></span>' +
15532
			'</td>' +
15533
			'<td class="fc-list-item-title ' + view.widgetContentClass + '">' +
15534
				'<a' + (url ? ' href="' + htmlEscape(url) + '"' : '') + '>' +
15535
					htmlEscape(seg.event.title || '') +
15536
				'</a>' +
15537
			'</td>' +
15538
		'</tr>';
15539
	}
15540
15541
});
15542
15543
;;
15544
15545
fcViews.list = {
15546
	'class': ListView,
15547
	buttonTextKey: 'list', // what to lookup in locale files
15548
	defaults: {
15549
		buttonText: 'list', // text to display for English
15550
		listDayFormat: 'LL', // like "January 1, 2016"
15551
		noEventsMessage: 'No events to display'
15552
	}
15553
};
15554
15555
fcViews.listDay = {
15556
	type: 'list',
15557
	duration: { days: 1 },
15558
	defaults: {
15559
		listDayFormat: 'dddd' // day-of-week is all we need. full date is probably in header
15560
	}
15561
};
15562
15563
fcViews.listWeek = {
15564
	type: 'list',
15565
	duration: { weeks: 1 },
15566
	defaults: {
15567
		listDayFormat: 'dddd', // day-of-week is more important
15568
		listDayAltFormat: 'LL'
15569
	}
15570
};
15571
15572
fcViews.listMonth = {
15573
	type: 'list',
15574
	duration: { month: 1 },
15575
	defaults: {
15576
		listDayAltFormat: 'dddd' // day-of-week is nice-to-have
15577
	}
15578
};
15579
15580
fcViews.listYear = {
15581
	type: 'list',
15582
	duration: { year: 1 },
15583
	defaults: {
15584
		listDayAltFormat: 'dddd' // day-of-week is nice-to-have
15585
	}
15586
};
15587
15588
;;
15589
15590
return FC; // export for Node/CommonJS
15591
});