]> git.mxchange.org Git - friendica.git/blob - library/fullcalendar/fullcalendar.js
Added a whitespace between comma and t()
[friendica.git] / library / fullcalendar / fullcalendar.js
1 /*!
2  * FullCalendar v3.0.1
3  * Docs & License: http://fullcalendar.io/
4  * (c) 2016 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.0.1",
23         internalApiVersion: 6
24 };
25 var fcViews = FC.views = {};
26
27
28 $.fn.fullCalendar = function(options) {
29         var args = Array.prototype.slice.call(arguments, 1); // for a possible method call
30         var res = this; // what this function will return (this jQuery object by default)
31
32         this.each(function(i, _element) { // loop each DOM element involved
33                 var element = $(_element);
34                 var calendar = element.data('fullCalendar'); // get the existing calendar object (if any)
35                 var singleRes; // the returned value of this single method call
36
37                 // a method call
38                 if (typeof options === 'string') {
39                         if (calendar && $.isFunction(calendar[options])) {
40                                 singleRes = calendar[options].apply(calendar, args);
41                                 if (!i) {
42                                         res = singleRes; // record the first method call result
43                                 }
44                                 if (options === 'destroy') { // for the destroy method, must remove Calendar object data
45                                         element.removeData('fullCalendar');
46                                 }
47                         }
48                 }
49                 // a new calendar initialization
50                 else if (!calendar) { // don't initialize twice
51                         calendar = new Calendar(element, options);
52                         element.data('fullCalendar', calendar);
53                         calendar.render();
54                 }
55         });
56         
57         return res;
58 };
59
60
61 var complexOptions = [ // names of options that are objects whose properties should be combined
62         'header',
63         'buttonText',
64         'buttonIcons',
65         'themeButtonIcons'
66 ];
67
68
69 // Merges an array of option objects into a single object
70 function mergeOptions(optionObjs) {
71         return mergeProps(optionObjs, complexOptions);
72 }
73
74 ;;
75
76 // exports
77 FC.intersectRanges = intersectRanges;
78 FC.applyAll = applyAll;
79 FC.debounce = debounce;
80 FC.isInt = isInt;
81 FC.htmlEscape = htmlEscape;
82 FC.cssToStr = cssToStr;
83 FC.proxy = proxy;
84 FC.capitaliseFirstLetter = capitaliseFirstLetter;
85
86
87 /* FullCalendar-specific DOM Utilities
88 ----------------------------------------------------------------------------------------------------------------------*/
89
90
91 // Given the scrollbar widths of some other container, create borders/margins on rowEls in order to match the left
92 // and right space that was offset by the scrollbars. A 1-pixel border first, then margin beyond that.
93 function compensateScroll(rowEls, scrollbarWidths) {
94         if (scrollbarWidths.left) {
95                 rowEls.css({
96                         'border-left-width': 1,
97                         'margin-left': scrollbarWidths.left - 1
98                 });
99         }
100         if (scrollbarWidths.right) {
101                 rowEls.css({
102                         'border-right-width': 1,
103                         'margin-right': scrollbarWidths.right - 1
104                 });
105         }
106 }
107
108
109 // Undoes compensateScroll and restores all borders/margins
110 function uncompensateScroll(rowEls) {
111         rowEls.css({
112                 'margin-left': '',
113                 'margin-right': '',
114                 'border-left-width': '',
115                 'border-right-width': ''
116         });
117 }
118
119
120 // Make the mouse cursor express that an event is not allowed in the current area
121 function disableCursor() {
122         $('body').addClass('fc-not-allowed');
123 }
124
125
126 // Returns the mouse cursor to its original look
127 function enableCursor() {
128         $('body').removeClass('fc-not-allowed');
129 }
130
131
132 // Given a total available height to fill, have `els` (essentially child rows) expand to accomodate.
133 // By default, all elements that are shorter than the recommended height are expanded uniformly, not considering
134 // any other els that are already too tall. if `shouldRedistribute` is on, it considers these tall rows and 
135 // reduces the available height.
136 function distributeHeight(els, availableHeight, shouldRedistribute) {
137
138         // *FLOORING NOTE*: we floor in certain places because zoom can give inaccurate floating-point dimensions,
139         // and it is better to be shorter than taller, to avoid creating unnecessary scrollbars.
140
141         var minOffset1 = Math.floor(availableHeight / els.length); // for non-last element
142         var minOffset2 = Math.floor(availableHeight - minOffset1 * (els.length - 1)); // for last element *FLOORING NOTE*
143         var flexEls = []; // elements that are allowed to expand. array of DOM nodes
144         var flexOffsets = []; // amount of vertical space it takes up
145         var flexHeights = []; // actual css height
146         var usedHeight = 0;
147
148         undistributeHeight(els); // give all elements their natural height
149
150         // find elements that are below the recommended height (expandable).
151         // important to query for heights in a single first pass (to avoid reflow oscillation).
152         els.each(function(i, el) {
153                 var minOffset = i === els.length - 1 ? minOffset2 : minOffset1;
154                 var naturalOffset = $(el).outerHeight(true);
155
156                 if (naturalOffset < minOffset) {
157                         flexEls.push(el);
158                         flexOffsets.push(naturalOffset);
159                         flexHeights.push($(el).height());
160                 }
161                 else {
162                         // this element stretches past recommended height (non-expandable). mark the space as occupied.
163                         usedHeight += naturalOffset;
164                 }
165         });
166
167         // readjust the recommended height to only consider the height available to non-maxed-out rows.
168         if (shouldRedistribute) {
169                 availableHeight -= usedHeight;
170                 minOffset1 = Math.floor(availableHeight / flexEls.length);
171                 minOffset2 = Math.floor(availableHeight - minOffset1 * (flexEls.length - 1)); // *FLOORING NOTE*
172         }
173
174         // assign heights to all expandable elements
175         $(flexEls).each(function(i, el) {
176                 var minOffset = i === flexEls.length - 1 ? minOffset2 : minOffset1;
177                 var naturalOffset = flexOffsets[i];
178                 var naturalHeight = flexHeights[i];
179                 var newHeight = minOffset - (naturalOffset - naturalHeight); // subtract the margin/padding
180
181                 if (naturalOffset < minOffset) { // we check this again because redistribution might have changed things
182                         $(el).height(newHeight);
183                 }
184         });
185 }
186
187
188 // Undoes distrubuteHeight, restoring all els to their natural height
189 function undistributeHeight(els) {
190         els.height('');
191 }
192
193
194 // Given `els`, a jQuery set of <td> cells, find the cell with the largest natural width and set the widths of all the
195 // cells to be that width.
196 // PREREQUISITE: if you want a cell to take up width, it needs to have a single inner element w/ display:inline
197 function matchCellWidths(els) {
198         var maxInnerWidth = 0;
199
200         els.find('> *').each(function(i, innerEl) {
201                 var innerWidth = $(innerEl).outerWidth();
202                 if (innerWidth > maxInnerWidth) {
203                         maxInnerWidth = innerWidth;
204                 }
205         });
206
207         maxInnerWidth++; // sometimes not accurate of width the text needs to stay on one line. insurance
208
209         els.width(maxInnerWidth);
210
211         return maxInnerWidth;
212 }
213
214
215 // Given one element that resides inside another,
216 // Subtracts the height of the inner element from the outer element.
217 function subtractInnerElHeight(outerEl, innerEl) {
218         var both = outerEl.add(innerEl);
219         var diff;
220
221         // effin' IE8/9/10/11 sometimes returns 0 for dimensions. this weird hack was the only thing that worked
222         both.css({
223                 position: 'relative', // cause a reflow, which will force fresh dimension recalculation
224                 left: -1 // ensure reflow in case the el was already relative. negative is less likely to cause new scroll
225         });
226         diff = outerEl.outerHeight() - innerEl.outerHeight(); // grab the dimensions
227         both.css({ position: '', left: '' }); // undo hack
228
229         return diff;
230 }
231
232
233 /* Element Geom Utilities
234 ----------------------------------------------------------------------------------------------------------------------*/
235
236 FC.getOuterRect = getOuterRect;
237 FC.getClientRect = getClientRect;
238 FC.getContentRect = getContentRect;
239 FC.getScrollbarWidths = getScrollbarWidths;
240
241
242 // borrowed from https://github.com/jquery/jquery-ui/blob/1.11.0/ui/core.js#L51
243 function getScrollParent(el) {
244         var position = el.css('position'),
245                 scrollParent = el.parents().filter(function() {
246                         var parent = $(this);
247                         return (/(auto|scroll)/).test(
248                                 parent.css('overflow') + parent.css('overflow-y') + parent.css('overflow-x')
249                         );
250                 }).eq(0);
251
252         return position === 'fixed' || !scrollParent.length ? $(el[0].ownerDocument || document) : scrollParent;
253 }
254
255
256 // Queries the outer bounding area of a jQuery element.
257 // Returns a rectangle with absolute coordinates: left, right (exclusive), top, bottom (exclusive).
258 // Origin is optional.
259 function getOuterRect(el, origin) {
260         var offset = el.offset();
261         var left = offset.left - (origin ? origin.left : 0);
262         var top = offset.top - (origin ? origin.top : 0);
263
264         return {
265                 left: left,
266                 right: left + el.outerWidth(),
267                 top: top,
268                 bottom: top + el.outerHeight()
269         };
270 }
271
272
273 // Queries the area within the margin/border/scrollbars of a jQuery element. Does not go within the padding.
274 // Returns a rectangle with absolute coordinates: left, right (exclusive), top, bottom (exclusive).
275 // Origin is optional.
276 // NOTE: should use clientLeft/clientTop, but very unreliable cross-browser.
277 function getClientRect(el, origin) {
278         var offset = el.offset();
279         var scrollbarWidths = getScrollbarWidths(el);
280         var left = offset.left + getCssFloat(el, 'border-left-width') + scrollbarWidths.left - (origin ? origin.left : 0);
281         var top = offset.top + getCssFloat(el, 'border-top-width') + scrollbarWidths.top - (origin ? origin.top : 0);
282
283         return {
284                 left: left,
285                 right: left + el[0].clientWidth, // clientWidth includes padding but NOT scrollbars
286                 top: top,
287                 bottom: top + el[0].clientHeight // clientHeight includes padding but NOT scrollbars
288         };
289 }
290
291
292 // Queries the area within the margin/border/padding of a jQuery element. Assumed not to have scrollbars.
293 // Returns a rectangle with absolute coordinates: left, right (exclusive), top, bottom (exclusive).
294 // Origin is optional.
295 function getContentRect(el, origin) {
296         var offset = el.offset(); // just outside of border, margin not included
297         var left = offset.left + getCssFloat(el, 'border-left-width') + getCssFloat(el, 'padding-left') -
298                 (origin ? origin.left : 0);
299         var top = offset.top + getCssFloat(el, 'border-top-width') + getCssFloat(el, 'padding-top') -
300                 (origin ? origin.top : 0);
301
302         return {
303                 left: left,
304                 right: left + el.width(),
305                 top: top,
306                 bottom: top + el.height()
307         };
308 }
309
310
311 // Returns the computed left/right/top/bottom scrollbar widths for the given jQuery element.
312 // NOTE: should use clientLeft/clientTop, but very unreliable cross-browser.
313 function getScrollbarWidths(el) {
314         var leftRightWidth = el.innerWidth() - el[0].clientWidth; // the paddings cancel out, leaving the scrollbars
315         var widths = {
316                 left: 0,
317                 right: 0,
318                 top: 0,
319                 bottom: el.innerHeight() - el[0].clientHeight // the paddings cancel out, leaving the bottom scrollbar
320         };
321
322         if (getIsLeftRtlScrollbars() && el.css('direction') == 'rtl') { // is the scrollbar on the left side?
323                 widths.left = leftRightWidth;
324         }
325         else {
326                 widths.right = leftRightWidth;
327         }
328
329         return widths;
330 }
331
332
333 // Logic for determining if, when the element is right-to-left, the scrollbar appears on the left side
334
335 var _isLeftRtlScrollbars = null;
336
337 function getIsLeftRtlScrollbars() { // responsible for caching the computation
338         if (_isLeftRtlScrollbars === null) {
339                 _isLeftRtlScrollbars = computeIsLeftRtlScrollbars();
340         }
341         return _isLeftRtlScrollbars;
342 }
343
344 function computeIsLeftRtlScrollbars() { // creates an offscreen test element, then removes it
345         var el = $('<div><div/></div>')
346                 .css({
347                         position: 'absolute',
348                         top: -1000,
349                         left: 0,
350                         border: 0,
351                         padding: 0,
352                         overflow: 'scroll',
353                         direction: 'rtl'
354                 })
355                 .appendTo('body');
356         var innerEl = el.children();
357         var res = innerEl.offset().left > el.offset().left; // is the inner div shifted to accommodate a left scrollbar?
358         el.remove();
359         return res;
360 }
361
362
363 // Retrieves a jQuery element's computed CSS value as a floating-point number.
364 // If the queried value is non-numeric (ex: IE can return "medium" for border width), will just return zero.
365 function getCssFloat(el, prop) {
366         return parseFloat(el.css(prop)) || 0;
367 }
368
369
370 /* Mouse / Touch Utilities
371 ----------------------------------------------------------------------------------------------------------------------*/
372
373 FC.preventDefault = preventDefault;
374
375
376 // Returns a boolean whether this was a left mouse click and no ctrl key (which means right click on Mac)
377 function isPrimaryMouseButton(ev) {
378         return ev.which == 1 && !ev.ctrlKey;
379 }
380
381
382 function getEvX(ev) {
383         if (ev.pageX !== undefined) {
384                 return ev.pageX;
385         }
386         var touches = ev.originalEvent.touches;
387         if (touches) {
388                 return touches[0].pageX;
389         }
390 }
391
392
393 function getEvY(ev) {
394         if (ev.pageY !== undefined) {
395                 return ev.pageY;
396         }
397         var touches = ev.originalEvent.touches;
398         if (touches) {
399                 return touches[0].pageY;
400         }
401 }
402
403
404 function getEvIsTouch(ev) {
405         return /^touch/.test(ev.type);
406 }
407
408
409 function preventSelection(el) {
410         el.addClass('fc-unselectable')
411                 .on('selectstart', preventDefault);
412 }
413
414
415 // Stops a mouse/touch event from doing it's native browser action
416 function preventDefault(ev) {
417         ev.preventDefault();
418 }
419
420
421 // attach a handler to get called when ANY scroll action happens on the page.
422 // this was impossible to do with normal on/off because 'scroll' doesn't bubble.
423 // http://stackoverflow.com/a/32954565/96342
424 // returns `true` on success.
425 function bindAnyScroll(handler) {
426         if (window.addEventListener) {
427                 window.addEventListener('scroll', handler, true); // useCapture=true
428                 return true;
429         }
430         return false;
431 }
432
433
434 // undoes bindAnyScroll. must pass in the original function.
435 // returns `true` on success.
436 function unbindAnyScroll(handler) {
437         if (window.removeEventListener) {
438                 window.removeEventListener('scroll', handler, true); // useCapture=true
439                 return true;
440         }
441         return false;
442 }
443
444
445 /* General Geometry Utils
446 ----------------------------------------------------------------------------------------------------------------------*/
447
448 FC.intersectRects = intersectRects;
449
450 // Returns a new rectangle that is the intersection of the two rectangles. If they don't intersect, returns false
451 function intersectRects(rect1, rect2) {
452         var res = {
453                 left: Math.max(rect1.left, rect2.left),
454                 right: Math.min(rect1.right, rect2.right),
455                 top: Math.max(rect1.top, rect2.top),
456                 bottom: Math.min(rect1.bottom, rect2.bottom)
457         };
458
459         if (res.left < res.right && res.top < res.bottom) {
460                 return res;
461         }
462         return false;
463 }
464
465
466 // Returns a new point that will have been moved to reside within the given rectangle
467 function constrainPoint(point, rect) {
468         return {
469                 left: Math.min(Math.max(point.left, rect.left), rect.right),
470                 top: Math.min(Math.max(point.top, rect.top), rect.bottom)
471         };
472 }
473
474
475 // Returns a point that is the center of the given rectangle
476 function getRectCenter(rect) {
477         return {
478                 left: (rect.left + rect.right) / 2,
479                 top: (rect.top + rect.bottom) / 2
480         };
481 }
482
483
484 // Subtracts point2's coordinates from point1's coordinates, returning a delta
485 function diffPoints(point1, point2) {
486         return {
487                 left: point1.left - point2.left,
488                 top: point1.top - point2.top
489         };
490 }
491
492
493 /* Object Ordering by Field
494 ----------------------------------------------------------------------------------------------------------------------*/
495
496 FC.parseFieldSpecs = parseFieldSpecs;
497 FC.compareByFieldSpecs = compareByFieldSpecs;
498 FC.compareByFieldSpec = compareByFieldSpec;
499 FC.flexibleCompare = flexibleCompare;
500
501
502 function parseFieldSpecs(input) {
503         var specs = [];
504         var tokens = [];
505         var i, token;
506
507         if (typeof input === 'string') {
508                 tokens = input.split(/\s*,\s*/);
509         }
510         else if (typeof input === 'function') {
511                 tokens = [ input ];
512         }
513         else if ($.isArray(input)) {
514                 tokens = input;
515         }
516
517         for (i = 0; i < tokens.length; i++) {
518                 token = tokens[i];
519
520                 if (typeof token === 'string') {
521                         specs.push(
522                                 token.charAt(0) == '-' ?
523                                         { field: token.substring(1), order: -1 } :
524                                         { field: token, order: 1 }
525                         );
526                 }
527                 else if (typeof token === 'function') {
528                         specs.push({ func: token });
529                 }
530         }
531
532         return specs;
533 }
534
535
536 function compareByFieldSpecs(obj1, obj2, fieldSpecs) {
537         var i;
538         var cmp;
539
540         for (i = 0; i < fieldSpecs.length; i++) {
541                 cmp = compareByFieldSpec(obj1, obj2, fieldSpecs[i]);
542                 if (cmp) {
543                         return cmp;
544                 }
545         }
546
547         return 0;
548 }
549
550
551 function compareByFieldSpec(obj1, obj2, fieldSpec) {
552         if (fieldSpec.func) {
553                 return fieldSpec.func(obj1, obj2);
554         }
555         return flexibleCompare(obj1[fieldSpec.field], obj2[fieldSpec.field]) *
556                 (fieldSpec.order || 1);
557 }
558
559
560 function flexibleCompare(a, b) {
561         if (!a && !b) {
562                 return 0;
563         }
564         if (b == null) {
565                 return -1;
566         }
567         if (a == null) {
568                 return 1;
569         }
570         if ($.type(a) === 'string' || $.type(b) === 'string') {
571                 return String(a).localeCompare(String(b));
572         }
573         return a - b;
574 }
575
576
577 /* FullCalendar-specific Misc Utilities
578 ----------------------------------------------------------------------------------------------------------------------*/
579
580
581 // Computes the intersection of the two ranges. Will return fresh date clones in a range.
582 // Returns undefined if no intersection.
583 // Expects all dates to be normalized to the same timezone beforehand.
584 // TODO: move to date section?
585 function intersectRanges(subjectRange, constraintRange) {
586         var subjectStart = subjectRange.start;
587         var subjectEnd = subjectRange.end;
588         var constraintStart = constraintRange.start;
589         var constraintEnd = constraintRange.end;
590         var segStart, segEnd;
591         var isStart, isEnd;
592
593         if (subjectEnd > constraintStart && subjectStart < constraintEnd) { // in bounds at all?
594
595                 if (subjectStart >= constraintStart) {
596                         segStart = subjectStart.clone();
597                         isStart = true;
598                 }
599                 else {
600                         segStart = constraintStart.clone();
601                         isStart =  false;
602                 }
603
604                 if (subjectEnd <= constraintEnd) {
605                         segEnd = subjectEnd.clone();
606                         isEnd = true;
607                 }
608                 else {
609                         segEnd = constraintEnd.clone();
610                         isEnd = false;
611                 }
612
613                 return {
614                         start: segStart,
615                         end: segEnd,
616                         isStart: isStart,
617                         isEnd: isEnd
618                 };
619         }
620 }
621
622
623 /* Date Utilities
624 ----------------------------------------------------------------------------------------------------------------------*/
625
626 FC.computeIntervalUnit = computeIntervalUnit;
627 FC.divideRangeByDuration = divideRangeByDuration;
628 FC.divideDurationByDuration = divideDurationByDuration;
629 FC.multiplyDuration = multiplyDuration;
630 FC.durationHasTime = durationHasTime;
631
632 var dayIDs = [ 'sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat' ];
633 var intervalUnits = [ 'year', 'month', 'week', 'day', 'hour', 'minute', 'second', 'millisecond' ];
634
635
636 // Diffs the two moments into a Duration where full-days are recorded first, then the remaining time.
637 // Moments will have their timezones normalized.
638 function diffDayTime(a, b) {
639         return moment.duration({
640                 days: a.clone().stripTime().diff(b.clone().stripTime(), 'days'),
641                 ms: a.time() - b.time() // time-of-day from day start. disregards timezone
642         });
643 }
644
645
646 // Diffs the two moments via their start-of-day (regardless of timezone). Produces whole-day durations.
647 function diffDay(a, b) {
648         return moment.duration({
649                 days: a.clone().stripTime().diff(b.clone().stripTime(), 'days')
650         });
651 }
652
653
654 // Diffs two moments, producing a duration, made of a whole-unit-increment of the given unit. Uses rounding.
655 function diffByUnit(a, b, unit) {
656         return moment.duration(
657                 Math.round(a.diff(b, unit, true)), // returnFloat=true
658                 unit
659         );
660 }
661
662
663 // Computes the unit name of the largest whole-unit period of time.
664 // For example, 48 hours will be "days" whereas 49 hours will be "hours".
665 // Accepts start/end, a range object, or an original duration object.
666 function computeIntervalUnit(start, end) {
667         var i, unit;
668         var val;
669
670         for (i = 0; i < intervalUnits.length; i++) {
671                 unit = intervalUnits[i];
672                 val = computeRangeAs(unit, start, end);
673
674                 if (val >= 1 && isInt(val)) {
675                         break;
676                 }
677         }
678
679         return unit; // will be "milliseconds" if nothing else matches
680 }
681
682
683 // Computes the number of units (like "hours") in the given range.
684 // Range can be a {start,end} object, separate start/end args, or a Duration.
685 // Results are based on Moment's .as() and .diff() methods, so results can depend on internal handling
686 // of month-diffing logic (which tends to vary from version to version).
687 function computeRangeAs(unit, start, end) {
688
689         if (end != null) { // given start, end
690                 return end.diff(start, unit, true);
691         }
692         else if (moment.isDuration(start)) { // given duration
693                 return start.as(unit);
694         }
695         else { // given { start, end } range object
696                 return start.end.diff(start.start, unit, true);
697         }
698 }
699
700
701 // Intelligently divides a range (specified by a start/end params) by a duration
702 function divideRangeByDuration(start, end, dur) {
703         var months;
704
705         if (durationHasTime(dur)) {
706                 return (end - start) / dur;
707         }
708         months = dur.asMonths();
709         if (Math.abs(months) >= 1 && isInt(months)) {
710                 return end.diff(start, 'months', true) / months;
711         }
712         return end.diff(start, 'days', true) / dur.asDays();
713 }
714
715
716 // Intelligently divides one duration by another
717 function divideDurationByDuration(dur1, dur2) {
718         var months1, months2;
719
720         if (durationHasTime(dur1) || durationHasTime(dur2)) {
721                 return dur1 / dur2;
722         }
723         months1 = dur1.asMonths();
724         months2 = dur2.asMonths();
725         if (
726                 Math.abs(months1) >= 1 && isInt(months1) &&
727                 Math.abs(months2) >= 1 && isInt(months2)
728         ) {
729                 return months1 / months2;
730         }
731         return dur1.asDays() / dur2.asDays();
732 }
733
734
735 // Intelligently multiplies a duration by a number
736 function multiplyDuration(dur, n) {
737         var months;
738
739         if (durationHasTime(dur)) {
740                 return moment.duration(dur * n);
741         }
742         months = dur.asMonths();
743         if (Math.abs(months) >= 1 && isInt(months)) {
744                 return moment.duration({ months: months * n });
745         }
746         return moment.duration({ days: dur.asDays() * n });
747 }
748
749
750 // Returns a boolean about whether the given duration has any time parts (hours/minutes/seconds/ms)
751 function durationHasTime(dur) {
752         return Boolean(dur.hours() || dur.minutes() || dur.seconds() || dur.milliseconds());
753 }
754
755
756 function isNativeDate(input) {
757         return  Object.prototype.toString.call(input) === '[object Date]' || input instanceof Date;
758 }
759
760
761 // Returns a boolean about whether the given input is a time string, like "06:40:00" or "06:00"
762 function isTimeString(str) {
763         return /^\d+\:\d+(?:\:\d+\.?(?:\d{3})?)?$/.test(str);
764 }
765
766
767 /* Logging and Debug
768 ----------------------------------------------------------------------------------------------------------------------*/
769
770 FC.log = function() {
771         var console = window.console;
772
773         if (console && console.log) {
774                 return console.log.apply(console, arguments);
775         }
776 };
777
778 FC.warn = function() {
779         var console = window.console;
780
781         if (console && console.warn) {
782                 return console.warn.apply(console, arguments);
783         }
784         else {
785                 return FC.log.apply(FC, arguments);
786         }
787 };
788
789
790 /* General Utilities
791 ----------------------------------------------------------------------------------------------------------------------*/
792
793 var hasOwnPropMethod = {}.hasOwnProperty;
794
795
796 // Merges an array of objects into a single object.
797 // The second argument allows for an array of property names who's object values will be merged together.
798 function mergeProps(propObjs, complexProps) {
799         var dest = {};
800         var i, name;
801         var complexObjs;
802         var j, val;
803         var props;
804
805         if (complexProps) {
806                 for (i = 0; i < complexProps.length; i++) {
807                         name = complexProps[i];
808                         complexObjs = [];
809
810                         // collect the trailing object values, stopping when a non-object is discovered
811                         for (j = propObjs.length - 1; j >= 0; j--) {
812                                 val = propObjs[j][name];
813
814                                 if (typeof val === 'object') {
815                                         complexObjs.unshift(val);
816                                 }
817                                 else if (val !== undefined) {
818                                         dest[name] = val; // if there were no objects, this value will be used
819                                         break;
820                                 }
821                         }
822
823                         // if the trailing values were objects, use the merged value
824                         if (complexObjs.length) {
825                                 dest[name] = mergeProps(complexObjs);
826                         }
827                 }
828         }
829
830         // copy values into the destination, going from last to first
831         for (i = propObjs.length - 1; i >= 0; i--) {
832                 props = propObjs[i];
833
834                 for (name in props) {
835                         if (!(name in dest)) { // if already assigned by previous props or complex props, don't reassign
836                                 dest[name] = props[name];
837                         }
838                 }
839         }
840
841         return dest;
842 }
843
844
845 // Create an object that has the given prototype. Just like Object.create
846 function createObject(proto) {
847         var f = function() {};
848         f.prototype = proto;
849         return new f();
850 }
851
852
853 function copyOwnProps(src, dest) {
854         for (var name in src) {
855                 if (hasOwnProp(src, name)) {
856                         dest[name] = src[name];
857                 }
858         }
859 }
860
861
862 function hasOwnProp(obj, name) {
863         return hasOwnPropMethod.call(obj, name);
864 }
865
866
867 // Is the given value a non-object non-function value?
868 function isAtomic(val) {
869         return /undefined|null|boolean|number|string/.test($.type(val));
870 }
871
872
873 function applyAll(functions, thisObj, args) {
874         if ($.isFunction(functions)) {
875                 functions = [ functions ];
876         }
877         if (functions) {
878                 var i;
879                 var ret;
880                 for (i=0; i<functions.length; i++) {
881                         ret = functions[i].apply(thisObj, args) || ret;
882                 }
883                 return ret;
884         }
885 }
886
887
888 function firstDefined() {
889         for (var i=0; i<arguments.length; i++) {
890                 if (arguments[i] !== undefined) {
891                         return arguments[i];
892                 }
893         }
894 }
895
896
897 function htmlEscape(s) {
898         return (s + '').replace(/&/g, '&amp;')
899                 .replace(/</g, '&lt;')
900                 .replace(/>/g, '&gt;')
901                 .replace(/'/g, '&#039;')
902                 .replace(/"/g, '&quot;')
903                 .replace(/\n/g, '<br />');
904 }
905
906
907 function stripHtmlEntities(text) {
908         return text.replace(/&.*?;/g, '');
909 }
910
911
912 // Given a hash of CSS properties, returns a string of CSS.
913 // Uses property names as-is (no camel-case conversion). Will not make statements for null/undefined values.
914 function cssToStr(cssProps) {
915         var statements = [];
916
917         $.each(cssProps, function(name, val) {
918                 if (val != null) {
919                         statements.push(name + ':' + val);
920                 }
921         });
922
923         return statements.join(';');
924 }
925
926
927 // Given an object hash of HTML attribute names to values,
928 // generates a string that can be injected between < > in HTML
929 function attrsToStr(attrs) {
930         var parts = [];
931
932         $.each(attrs, function(name, val) {
933                 if (val != null) {
934                         parts.push(name + '="' + htmlEscape(val) + '"');
935                 }
936         });
937
938         return parts.join(' ');
939 }
940
941
942 function capitaliseFirstLetter(str) {
943         return str.charAt(0).toUpperCase() + str.slice(1);
944 }
945
946
947 function compareNumbers(a, b) { // for .sort()
948         return a - b;
949 }
950
951
952 function isInt(n) {
953         return n % 1 === 0;
954 }
955
956
957 // Returns a method bound to the given object context.
958 // Just like one of the jQuery.proxy signatures, but without the undesired behavior of treating the same method with
959 // different contexts as identical when binding/unbinding events.
960 function proxy(obj, methodName) {
961         var method = obj[methodName];
962
963         return function() {
964                 return method.apply(obj, arguments);
965         };
966 }
967
968
969 // Returns a function, that, as long as it continues to be invoked, will not
970 // be triggered. The function will be called after it stops being called for
971 // N milliseconds. If `immediate` is passed, trigger the function on the
972 // leading edge, instead of the trailing.
973 // https://github.com/jashkenas/underscore/blob/1.6.0/underscore.js#L714
974 function debounce(func, wait, immediate) {
975         var timeout, args, context, timestamp, result;
976
977         var later = function() {
978                 var last = +new Date() - timestamp;
979                 if (last < wait) {
980                         timeout = setTimeout(later, wait - last);
981                 }
982                 else {
983                         timeout = null;
984                         if (!immediate) {
985                                 result = func.apply(context, args);
986                                 context = args = null;
987                         }
988                 }
989         };
990
991         return function() {
992                 context = this;
993                 args = arguments;
994                 timestamp = +new Date();
995                 var callNow = immediate && !timeout;
996                 if (!timeout) {
997                         timeout = setTimeout(later, wait);
998                 }
999                 if (callNow) {
1000                         result = func.apply(context, args);
1001                         context = args = null;
1002                 }
1003                 return result;
1004         };
1005 }
1006
1007
1008 // HACK around jQuery's now A+ promises: execute callback synchronously if already resolved.
1009 // thenFunc shouldn't accept args.
1010 // similar to whenResources in Scheduler plugin.
1011 function syncThen(promise, thenFunc) {
1012         // not a promise, or an already-resolved promise?
1013         if (!promise || !promise.then || promise.state() === 'resolved') {
1014                 return $.when(thenFunc()); // resolve immediately
1015         }
1016         else if (thenFunc) {
1017                 return promise.then(thenFunc);
1018         }
1019 }
1020
1021 ;;
1022
1023 /*
1024 GENERAL NOTE on moments throughout the *entire rest* of the codebase:
1025 All moments are assumed to be ambiguously-zoned unless otherwise noted,
1026 with the NOTABLE EXCEOPTION of start/end dates that live on *Event Objects*.
1027 Ambiguously-TIMED moments are assumed to be ambiguously-zoned by nature.
1028 */
1029
1030 var ambigDateOfMonthRegex = /^\s*\d{4}-\d\d$/;
1031 var ambigTimeOrZoneRegex =
1032         /^\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+)?)?)?)?)?$/;
1033 var newMomentProto = moment.fn; // where we will attach our new methods
1034 var oldMomentProto = $.extend({}, newMomentProto); // copy of original moment methods
1035
1036 // tell momentjs to transfer these properties upon clone
1037 var momentProperties = moment.momentProperties;
1038 momentProperties.push('_fullCalendar');
1039 momentProperties.push('_ambigTime');
1040 momentProperties.push('_ambigZone');
1041
1042
1043 // Creating
1044 // -------------------------------------------------------------------------------------------------
1045
1046 // Creates a new moment, similar to the vanilla moment(...) constructor, but with
1047 // extra features (ambiguous time, enhanced formatting). When given an existing moment,
1048 // it will function as a clone (and retain the zone of the moment). Anything else will
1049 // result in a moment in the local zone.
1050 FC.moment = function() {
1051         return makeMoment(arguments);
1052 };
1053
1054 // Sames as FC.moment, but forces the resulting moment to be in the UTC timezone.
1055 FC.moment.utc = function() {
1056         var mom = makeMoment(arguments, true);
1057
1058         // Force it into UTC because makeMoment doesn't guarantee it
1059         // (if given a pre-existing moment for example)
1060         if (mom.hasTime()) { // don't give ambiguously-timed moments a UTC zone
1061                 mom.utc();
1062         }
1063
1064         return mom;
1065 };
1066
1067 // Same as FC.moment, but when given an ISO8601 string, the timezone offset is preserved.
1068 // ISO8601 strings with no timezone offset will become ambiguously zoned.
1069 FC.moment.parseZone = function() {
1070         return makeMoment(arguments, true, true);
1071 };
1072
1073 // Builds an enhanced moment from args. When given an existing moment, it clones. When given a
1074 // native Date, or called with no arguments (the current time), the resulting moment will be local.
1075 // Anything else needs to be "parsed" (a string or an array), and will be affected by:
1076 //    parseAsUTC - if there is no zone information, should we parse the input in UTC?
1077 //    parseZone - if there is zone information, should we force the zone of the moment?
1078 function makeMoment(args, parseAsUTC, parseZone) {
1079         var input = args[0];
1080         var isSingleString = args.length == 1 && typeof input === 'string';
1081         var isAmbigTime;
1082         var isAmbigZone;
1083         var ambigMatch;
1084         var mom;
1085
1086         if (moment.isMoment(input) || isNativeDate(input) || input === undefined) {
1087                 mom = moment.apply(null, args);
1088         }
1089         else { // "parsing" is required
1090                 isAmbigTime = false;
1091                 isAmbigZone = false;
1092
1093                 if (isSingleString) {
1094                         if (ambigDateOfMonthRegex.test(input)) {
1095                                 // accept strings like '2014-05', but convert to the first of the month
1096                                 input += '-01';
1097                                 args = [ input ]; // for when we pass it on to moment's constructor
1098                                 isAmbigTime = true;
1099                                 isAmbigZone = true;
1100                         }
1101                         else if ((ambigMatch = ambigTimeOrZoneRegex.exec(input))) {
1102                                 isAmbigTime = !ambigMatch[5]; // no time part?
1103                                 isAmbigZone = true;
1104                         }
1105                 }
1106                 else if ($.isArray(input)) {
1107                         // arrays have no timezone information, so assume ambiguous zone
1108                         isAmbigZone = true;
1109                 }
1110                 // otherwise, probably a string with a format
1111
1112                 if (parseAsUTC || isAmbigTime) {
1113                         mom = moment.utc.apply(moment, args);
1114                 }
1115                 else {
1116                         mom = moment.apply(null, args);
1117                 }
1118
1119                 if (isAmbigTime) {
1120                         mom._ambigTime = true;
1121                         mom._ambigZone = true; // ambiguous time always means ambiguous zone
1122                 }
1123                 else if (parseZone) { // let's record the inputted zone somehow
1124                         if (isAmbigZone) {
1125                                 mom._ambigZone = true;
1126                         }
1127                         else if (isSingleString) {
1128                                 mom.utcOffset(input); // if not a valid zone, will assign UTC
1129                         }
1130                 }
1131         }
1132
1133         mom._fullCalendar = true; // flag for extended functionality
1134
1135         return mom;
1136 }
1137
1138
1139 // Week Number
1140 // -------------------------------------------------------------------------------------------------
1141
1142
1143 // Returns the week number, considering the locale's custom week number calcuation
1144 // `weeks` is an alias for `week`
1145 newMomentProto.week = newMomentProto.weeks = function(input) {
1146         var weekCalc = this._locale._fullCalendar_weekCalc;
1147
1148         if (input == null && typeof weekCalc === 'function') { // custom function only works for getter
1149                 return weekCalc(this);
1150         }
1151         else if (weekCalc === 'ISO') {
1152                 return oldMomentProto.isoWeek.apply(this, arguments); // ISO getter/setter
1153         }
1154
1155         return oldMomentProto.week.apply(this, arguments); // local getter/setter
1156 };
1157
1158
1159 // Time-of-day
1160 // -------------------------------------------------------------------------------------------------
1161
1162 // GETTER
1163 // Returns a Duration with the hours/minutes/seconds/ms values of the moment.
1164 // If the moment has an ambiguous time, a duration of 00:00 will be returned.
1165 //
1166 // SETTER
1167 // You can supply a Duration, a Moment, or a Duration-like argument.
1168 // When setting the time, and the moment has an ambiguous time, it then becomes unambiguous.
1169 newMomentProto.time = function(time) {
1170
1171         // Fallback to the original method (if there is one) if this moment wasn't created via FullCalendar.
1172         // `time` is a generic enough method name where this precaution is necessary to avoid collisions w/ other plugins.
1173         if (!this._fullCalendar) {
1174                 return oldMomentProto.time.apply(this, arguments);
1175         }
1176
1177         if (time == null) { // getter
1178                 return moment.duration({
1179                         hours: this.hours(),
1180                         minutes: this.minutes(),
1181                         seconds: this.seconds(),
1182                         milliseconds: this.milliseconds()
1183                 });
1184         }
1185         else { // setter
1186
1187                 this._ambigTime = false; // mark that the moment now has a time
1188
1189                 if (!moment.isDuration(time) && !moment.isMoment(time)) {
1190                         time = moment.duration(time);
1191                 }
1192
1193                 // The day value should cause overflow (so 24 hours becomes 00:00:00 of next day).
1194                 // Only for Duration times, not Moment times.
1195                 var dayHours = 0;
1196                 if (moment.isDuration(time)) {
1197                         dayHours = Math.floor(time.asDays()) * 24;
1198                 }
1199
1200                 // We need to set the individual fields.
1201                 // Can't use startOf('day') then add duration. In case of DST at start of day.
1202                 return this.hours(dayHours + time.hours())
1203                         .minutes(time.minutes())
1204                         .seconds(time.seconds())
1205                         .milliseconds(time.milliseconds());
1206         }
1207 };
1208
1209 // Converts the moment to UTC, stripping out its time-of-day and timezone offset,
1210 // but preserving its YMD. A moment with a stripped time will display no time
1211 // nor timezone offset when .format() is called.
1212 newMomentProto.stripTime = function() {
1213
1214         if (!this._ambigTime) {
1215
1216                 this.utc(true); // keepLocalTime=true (for keeping *date* value)
1217
1218                 // set time to zero
1219                 this.set({
1220                         hours: 0,
1221                         minutes: 0,
1222                         seconds: 0,
1223                         ms: 0
1224                 });
1225
1226                 // Mark the time as ambiguous. This needs to happen after the .utc() call, which might call .utcOffset(),
1227                 // which clears all ambig flags.
1228                 this._ambigTime = true;
1229                 this._ambigZone = true; // if ambiguous time, also ambiguous timezone offset
1230         }
1231
1232         return this; // for chaining
1233 };
1234
1235 // Returns if the moment has a non-ambiguous time (boolean)
1236 newMomentProto.hasTime = function() {
1237         return !this._ambigTime;
1238 };
1239
1240
1241 // Timezone
1242 // -------------------------------------------------------------------------------------------------
1243
1244 // Converts the moment to UTC, stripping out its timezone offset, but preserving its
1245 // YMD and time-of-day. A moment with a stripped timezone offset will display no
1246 // timezone offset when .format() is called.
1247 newMomentProto.stripZone = function() {
1248         var wasAmbigTime;
1249
1250         if (!this._ambigZone) {
1251
1252                 wasAmbigTime = this._ambigTime;
1253
1254                 this.utc(true); // keepLocalTime=true (for keeping date and time values)
1255
1256                 // the above call to .utc()/.utcOffset() unfortunately might clear the ambig flags, so restore
1257                 this._ambigTime = wasAmbigTime || false;
1258
1259                 // Mark the zone as ambiguous. This needs to happen after the .utc() call, which might call .utcOffset(),
1260                 // which clears the ambig flags.
1261                 this._ambigZone = true;
1262         }
1263
1264         return this; // for chaining
1265 };
1266
1267 // Returns of the moment has a non-ambiguous timezone offset (boolean)
1268 newMomentProto.hasZone = function() {
1269         return !this._ambigZone;
1270 };
1271
1272
1273 // implicitly marks a zone
1274 newMomentProto.local = function(keepLocalTime) {
1275
1276         // for when converting from ambiguously-zoned to local,
1277         // keep the time values when converting from UTC -> local
1278         oldMomentProto.local.call(this, this._ambigZone || keepLocalTime);
1279
1280         // ensure non-ambiguous
1281         // this probably already happened via local() -> utcOffset(), but don't rely on Moment's internals
1282         this._ambigTime = false;
1283         this._ambigZone = false;
1284
1285         return this; // for chaining
1286 };
1287
1288
1289 // implicitly marks a zone
1290 newMomentProto.utc = function(keepLocalTime) {
1291
1292         oldMomentProto.utc.call(this, keepLocalTime);
1293
1294         // ensure non-ambiguous
1295         // this probably already happened via utc() -> utcOffset(), but don't rely on Moment's internals
1296         this._ambigTime = false;
1297         this._ambigZone = false;
1298
1299         return this;
1300 };
1301
1302
1303 // implicitly marks a zone (will probably get called upon .utc() and .local())
1304 newMomentProto.utcOffset = function(tzo) {
1305
1306         if (tzo != null) { // setter
1307                 // these assignments needs to happen before the original zone method is called.
1308                 // I forget why, something to do with a browser crash.
1309                 this._ambigTime = false;
1310                 this._ambigZone = false;
1311         }
1312
1313         return oldMomentProto.utcOffset.apply(this, arguments);
1314 };
1315
1316
1317 // Formatting
1318 // -------------------------------------------------------------------------------------------------
1319
1320 newMomentProto.format = function() {
1321         if (this._fullCalendar && arguments[0]) { // an enhanced moment? and a format string provided?
1322                 return formatDate(this, arguments[0]); // our extended formatting
1323         }
1324         if (this._ambigTime) {
1325                 return oldMomentFormat(this, 'YYYY-MM-DD');
1326         }
1327         if (this._ambigZone) {
1328                 return oldMomentFormat(this, 'YYYY-MM-DD[T]HH:mm:ss');
1329         }
1330         return oldMomentProto.format.apply(this, arguments);
1331 };
1332
1333 newMomentProto.toISOString = function() {
1334         if (this._ambigTime) {
1335                 return oldMomentFormat(this, 'YYYY-MM-DD');
1336         }
1337         if (this._ambigZone) {
1338                 return oldMomentFormat(this, 'YYYY-MM-DD[T]HH:mm:ss');
1339         }
1340         return oldMomentProto.toISOString.apply(this, arguments);
1341 };
1342
1343 ;;
1344
1345 // Single Date Formatting
1346 // -------------------------------------------------------------------------------------------------
1347
1348
1349 // call this if you want Moment's original format method to be used
1350 function oldMomentFormat(mom, formatStr) {
1351         return oldMomentProto.format.call(mom, formatStr); // oldMomentProto defined in moment-ext.js
1352 }
1353
1354
1355 // Formats `date` with a Moment formatting string, but allow our non-zero areas and
1356 // additional token.
1357 function formatDate(date, formatStr) {
1358         return formatDateWithChunks(date, getFormatStringChunks(formatStr));
1359 }
1360
1361
1362 function formatDateWithChunks(date, chunks) {
1363         var s = '';
1364         var i;
1365
1366         for (i=0; i<chunks.length; i++) {
1367                 s += formatDateWithChunk(date, chunks[i]);
1368         }
1369
1370         return s;
1371 }
1372
1373
1374 // addition formatting tokens we want recognized
1375 var tokenOverrides = {
1376         t: function(date) { // "a" or "p"
1377                 return oldMomentFormat(date, 'a').charAt(0);
1378         },
1379         T: function(date) { // "A" or "P"
1380                 return oldMomentFormat(date, 'A').charAt(0);
1381         }
1382 };
1383
1384
1385 function formatDateWithChunk(date, chunk) {
1386         var token;
1387         var maybeStr;
1388
1389         if (typeof chunk === 'string') { // a literal string
1390                 return chunk;
1391         }
1392         else if ((token = chunk.token)) { // a token, like "YYYY"
1393                 if (tokenOverrides[token]) {
1394                         return tokenOverrides[token](date); // use our custom token
1395                 }
1396                 return oldMomentFormat(date, token);
1397         }
1398         else if (chunk.maybe) { // a grouping of other chunks that must be non-zero
1399                 maybeStr = formatDateWithChunks(date, chunk.maybe);
1400                 if (maybeStr.match(/[1-9]/)) {
1401                         return maybeStr;
1402                 }
1403         }
1404
1405         return '';
1406 }
1407
1408
1409 // Date Range Formatting
1410 // -------------------------------------------------------------------------------------------------
1411 // TODO: make it work with timezone offset
1412
1413 // Using a formatting string meant for a single date, generate a range string, like
1414 // "Sep 2 - 9 2013", that intelligently inserts a separator where the dates differ.
1415 // If the dates are the same as far as the format string is concerned, just return a single
1416 // rendering of one date, without any separator.
1417 function formatRange(date1, date2, formatStr, separator, isRTL) {
1418         var localeData;
1419
1420         date1 = FC.moment.parseZone(date1);
1421         date2 = FC.moment.parseZone(date2);
1422
1423         localeData = date1.localeData();
1424
1425         // Expand localized format strings, like "LL" -> "MMMM D YYYY"
1426         formatStr = localeData.longDateFormat(formatStr) || formatStr;
1427         // BTW, this is not important for `formatDate` because it is impossible to put custom tokens
1428         // or non-zero areas in Moment's localized format strings.
1429
1430         separator = separator || ' - ';
1431
1432         return formatRangeWithChunks(
1433                 date1,
1434                 date2,
1435                 getFormatStringChunks(formatStr),
1436                 separator,
1437                 isRTL
1438         );
1439 }
1440 FC.formatRange = formatRange; // expose
1441
1442
1443 function formatRangeWithChunks(date1, date2, chunks, separator, isRTL) {
1444         var unzonedDate1 = date1.clone().stripZone(); // for formatSimilarChunk
1445         var unzonedDate2 = date2.clone().stripZone(); // "
1446         var chunkStr; // the rendering of the chunk
1447         var leftI;
1448         var leftStr = '';
1449         var rightI;
1450         var rightStr = '';
1451         var middleI;
1452         var middleStr1 = '';
1453         var middleStr2 = '';
1454         var middleStr = '';
1455
1456         // Start at the leftmost side of the formatting string and continue until you hit a token
1457         // that is not the same between dates.
1458         for (leftI=0; leftI<chunks.length; leftI++) {
1459                 chunkStr = formatSimilarChunk(date1, date2, unzonedDate1, unzonedDate2, chunks[leftI]);
1460                 if (chunkStr === false) {
1461                         break;
1462                 }
1463                 leftStr += chunkStr;
1464         }
1465
1466         // Similarly, start at the rightmost side of the formatting string and move left
1467         for (rightI=chunks.length-1; rightI>leftI; rightI--) {
1468                 chunkStr = formatSimilarChunk(date1, date2, unzonedDate1, unzonedDate2,  chunks[rightI]);
1469                 if (chunkStr === false) {
1470                         break;
1471                 }
1472                 rightStr = chunkStr + rightStr;
1473         }
1474
1475         // The area in the middle is different for both of the dates.
1476         // Collect them distinctly so we can jam them together later.
1477         for (middleI=leftI; middleI<=rightI; middleI++) {
1478                 middleStr1 += formatDateWithChunk(date1, chunks[middleI]);
1479                 middleStr2 += formatDateWithChunk(date2, chunks[middleI]);
1480         }
1481
1482         if (middleStr1 || middleStr2) {
1483                 if (isRTL) {
1484                         middleStr = middleStr2 + separator + middleStr1;
1485                 }
1486                 else {
1487                         middleStr = middleStr1 + separator + middleStr2;
1488                 }
1489         }
1490
1491         return leftStr + middleStr + rightStr;
1492 }
1493
1494
1495 var similarUnitMap = {
1496         Y: 'year',
1497         M: 'month',
1498         D: 'day', // day of month
1499         d: 'day', // day of week
1500         // prevents a separator between anything time-related...
1501         A: 'second', // AM/PM
1502         a: 'second', // am/pm
1503         T: 'second', // A/P
1504         t: 'second', // a/p
1505         H: 'second', // hour (24)
1506         h: 'second', // hour (12)
1507         m: 'second', // minute
1508         s: 'second' // second
1509 };
1510 // TODO: week maybe?
1511
1512
1513 // Given a formatting chunk, and given that both dates are similar in the regard the
1514 // formatting chunk is concerned, format date1 against `chunk`. Otherwise, return `false`.
1515 function formatSimilarChunk(date1, date2, unzonedDate1, unzonedDate2, chunk) {
1516         var token;
1517         var unit;
1518
1519         if (typeof chunk === 'string') { // a literal string
1520                 return chunk;
1521         }
1522         else if ((token = chunk.token)) {
1523                 unit = similarUnitMap[token.charAt(0)];
1524
1525                 // are the dates the same for this unit of measurement?
1526                 // use the unzoned dates for this calculation because unreliable when near DST (bug #2396)
1527                 if (unit && unzonedDate1.isSame(unzonedDate2, unit)) {
1528                         return oldMomentFormat(date1, token); // would be the same if we used `date2`
1529                         // BTW, don't support custom tokens
1530                 }
1531         }
1532
1533         return false; // the chunk is NOT the same for the two dates
1534         // BTW, don't support splitting on non-zero areas
1535 }
1536
1537
1538 // Chunking Utils
1539 // -------------------------------------------------------------------------------------------------
1540
1541
1542 var formatStringChunkCache = {};
1543
1544
1545 function getFormatStringChunks(formatStr) {
1546         if (formatStr in formatStringChunkCache) {
1547                 return formatStringChunkCache[formatStr];
1548         }
1549         return (formatStringChunkCache[formatStr] = chunkFormatString(formatStr));
1550 }
1551
1552
1553 // Break the formatting string into an array of chunks
1554 function chunkFormatString(formatStr) {
1555         var chunks = [];
1556         var chunker = /\[([^\]]*)\]|\(([^\)]*)\)|(LTS|LT|(\w)\4*o?)|([^\w\[\(]+)/g; // TODO: more descrimination
1557         var match;
1558
1559         while ((match = chunker.exec(formatStr))) {
1560                 if (match[1]) { // a literal string inside [ ... ]
1561                         chunks.push(match[1]);
1562                 }
1563                 else if (match[2]) { // non-zero formatting inside ( ... )
1564                         chunks.push({ maybe: chunkFormatString(match[2]) });
1565                 }
1566                 else if (match[3]) { // a formatting token
1567                         chunks.push({ token: match[3] });
1568                 }
1569                 else if (match[5]) { // an unenclosed literal string
1570                         chunks.push(match[5]);
1571                 }
1572         }
1573
1574         return chunks;
1575 }
1576
1577
1578 // Misc Utils
1579 // -------------------------------------------------------------------------------------------------
1580
1581
1582 // granularity only goes up until day
1583 // TODO: unify with similarUnitMap
1584 var tokenGranularities = {
1585         Y: { value: 1, unit: 'year' },
1586         M: { value: 2, unit: 'month' },
1587         W: { value: 3, unit: 'week' },
1588         w: { value: 3, unit: 'week' },
1589         D: { value: 4, unit: 'day' }, // day of month
1590         d: { value: 4, unit: 'day' } // day of week
1591 };
1592
1593 // returns a unit string, either 'year', 'month', 'day', or null
1594 // for the most granular formatting token in the string.
1595 FC.queryMostGranularFormatUnit = function(formatStr) {
1596         var chunks = getFormatStringChunks(formatStr);
1597         var i, chunk;
1598         var candidate;
1599         var best;
1600
1601         for (i = 0; i < chunks.length; i++) {
1602                 chunk = chunks[i];
1603                 if (chunk.token) {
1604                         candidate = tokenGranularities[chunk.token.charAt(0)];
1605                         if (candidate) {
1606                                 if (!best || candidate.value > best.value) {
1607                                         best = candidate;
1608                                 }
1609                         }
1610                 }
1611         }
1612
1613         if (best) {
1614                 return best.unit;
1615         }
1616
1617         return null;
1618 };
1619
1620 ;;
1621
1622 FC.Class = Class; // export
1623
1624 // Class that all other classes will inherit from
1625 function Class() { }
1626
1627
1628 // Called on a class to create a subclass.
1629 // Last argument contains instance methods. Any argument before the last are considered mixins.
1630 Class.extend = function() {
1631         var len = arguments.length;
1632         var i;
1633         var members;
1634
1635         for (i = 0; i < len; i++) {
1636                 members = arguments[i];
1637                 if (i < len - 1) { // not the last argument?
1638                         mixIntoClass(this, members);
1639                 }
1640         }
1641
1642         return extendClass(this, members || {}); // members will be undefined if no arguments
1643 };
1644
1645
1646 // Adds new member variables/methods to the class's prototype.
1647 // Can be called with another class, or a plain object hash containing new members.
1648 Class.mixin = function(members) {
1649         mixIntoClass(this, members);
1650 };
1651
1652
1653 function extendClass(superClass, members) {
1654         var subClass;
1655
1656         // ensure a constructor for the subclass, forwarding all arguments to the super-constructor if it doesn't exist
1657         if (hasOwnProp(members, 'constructor')) {
1658                 subClass = members.constructor;
1659         }
1660         if (typeof subClass !== 'function') {
1661                 subClass = members.constructor = function() {
1662                         superClass.apply(this, arguments);
1663                 };
1664         }
1665
1666         // build the base prototype for the subclass, which is an new object chained to the superclass's prototype
1667         subClass.prototype = createObject(superClass.prototype);
1668
1669         // copy each member variable/method onto the the subclass's prototype
1670         copyOwnProps(members, subClass.prototype);
1671
1672         // copy over all class variables/methods to the subclass, such as `extend` and `mixin`
1673         copyOwnProps(superClass, subClass);
1674
1675         return subClass;
1676 }
1677
1678
1679 function mixIntoClass(theClass, members) {
1680         copyOwnProps(members, theClass.prototype);
1681 }
1682 ;;
1683
1684 var EmitterMixin = FC.EmitterMixin = {
1685
1686         // jQuery-ification via $(this) allows a non-DOM object to have
1687         // the same event handling capabilities (including namespaces).
1688
1689
1690         on: function(types, handler) {
1691
1692                 // handlers are always called with an "event" object as their first param.
1693                 // sneak the `this` context and arguments into the extra parameter object
1694                 // and forward them on to the original handler.
1695                 var intercept = function(ev, extra) {
1696                         return handler.apply(
1697                                 extra.context || this,
1698                                 extra.args || []
1699                         );
1700                 };
1701
1702                 // mimick jQuery's internal "proxy" system (risky, I know)
1703                 // causing all functions with the same .guid to appear to be the same.
1704                 // https://github.com/jquery/jquery/blob/2.2.4/src/core.js#L448
1705                 // this is needed for calling .off with the original non-intercept handler.
1706                 if (!handler.guid) {
1707                         handler.guid = $.guid++;
1708                 }
1709                 intercept.guid = handler.guid;
1710
1711                 $(this).on(types, intercept);
1712
1713                 return this; // for chaining
1714         },
1715
1716
1717         off: function(types, handler) {
1718                 $(this).off(types, handler);
1719
1720                 return this; // for chaining
1721         },
1722
1723
1724         trigger: function(types) {
1725                 var args = Array.prototype.slice.call(arguments, 1); // arguments after the first
1726
1727                 // pass in "extra" info to the intercept
1728                 $(this).triggerHandler(types, { args: args });
1729
1730                 return this; // for chaining
1731         },
1732
1733
1734         triggerWith: function(types, context, args) {
1735
1736                 // `triggerHandler` is less reliant on the DOM compared to `trigger`.
1737                 // pass in "extra" info to the intercept.
1738                 $(this).triggerHandler(types, { context: context, args: args });
1739
1740                 return this; // for chaining
1741         }
1742
1743 };
1744
1745 ;;
1746
1747 /*
1748 Utility methods for easily listening to events on another object,
1749 and more importantly, easily unlistening from them.
1750 */
1751 var ListenerMixin = FC.ListenerMixin = (function() {
1752         var guid = 0;
1753         var ListenerMixin = {
1754
1755                 listenerId: null,
1756
1757                 /*
1758                 Given an `other` object that has on/off methods, bind the given `callback` to an event by the given name.
1759                 The `callback` will be called with the `this` context of the object that .listenTo is being called on.
1760                 Can be called:
1761                         .listenTo(other, eventName, callback)
1762                 OR
1763                         .listenTo(other, {
1764                                 eventName1: callback1,
1765                                 eventName2: callback2
1766                         })
1767                 */
1768                 listenTo: function(other, arg, callback) {
1769                         if (typeof arg === 'object') { // given dictionary of callbacks
1770                                 for (var eventName in arg) {
1771                                         if (arg.hasOwnProperty(eventName)) {
1772                                                 this.listenTo(other, eventName, arg[eventName]);
1773                                         }
1774                                 }
1775                         }
1776                         else if (typeof arg === 'string') {
1777                                 other.on(
1778                                         arg + '.' + this.getListenerNamespace(), // use event namespacing to identify this object
1779                                         $.proxy(callback, this) // always use `this` context
1780                                                 // the usually-undesired jQuery guid behavior doesn't matter,
1781                                                 // because we always unbind via namespace
1782                                 );
1783                         }
1784                 },
1785
1786                 /*
1787                 Causes the current object to stop listening to events on the `other` object.
1788                 `eventName` is optional. If omitted, will stop listening to ALL events on `other`.
1789                 */
1790                 stopListeningTo: function(other, eventName) {
1791                         other.off((eventName || '') + '.' + this.getListenerNamespace());
1792                 },
1793
1794                 /*
1795                 Returns a string, unique to this object, to be used for event namespacing
1796                 */
1797                 getListenerNamespace: function() {
1798                         if (this.listenerId == null) {
1799                                 this.listenerId = guid++;
1800                         }
1801                         return '_listener' + this.listenerId;
1802                 }
1803
1804         };
1805         return ListenerMixin;
1806 })();
1807 ;;
1808
1809 // simple class for toggle a `isIgnoringMouse` flag on delay
1810 // initMouseIgnoring must first be called, with a millisecond delay setting.
1811 var MouseIgnorerMixin = {
1812
1813         isIgnoringMouse: false, // bool
1814         delayUnignoreMouse: null, // method
1815
1816
1817         initMouseIgnoring: function(delay) {
1818                 this.delayUnignoreMouse = debounce(proxy(this, 'unignoreMouse'), delay || 1000);
1819         },
1820
1821
1822         // temporarily ignore mouse actions on segments
1823         tempIgnoreMouse: function() {
1824                 this.isIgnoringMouse = true;
1825                 this.delayUnignoreMouse();
1826         },
1827
1828
1829         // delayUnignoreMouse eventually calls this
1830         unignoreMouse: function() {
1831                 this.isIgnoringMouse = false;
1832         }
1833
1834 };
1835
1836 ;;
1837
1838 /* A rectangular panel that is absolutely positioned over other content
1839 ------------------------------------------------------------------------------------------------------------------------
1840 Options:
1841         - className (string)
1842         - content (HTML string or jQuery element set)
1843         - parentEl
1844         - top
1845         - left
1846         - right (the x coord of where the right edge should be. not a "CSS" right)
1847         - autoHide (boolean)
1848         - show (callback)
1849         - hide (callback)
1850 */
1851
1852 var Popover = Class.extend(ListenerMixin, {
1853
1854         isHidden: true,
1855         options: null,
1856         el: null, // the container element for the popover. generated by this object
1857         margin: 10, // the space required between the popover and the edges of the scroll container
1858
1859
1860         constructor: function(options) {
1861                 this.options = options || {};
1862         },
1863
1864
1865         // Shows the popover on the specified position. Renders it if not already
1866         show: function() {
1867                 if (this.isHidden) {
1868                         if (!this.el) {
1869                                 this.render();
1870                         }
1871                         this.el.show();
1872                         this.position();
1873                         this.isHidden = false;
1874                         this.trigger('show');
1875                 }
1876         },
1877
1878
1879         // Hides the popover, through CSS, but does not remove it from the DOM
1880         hide: function() {
1881                 if (!this.isHidden) {
1882                         this.el.hide();
1883                         this.isHidden = true;
1884                         this.trigger('hide');
1885                 }
1886         },
1887
1888
1889         // Creates `this.el` and renders content inside of it
1890         render: function() {
1891                 var _this = this;
1892                 var options = this.options;
1893
1894                 this.el = $('<div class="fc-popover"/>')
1895                         .addClass(options.className || '')
1896                         .css({
1897                                 // position initially to the top left to avoid creating scrollbars
1898                                 top: 0,
1899                                 left: 0
1900                         })
1901                         .append(options.content)
1902                         .appendTo(options.parentEl);
1903
1904                 // when a click happens on anything inside with a 'fc-close' className, hide the popover
1905                 this.el.on('click', '.fc-close', function() {
1906                         _this.hide();
1907                 });
1908
1909                 if (options.autoHide) {
1910                         this.listenTo($(document), 'mousedown', this.documentMousedown);
1911                 }
1912         },
1913
1914
1915         // Triggered when the user clicks *anywhere* in the document, for the autoHide feature
1916         documentMousedown: function(ev) {
1917                 // only hide the popover if the click happened outside the popover
1918                 if (this.el && !$(ev.target).closest(this.el).length) {
1919                         this.hide();
1920                 }
1921         },
1922
1923
1924         // Hides and unregisters any handlers
1925         removeElement: function() {
1926                 this.hide();
1927
1928                 if (this.el) {
1929                         this.el.remove();
1930                         this.el = null;
1931                 }
1932
1933                 this.stopListeningTo($(document), 'mousedown');
1934         },
1935
1936
1937         // Positions the popover optimally, using the top/left/right options
1938         position: function() {
1939                 var options = this.options;
1940                 var origin = this.el.offsetParent().offset();
1941                 var width = this.el.outerWidth();
1942                 var height = this.el.outerHeight();
1943                 var windowEl = $(window);
1944                 var viewportEl = getScrollParent(this.el);
1945                 var viewportTop;
1946                 var viewportLeft;
1947                 var viewportOffset;
1948                 var top; // the "position" (not "offset") values for the popover
1949                 var left; //
1950
1951                 // compute top and left
1952                 top = options.top || 0;
1953                 if (options.left !== undefined) {
1954                         left = options.left;
1955                 }
1956                 else if (options.right !== undefined) {
1957                         left = options.right - width; // derive the left value from the right value
1958                 }
1959                 else {
1960                         left = 0;
1961                 }
1962
1963                 if (viewportEl.is(window) || viewportEl.is(document)) { // normalize getScrollParent's result
1964                         viewportEl = windowEl;
1965                         viewportTop = 0; // the window is always at the top left
1966                         viewportLeft = 0; // (and .offset() won't work if called here)
1967                 }
1968                 else {
1969                         viewportOffset = viewportEl.offset();
1970                         viewportTop = viewportOffset.top;
1971                         viewportLeft = viewportOffset.left;
1972                 }
1973
1974                 // if the window is scrolled, it causes the visible area to be further down
1975                 viewportTop += windowEl.scrollTop();
1976                 viewportLeft += windowEl.scrollLeft();
1977
1978                 // constrain to the view port. if constrained by two edges, give precedence to top/left
1979                 if (options.viewportConstrain !== false) {
1980                         top = Math.min(top, viewportTop + viewportEl.outerHeight() - height - this.margin);
1981                         top = Math.max(top, viewportTop + this.margin);
1982                         left = Math.min(left, viewportLeft + viewportEl.outerWidth() - width - this.margin);
1983                         left = Math.max(left, viewportLeft + this.margin);
1984                 }
1985
1986                 this.el.css({
1987                         top: top - origin.top,
1988                         left: left - origin.left
1989                 });
1990         },
1991
1992
1993         // Triggers a callback. Calls a function in the option hash of the same name.
1994         // Arguments beyond the first `name` are forwarded on.
1995         // TODO: better code reuse for this. Repeat code
1996         trigger: function(name) {
1997                 if (this.options[name]) {
1998                         this.options[name].apply(this, Array.prototype.slice.call(arguments, 1));
1999                 }
2000         }
2001
2002 });
2003
2004 ;;
2005
2006 /*
2007 A cache for the left/right/top/bottom/width/height values for one or more elements.
2008 Works with both offset (from topleft document) and position (from offsetParent).
2009
2010 options:
2011 - els
2012 - isHorizontal
2013 - isVertical
2014 */
2015 var CoordCache = FC.CoordCache = Class.extend({
2016
2017         els: null, // jQuery set (assumed to be siblings)
2018         forcedOffsetParentEl: null, // options can override the natural offsetParent
2019         origin: null, // {left,top} position of offsetParent of els
2020         boundingRect: null, // constrain cordinates to this rectangle. {left,right,top,bottom} or null
2021         isHorizontal: false, // whether to query for left/right/width
2022         isVertical: false, // whether to query for top/bottom/height
2023
2024         // arrays of coordinates (offsets from topleft of document)
2025         lefts: null,
2026         rights: null,
2027         tops: null,
2028         bottoms: null,
2029
2030
2031         constructor: function(options) {
2032                 this.els = $(options.els);
2033                 this.isHorizontal = options.isHorizontal;
2034                 this.isVertical = options.isVertical;
2035                 this.forcedOffsetParentEl = options.offsetParent ? $(options.offsetParent) : null;
2036         },
2037
2038
2039         // Queries the els for coordinates and stores them.
2040         // Call this method before using and of the get* methods below.
2041         build: function() {
2042                 var offsetParentEl = this.forcedOffsetParentEl || this.els.eq(0).offsetParent();
2043
2044                 this.origin = offsetParentEl.offset();
2045                 this.boundingRect = this.queryBoundingRect();
2046
2047                 if (this.isHorizontal) {
2048                         this.buildElHorizontals();
2049                 }
2050                 if (this.isVertical) {
2051                         this.buildElVerticals();
2052                 }
2053         },
2054
2055
2056         // Destroys all internal data about coordinates, freeing memory
2057         clear: function() {
2058                 this.origin = null;
2059                 this.boundingRect = null;
2060                 this.lefts = null;
2061                 this.rights = null;
2062                 this.tops = null;
2063                 this.bottoms = null;
2064         },
2065
2066
2067         // When called, if coord caches aren't built, builds them
2068         ensureBuilt: function() {
2069                 if (!this.origin) {
2070                         this.build();
2071                 }
2072         },
2073
2074
2075         // Populates the left/right internal coordinate arrays
2076         buildElHorizontals: function() {
2077                 var lefts = [];
2078                 var rights = [];
2079
2080                 this.els.each(function(i, node) {
2081                         var el = $(node);
2082                         var left = el.offset().left;
2083                         var width = el.outerWidth();
2084
2085                         lefts.push(left);
2086                         rights.push(left + width);
2087                 });
2088
2089                 this.lefts = lefts;
2090                 this.rights = rights;
2091         },
2092
2093
2094         // Populates the top/bottom internal coordinate arrays
2095         buildElVerticals: function() {
2096                 var tops = [];
2097                 var bottoms = [];
2098
2099                 this.els.each(function(i, node) {
2100                         var el = $(node);
2101                         var top = el.offset().top;
2102                         var height = el.outerHeight();
2103
2104                         tops.push(top);
2105                         bottoms.push(top + height);
2106                 });
2107
2108                 this.tops = tops;
2109                 this.bottoms = bottoms;
2110         },
2111
2112
2113         // Given a left offset (from document left), returns the index of the el that it horizontally intersects.
2114         // If no intersection is made, returns undefined.
2115         getHorizontalIndex: function(leftOffset) {
2116                 this.ensureBuilt();
2117
2118                 var lefts = this.lefts;
2119                 var rights = this.rights;
2120                 var len = lefts.length;
2121                 var i;
2122
2123                 for (i = 0; i < len; i++) {
2124                         if (leftOffset >= lefts[i] && leftOffset < rights[i]) {
2125                                 return i;
2126                         }
2127                 }
2128         },
2129
2130
2131         // Given a top offset (from document top), returns the index of the el that it vertically intersects.
2132         // If no intersection is made, returns undefined.
2133         getVerticalIndex: function(topOffset) {
2134                 this.ensureBuilt();
2135
2136                 var tops = this.tops;
2137                 var bottoms = this.bottoms;
2138                 var len = tops.length;
2139                 var i;
2140
2141                 for (i = 0; i < len; i++) {
2142                         if (topOffset >= tops[i] && topOffset < bottoms[i]) {
2143                                 return i;
2144                         }
2145                 }
2146         },
2147
2148
2149         // Gets the left offset (from document left) of the element at the given index
2150         getLeftOffset: function(leftIndex) {
2151                 this.ensureBuilt();
2152                 return this.lefts[leftIndex];
2153         },
2154
2155
2156         // Gets the left position (from offsetParent left) of the element at the given index
2157         getLeftPosition: function(leftIndex) {
2158                 this.ensureBuilt();
2159                 return this.lefts[leftIndex] - this.origin.left;
2160         },
2161
2162
2163         // Gets the right offset (from document left) of the element at the given index.
2164         // This value is NOT relative to the document's right edge, like the CSS concept of "right" would be.
2165         getRightOffset: function(leftIndex) {
2166                 this.ensureBuilt();
2167                 return this.rights[leftIndex];
2168         },
2169
2170
2171         // Gets the right position (from offsetParent left) of the element at the given index.
2172         // This value is NOT relative to the offsetParent's right edge, like the CSS concept of "right" would be.
2173         getRightPosition: function(leftIndex) {
2174                 this.ensureBuilt();
2175                 return this.rights[leftIndex] - this.origin.left;
2176         },
2177
2178
2179         // Gets the width of the element at the given index
2180         getWidth: function(leftIndex) {
2181                 this.ensureBuilt();
2182                 return this.rights[leftIndex] - this.lefts[leftIndex];
2183         },
2184
2185
2186         // Gets the top offset (from document top) of the element at the given index
2187         getTopOffset: function(topIndex) {
2188                 this.ensureBuilt();
2189                 return this.tops[topIndex];
2190         },
2191
2192
2193         // Gets the top position (from offsetParent top) of the element at the given position
2194         getTopPosition: function(topIndex) {
2195                 this.ensureBuilt();
2196                 return this.tops[topIndex] - this.origin.top;
2197         },
2198
2199         // Gets the bottom offset (from the document top) of the element at the given index.
2200         // This value is NOT relative to the offsetParent's bottom edge, like the CSS concept of "bottom" would be.
2201         getBottomOffset: function(topIndex) {
2202                 this.ensureBuilt();
2203                 return this.bottoms[topIndex];
2204         },
2205
2206
2207         // Gets the bottom position (from the offsetParent top) of the element at the given index.
2208         // This value is NOT relative to the offsetParent's bottom edge, like the CSS concept of "bottom" would be.
2209         getBottomPosition: function(topIndex) {
2210                 this.ensureBuilt();
2211                 return this.bottoms[topIndex] - this.origin.top;
2212         },
2213
2214
2215         // Gets the height of the element at the given index
2216         getHeight: function(topIndex) {
2217                 this.ensureBuilt();
2218                 return this.bottoms[topIndex] - this.tops[topIndex];
2219         },
2220
2221
2222         // Bounding Rect
2223         // TODO: decouple this from CoordCache
2224
2225         // Compute and return what the elements' bounding rectangle is, from the user's perspective.
2226         // Right now, only returns a rectangle if constrained by an overflow:scroll element.
2227         queryBoundingRect: function() {
2228                 var scrollParentEl = getScrollParent(this.els.eq(0));
2229
2230                 if (!scrollParentEl.is(document)) {
2231                         return getClientRect(scrollParentEl);
2232                 }
2233         },
2234
2235         isPointInBounds: function(leftOffset, topOffset) {
2236                 return this.isLeftInBounds(leftOffset) && this.isTopInBounds(topOffset);
2237         },
2238
2239         isLeftInBounds: function(leftOffset) {
2240                 return !this.boundingRect || (leftOffset >= this.boundingRect.left && leftOffset < this.boundingRect.right);
2241         },
2242
2243         isTopInBounds: function(topOffset) {
2244                 return !this.boundingRect || (topOffset >= this.boundingRect.top && topOffset < this.boundingRect.bottom);
2245         }
2246
2247 });
2248
2249 ;;
2250
2251 /* Tracks a drag's mouse movement, firing various handlers
2252 ----------------------------------------------------------------------------------------------------------------------*/
2253 // TODO: use Emitter
2254
2255 var DragListener = FC.DragListener = Class.extend(ListenerMixin, MouseIgnorerMixin, {
2256
2257         options: null,
2258         subjectEl: null,
2259
2260         // coordinates of the initial mousedown
2261         originX: null,
2262         originY: null,
2263
2264         // the wrapping element that scrolls, or MIGHT scroll if there's overflow.
2265         // TODO: do this for wrappers that have overflow:hidden as well.
2266         scrollEl: null,
2267
2268         isInteracting: false,
2269         isDistanceSurpassed: false,
2270         isDelayEnded: false,
2271         isDragging: false,
2272         isTouch: false,
2273
2274         delay: null,
2275         delayTimeoutId: null,
2276         minDistance: null,
2277
2278         handleTouchScrollProxy: null, // calls handleTouchScroll, always bound to `this`
2279
2280
2281         constructor: function(options) {
2282                 this.options = options || {};
2283                 this.handleTouchScrollProxy = proxy(this, 'handleTouchScroll');
2284                 this.initMouseIgnoring(500);
2285         },
2286
2287
2288         // Interaction (high-level)
2289         // -----------------------------------------------------------------------------------------------------------------
2290
2291
2292         startInteraction: function(ev, extraOptions) {
2293                 var isTouch = getEvIsTouch(ev);
2294
2295                 if (ev.type === 'mousedown') {
2296                         if (this.isIgnoringMouse) {
2297                                 return;
2298                         }
2299                         else if (!isPrimaryMouseButton(ev)) {
2300                                 return;
2301                         }
2302                         else {
2303                                 ev.preventDefault(); // prevents native selection in most browsers
2304                         }
2305                 }
2306
2307                 if (!this.isInteracting) {
2308
2309                         // process options
2310                         extraOptions = extraOptions || {};
2311                         this.delay = firstDefined(extraOptions.delay, this.options.delay, 0);
2312                         this.minDistance = firstDefined(extraOptions.distance, this.options.distance, 0);
2313                         this.subjectEl = this.options.subjectEl;
2314
2315                         this.isInteracting = true;
2316                         this.isTouch = isTouch;
2317                         this.isDelayEnded = false;
2318                         this.isDistanceSurpassed = false;
2319
2320                         this.originX = getEvX(ev);
2321                         this.originY = getEvY(ev);
2322                         this.scrollEl = getScrollParent($(ev.target));
2323
2324                         this.bindHandlers();
2325                         this.initAutoScroll();
2326                         this.handleInteractionStart(ev);
2327                         this.startDelay(ev);
2328
2329                         if (!this.minDistance) {
2330                                 this.handleDistanceSurpassed(ev);
2331                         }
2332                 }
2333         },
2334
2335
2336         handleInteractionStart: function(ev) {
2337                 this.trigger('interactionStart', ev);
2338         },
2339
2340
2341         endInteraction: function(ev, isCancelled) {
2342                 if (this.isInteracting) {
2343                         this.endDrag(ev);
2344
2345                         if (this.delayTimeoutId) {
2346                                 clearTimeout(this.delayTimeoutId);
2347                                 this.delayTimeoutId = null;
2348                         }
2349
2350                         this.destroyAutoScroll();
2351                         this.unbindHandlers();
2352
2353                         this.isInteracting = false;
2354                         this.handleInteractionEnd(ev, isCancelled);
2355
2356                         // a touchstart+touchend on the same element will result in the following addition simulated events:
2357                         // mouseover + mouseout + click
2358                         // let's ignore these bogus events
2359                         if (this.isTouch) {
2360                                 this.tempIgnoreMouse();
2361                         }
2362                 }
2363         },
2364
2365
2366         handleInteractionEnd: function(ev, isCancelled) {
2367                 this.trigger('interactionEnd', ev, isCancelled || false);
2368         },
2369
2370
2371         // Binding To DOM
2372         // -----------------------------------------------------------------------------------------------------------------
2373
2374
2375         bindHandlers: function() {
2376                 var _this = this;
2377                 var touchStartIgnores = 1;
2378
2379                 if (this.isTouch) {
2380                         this.listenTo($(document), {
2381                                 touchmove: this.handleTouchMove,
2382                                 touchend: this.endInteraction,
2383                                 touchcancel: this.endInteraction,
2384
2385                                 // Sometimes touchend doesn't fire
2386                                 // (can't figure out why. touchcancel doesn't fire either. has to do with scrolling?)
2387                                 // If another touchstart happens, we know it's bogus, so cancel the drag.
2388                                 // touchend will continue to be broken until user does a shorttap/scroll, but this is best we can do.
2389                                 touchstart: function(ev) {
2390                                         if (touchStartIgnores) { // bindHandlers is called from within a touchstart,
2391                                                 touchStartIgnores--; // and we don't want this to fire immediately, so ignore.
2392                                         }
2393                                         else {
2394                                                 _this.endInteraction(ev, true); // isCancelled=true
2395                                         }
2396                                 }
2397                         });
2398
2399                         // listen to ALL scroll actions on the page
2400                         if (
2401                                 !bindAnyScroll(this.handleTouchScrollProxy) && // hopefully this works and short-circuits the rest
2402                                 this.scrollEl // otherwise, attach a single handler to this
2403                         ) {
2404                                 this.listenTo(this.scrollEl, 'scroll', this.handleTouchScroll);
2405                         }
2406                 }
2407                 else {
2408                         this.listenTo($(document), {
2409                                 mousemove: this.handleMouseMove,
2410                                 mouseup: this.endInteraction
2411                         });
2412                 }
2413
2414                 this.listenTo($(document), {
2415                         selectstart: preventDefault, // don't allow selection while dragging
2416                         contextmenu: preventDefault // long taps would open menu on Chrome dev tools
2417                 });
2418         },
2419
2420
2421         unbindHandlers: function() {
2422                 this.stopListeningTo($(document));
2423
2424                 // unbind scroll listening
2425                 unbindAnyScroll(this.handleTouchScrollProxy);
2426                 if (this.scrollEl) {
2427                         this.stopListeningTo(this.scrollEl, 'scroll');
2428                 }
2429         },
2430
2431
2432         // Drag (high-level)
2433         // -----------------------------------------------------------------------------------------------------------------
2434
2435
2436         // extraOptions ignored if drag already started
2437         startDrag: function(ev, extraOptions) {
2438                 this.startInteraction(ev, extraOptions); // ensure interaction began
2439
2440                 if (!this.isDragging) {
2441                         this.isDragging = true;
2442                         this.handleDragStart(ev);
2443                 }
2444         },
2445
2446
2447         handleDragStart: function(ev) {
2448                 this.trigger('dragStart', ev);
2449         },
2450
2451
2452         handleMove: function(ev) {
2453                 var dx = getEvX(ev) - this.originX;
2454                 var dy = getEvY(ev) - this.originY;
2455                 var minDistance = this.minDistance;
2456                 var distanceSq; // current distance from the origin, squared
2457
2458                 if (!this.isDistanceSurpassed) {
2459                         distanceSq = dx * dx + dy * dy;
2460                         if (distanceSq >= minDistance * minDistance) { // use pythagorean theorem
2461                                 this.handleDistanceSurpassed(ev);
2462                         }
2463                 }
2464
2465                 if (this.isDragging) {
2466                         this.handleDrag(dx, dy, ev);
2467                 }
2468         },
2469
2470
2471         // Called while the mouse is being moved and when we know a legitimate drag is taking place
2472         handleDrag: function(dx, dy, ev) {
2473                 this.trigger('drag', dx, dy, ev);
2474                 this.updateAutoScroll(ev); // will possibly cause scrolling
2475         },
2476
2477
2478         endDrag: function(ev) {
2479                 if (this.isDragging) {
2480                         this.isDragging = false;
2481                         this.handleDragEnd(ev);
2482                 }
2483         },
2484
2485
2486         handleDragEnd: function(ev) {
2487                 this.trigger('dragEnd', ev);
2488         },
2489
2490
2491         // Delay
2492         // -----------------------------------------------------------------------------------------------------------------
2493
2494
2495         startDelay: function(initialEv) {
2496                 var _this = this;
2497
2498                 if (this.delay) {
2499                         this.delayTimeoutId = setTimeout(function() {
2500                                 _this.handleDelayEnd(initialEv);
2501                         }, this.delay);
2502                 }
2503                 else {
2504                         this.handleDelayEnd(initialEv);
2505                 }
2506         },
2507
2508
2509         handleDelayEnd: function(initialEv) {
2510                 this.isDelayEnded = true;
2511
2512                 if (this.isDistanceSurpassed) {
2513                         this.startDrag(initialEv);
2514                 }
2515         },
2516
2517
2518         // Distance
2519         // -----------------------------------------------------------------------------------------------------------------
2520
2521
2522         handleDistanceSurpassed: function(ev) {
2523                 this.isDistanceSurpassed = true;
2524
2525                 if (this.isDelayEnded) {
2526                         this.startDrag(ev);
2527                 }
2528         },
2529
2530
2531         // Mouse / Touch
2532         // -----------------------------------------------------------------------------------------------------------------
2533
2534
2535         handleTouchMove: function(ev) {
2536                 // prevent inertia and touchmove-scrolling while dragging
2537                 if (this.isDragging) {
2538                         ev.preventDefault();
2539                 }
2540
2541                 this.handleMove(ev);
2542         },
2543
2544
2545         handleMouseMove: function(ev) {
2546                 this.handleMove(ev);
2547         },
2548
2549
2550         // Scrolling (unrelated to auto-scroll)
2551         // -----------------------------------------------------------------------------------------------------------------
2552
2553
2554         handleTouchScroll: function(ev) {
2555                 // if the drag is being initiated by touch, but a scroll happens before
2556                 // the drag-initiating delay is over, cancel the drag
2557                 if (!this.isDragging) {
2558                         this.endInteraction(ev, true); // isCancelled=true
2559                 }
2560         },
2561
2562
2563         // Utils
2564         // -----------------------------------------------------------------------------------------------------------------
2565
2566
2567         // Triggers a callback. Calls a function in the option hash of the same name.
2568         // Arguments beyond the first `name` are forwarded on.
2569         trigger: function(name) {
2570                 if (this.options[name]) {
2571                         this.options[name].apply(this, Array.prototype.slice.call(arguments, 1));
2572                 }
2573                 // makes _methods callable by event name. TODO: kill this
2574                 if (this['_' + name]) {
2575                         this['_' + name].apply(this, Array.prototype.slice.call(arguments, 1));
2576                 }
2577         }
2578
2579
2580 });
2581
2582 ;;
2583 /*
2584 this.scrollEl is set in DragListener
2585 */
2586 DragListener.mixin({
2587
2588         isAutoScroll: false,
2589
2590         scrollBounds: null, // { top, bottom, left, right }
2591         scrollTopVel: null, // pixels per second
2592         scrollLeftVel: null, // pixels per second
2593         scrollIntervalId: null, // ID of setTimeout for scrolling animation loop
2594
2595         // defaults
2596         scrollSensitivity: 30, // pixels from edge for scrolling to start
2597         scrollSpeed: 200, // pixels per second, at maximum speed
2598         scrollIntervalMs: 50, // millisecond wait between scroll increment
2599
2600
2601         initAutoScroll: function() {
2602                 var scrollEl = this.scrollEl;
2603
2604                 this.isAutoScroll =
2605                         this.options.scroll &&
2606                         scrollEl &&
2607                         !scrollEl.is(window) &&
2608                         !scrollEl.is(document);
2609
2610                 if (this.isAutoScroll) {
2611                         // debounce makes sure rapid calls don't happen
2612                         this.listenTo(scrollEl, 'scroll', debounce(this.handleDebouncedScroll, 100));
2613                 }
2614         },
2615
2616
2617         destroyAutoScroll: function() {
2618                 this.endAutoScroll(); // kill any animation loop
2619
2620                 // remove the scroll handler if there is a scrollEl
2621                 if (this.isAutoScroll) {
2622                         this.stopListeningTo(this.scrollEl, 'scroll'); // will probably get removed by unbindHandlers too :(
2623                 }
2624         },
2625
2626
2627         // Computes and stores the bounding rectangle of scrollEl
2628         computeScrollBounds: function() {
2629                 if (this.isAutoScroll) {
2630                         this.scrollBounds = getOuterRect(this.scrollEl);
2631                         // TODO: use getClientRect in future. but prevents auto scrolling when on top of scrollbars
2632                 }
2633         },
2634
2635
2636         // Called when the dragging is in progress and scrolling should be updated
2637         updateAutoScroll: function(ev) {
2638                 var sensitivity = this.scrollSensitivity;
2639                 var bounds = this.scrollBounds;
2640                 var topCloseness, bottomCloseness;
2641                 var leftCloseness, rightCloseness;
2642                 var topVel = 0;
2643                 var leftVel = 0;
2644
2645                 if (bounds) { // only scroll if scrollEl exists
2646
2647                         // compute closeness to edges. valid range is from 0.0 - 1.0
2648                         topCloseness = (sensitivity - (getEvY(ev) - bounds.top)) / sensitivity;
2649                         bottomCloseness = (sensitivity - (bounds.bottom - getEvY(ev))) / sensitivity;
2650                         leftCloseness = (sensitivity - (getEvX(ev) - bounds.left)) / sensitivity;
2651                         rightCloseness = (sensitivity - (bounds.right - getEvX(ev))) / sensitivity;
2652
2653                         // translate vertical closeness into velocity.
2654                         // mouse must be completely in bounds for velocity to happen.
2655                         if (topCloseness >= 0 && topCloseness <= 1) {
2656                                 topVel = topCloseness * this.scrollSpeed * -1; // negative. for scrolling up
2657                         }
2658                         else if (bottomCloseness >= 0 && bottomCloseness <= 1) {
2659                                 topVel = bottomCloseness * this.scrollSpeed;
2660                         }
2661
2662                         // translate horizontal closeness into velocity
2663                         if (leftCloseness >= 0 && leftCloseness <= 1) {
2664                                 leftVel = leftCloseness * this.scrollSpeed * -1; // negative. for scrolling left
2665                         }
2666                         else if (rightCloseness >= 0 && rightCloseness <= 1) {
2667                                 leftVel = rightCloseness * this.scrollSpeed;
2668                         }
2669                 }
2670
2671                 this.setScrollVel(topVel, leftVel);
2672         },
2673
2674
2675         // Sets the speed-of-scrolling for the scrollEl
2676         setScrollVel: function(topVel, leftVel) {
2677
2678                 this.scrollTopVel = topVel;
2679                 this.scrollLeftVel = leftVel;
2680
2681                 this.constrainScrollVel(); // massages into realistic values
2682
2683                 // if there is non-zero velocity, and an animation loop hasn't already started, then START
2684                 if ((this.scrollTopVel || this.scrollLeftVel) && !this.scrollIntervalId) {
2685                         this.scrollIntervalId = setInterval(
2686                                 proxy(this, 'scrollIntervalFunc'), // scope to `this`
2687                                 this.scrollIntervalMs
2688                         );
2689                 }
2690         },
2691
2692
2693         // Forces scrollTopVel and scrollLeftVel to be zero if scrolling has already gone all the way
2694         constrainScrollVel: function() {
2695                 var el = this.scrollEl;
2696
2697                 if (this.scrollTopVel < 0) { // scrolling up?
2698                         if (el.scrollTop() <= 0) { // already scrolled all the way up?
2699                                 this.scrollTopVel = 0;
2700                         }
2701                 }
2702                 else if (this.scrollTopVel > 0) { // scrolling down?
2703                         if (el.scrollTop() + el[0].clientHeight >= el[0].scrollHeight) { // already scrolled all the way down?
2704                                 this.scrollTopVel = 0;
2705                         }
2706                 }
2707
2708                 if (this.scrollLeftVel < 0) { // scrolling left?
2709                         if (el.scrollLeft() <= 0) { // already scrolled all the left?
2710                                 this.scrollLeftVel = 0;
2711                         }
2712                 }
2713                 else if (this.scrollLeftVel > 0) { // scrolling right?
2714                         if (el.scrollLeft() + el[0].clientWidth >= el[0].scrollWidth) { // already scrolled all the way right?
2715                                 this.scrollLeftVel = 0;
2716                         }
2717                 }
2718         },
2719
2720
2721         // This function gets called during every iteration of the scrolling animation loop
2722         scrollIntervalFunc: function() {
2723                 var el = this.scrollEl;
2724                 var frac = this.scrollIntervalMs / 1000; // considering animation frequency, what the vel should be mult'd by
2725
2726                 // change the value of scrollEl's scroll
2727                 if (this.scrollTopVel) {
2728                         el.scrollTop(el.scrollTop() + this.scrollTopVel * frac);
2729                 }
2730                 if (this.scrollLeftVel) {
2731                         el.scrollLeft(el.scrollLeft() + this.scrollLeftVel * frac);
2732                 }
2733
2734                 this.constrainScrollVel(); // since the scroll values changed, recompute the velocities
2735
2736                 // if scrolled all the way, which causes the vels to be zero, stop the animation loop
2737                 if (!this.scrollTopVel && !this.scrollLeftVel) {
2738                         this.endAutoScroll();
2739                 }
2740         },
2741
2742
2743         // Kills any existing scrolling animation loop
2744         endAutoScroll: function() {
2745                 if (this.scrollIntervalId) {
2746                         clearInterval(this.scrollIntervalId);
2747                         this.scrollIntervalId = null;
2748
2749                         this.handleScrollEnd();
2750                 }
2751         },
2752
2753
2754         // Get called when the scrollEl is scrolled (NOTE: this is delayed via debounce)
2755         handleDebouncedScroll: function() {
2756                 // recompute all coordinates, but *only* if this is *not* part of our scrolling animation
2757                 if (!this.scrollIntervalId) {
2758                         this.handleScrollEnd();
2759                 }
2760         },
2761
2762
2763         // Called when scrolling has stopped, whether through auto scroll, or the user scrolling
2764         handleScrollEnd: function() {
2765         }
2766
2767 });
2768 ;;
2769
2770 /* Tracks mouse movements over a component and raises events about which hit the mouse is over.
2771 ------------------------------------------------------------------------------------------------------------------------
2772 options:
2773 - subjectEl
2774 - subjectCenter
2775 */
2776
2777 var HitDragListener = DragListener.extend({
2778
2779         component: null, // converts coordinates to hits
2780                 // methods: prepareHits, releaseHits, queryHit
2781
2782         origHit: null, // the hit the mouse was over when listening started
2783         hit: null, // the hit the mouse is over
2784         coordAdjust: null, // delta that will be added to the mouse coordinates when computing collisions
2785
2786
2787         constructor: function(component, options) {
2788                 DragListener.call(this, options); // call the super-constructor
2789
2790                 this.component = component;
2791         },
2792
2793
2794         // Called when drag listening starts (but a real drag has not necessarily began).
2795         // ev might be undefined if dragging was started manually.
2796         handleInteractionStart: function(ev) {
2797                 var subjectEl = this.subjectEl;
2798                 var subjectRect;
2799                 var origPoint;
2800                 var point;
2801
2802                 this.computeCoords();
2803
2804                 if (ev) {
2805                         origPoint = { left: getEvX(ev), top: getEvY(ev) };
2806                         point = origPoint;
2807
2808                         // constrain the point to bounds of the element being dragged
2809                         if (subjectEl) {
2810                                 subjectRect = getOuterRect(subjectEl); // used for centering as well
2811                                 point = constrainPoint(point, subjectRect);
2812                         }
2813
2814                         this.origHit = this.queryHit(point.left, point.top);
2815
2816                         // treat the center of the subject as the collision point?
2817                         if (subjectEl && this.options.subjectCenter) {
2818
2819                                 // only consider the area the subject overlaps the hit. best for large subjects.
2820                                 // TODO: skip this if hit didn't supply left/right/top/bottom
2821                                 if (this.origHit) {
2822                                         subjectRect = intersectRects(this.origHit, subjectRect) ||
2823                                                 subjectRect; // in case there is no intersection
2824                                 }
2825
2826                                 point = getRectCenter(subjectRect);
2827                         }
2828
2829                         this.coordAdjust = diffPoints(point, origPoint); // point - origPoint
2830                 }
2831                 else {
2832                         this.origHit = null;
2833                         this.coordAdjust = null;
2834                 }
2835
2836                 // call the super-method. do it after origHit has been computed
2837                 DragListener.prototype.handleInteractionStart.apply(this, arguments);
2838         },
2839
2840
2841         // Recomputes the drag-critical positions of elements
2842         computeCoords: function() {
2843                 this.component.prepareHits();
2844                 this.computeScrollBounds(); // why is this here??????
2845         },
2846
2847
2848         // Called when the actual drag has started
2849         handleDragStart: function(ev) {
2850                 var hit;
2851
2852                 DragListener.prototype.handleDragStart.apply(this, arguments); // call the super-method
2853
2854                 // might be different from this.origHit if the min-distance is large
2855                 hit = this.queryHit(getEvX(ev), getEvY(ev));
2856
2857                 // report the initial hit the mouse is over
2858                 // especially important if no min-distance and drag starts immediately
2859                 if (hit) {
2860                         this.handleHitOver(hit);
2861                 }
2862         },
2863
2864
2865         // Called when the drag moves
2866         handleDrag: function(dx, dy, ev) {
2867                 var hit;
2868
2869                 DragListener.prototype.handleDrag.apply(this, arguments); // call the super-method
2870
2871                 hit = this.queryHit(getEvX(ev), getEvY(ev));
2872
2873                 if (!isHitsEqual(hit, this.hit)) { // a different hit than before?
2874                         if (this.hit) {
2875                                 this.handleHitOut();
2876                         }
2877                         if (hit) {
2878                                 this.handleHitOver(hit);
2879                         }
2880                 }
2881         },
2882
2883
2884         // Called when dragging has been stopped
2885         handleDragEnd: function() {
2886                 this.handleHitDone();
2887                 DragListener.prototype.handleDragEnd.apply(this, arguments); // call the super-method
2888         },
2889
2890
2891         // Called when a the mouse has just moved over a new hit
2892         handleHitOver: function(hit) {
2893                 var isOrig = isHitsEqual(hit, this.origHit);
2894
2895                 this.hit = hit;
2896
2897                 this.trigger('hitOver', this.hit, isOrig, this.origHit);
2898         },
2899
2900
2901         // Called when the mouse has just moved out of a hit
2902         handleHitOut: function() {
2903                 if (this.hit) {
2904                         this.trigger('hitOut', this.hit);
2905                         this.handleHitDone();
2906                         this.hit = null;
2907                 }
2908         },
2909
2910
2911         // Called after a hitOut. Also called before a dragStop
2912         handleHitDone: function() {
2913                 if (this.hit) {
2914                         this.trigger('hitDone', this.hit);
2915                 }
2916         },
2917
2918
2919         // Called when the interaction ends, whether there was a real drag or not
2920         handleInteractionEnd: function() {
2921                 DragListener.prototype.handleInteractionEnd.apply(this, arguments); // call the super-method
2922
2923                 this.origHit = null;
2924                 this.hit = null;
2925
2926                 this.component.releaseHits();
2927         },
2928
2929
2930         // Called when scrolling has stopped, whether through auto scroll, or the user scrolling
2931         handleScrollEnd: function() {
2932                 DragListener.prototype.handleScrollEnd.apply(this, arguments); // call the super-method
2933
2934                 this.computeCoords(); // hits' absolute positions will be in new places. recompute
2935         },
2936
2937
2938         // Gets the hit underneath the coordinates for the given mouse event
2939         queryHit: function(left, top) {
2940
2941                 if (this.coordAdjust) {
2942                         left += this.coordAdjust.left;
2943                         top += this.coordAdjust.top;
2944                 }
2945
2946                 return this.component.queryHit(left, top);
2947         }
2948
2949 });
2950
2951
2952 // Returns `true` if the hits are identically equal. `false` otherwise. Must be from the same component.
2953 // Two null values will be considered equal, as two "out of the component" states are the same.
2954 function isHitsEqual(hit0, hit1) {
2955
2956         if (!hit0 && !hit1) {
2957                 return true;
2958         }
2959
2960         if (hit0 && hit1) {
2961                 return hit0.component === hit1.component &&
2962                         isHitPropsWithin(hit0, hit1) &&
2963                         isHitPropsWithin(hit1, hit0); // ensures all props are identical
2964         }
2965
2966         return false;
2967 }
2968
2969
2970 // Returns true if all of subHit's non-standard properties are within superHit
2971 function isHitPropsWithin(subHit, superHit) {
2972         for (var propName in subHit) {
2973                 if (!/^(component|left|right|top|bottom)$/.test(propName)) {
2974                         if (subHit[propName] !== superHit[propName]) {
2975                                 return false;
2976                         }
2977                 }
2978         }
2979         return true;
2980 }
2981
2982 ;;
2983
2984 /* Creates a clone of an element and lets it track the mouse as it moves
2985 ----------------------------------------------------------------------------------------------------------------------*/
2986
2987 var MouseFollower = Class.extend(ListenerMixin, {
2988
2989         options: null,
2990
2991         sourceEl: null, // the element that will be cloned and made to look like it is dragging
2992         el: null, // the clone of `sourceEl` that will track the mouse
2993         parentEl: null, // the element that `el` (the clone) will be attached to
2994
2995         // the initial position of el, relative to the offset parent. made to match the initial offset of sourceEl
2996         top0: null,
2997         left0: null,
2998
2999         // the absolute coordinates of the initiating touch/mouse action
3000         y0: null,
3001         x0: null,
3002
3003         // the number of pixels the mouse has moved from its initial position
3004         topDelta: null,
3005         leftDelta: null,
3006
3007         isFollowing: false,
3008         isHidden: false,
3009         isAnimating: false, // doing the revert animation?
3010
3011         constructor: function(sourceEl, options) {
3012                 this.options = options = options || {};
3013                 this.sourceEl = sourceEl;
3014                 this.parentEl = options.parentEl ? $(options.parentEl) : sourceEl.parent(); // default to sourceEl's parent
3015         },
3016
3017
3018         // Causes the element to start following the mouse
3019         start: function(ev) {
3020                 if (!this.isFollowing) {
3021                         this.isFollowing = true;
3022
3023                         this.y0 = getEvY(ev);
3024                         this.x0 = getEvX(ev);
3025                         this.topDelta = 0;
3026                         this.leftDelta = 0;
3027
3028                         if (!this.isHidden) {
3029                                 this.updatePosition();
3030                         }
3031
3032                         if (getEvIsTouch(ev)) {
3033                                 this.listenTo($(document), 'touchmove', this.handleMove);
3034                         }
3035                         else {
3036                                 this.listenTo($(document), 'mousemove', this.handleMove);
3037                         }
3038                 }
3039         },
3040
3041
3042         // Causes the element to stop following the mouse. If shouldRevert is true, will animate back to original position.
3043         // `callback` gets invoked when the animation is complete. If no animation, it is invoked immediately.
3044         stop: function(shouldRevert, callback) {
3045                 var _this = this;
3046                 var revertDuration = this.options.revertDuration;
3047
3048                 function complete() { // might be called by .animate(), which might change `this` context
3049                         _this.isAnimating = false;
3050                         _this.removeElement();
3051
3052                         _this.top0 = _this.left0 = null; // reset state for future updatePosition calls
3053
3054                         if (callback) {
3055                                 callback();
3056                         }
3057                 }
3058
3059                 if (this.isFollowing && !this.isAnimating) { // disallow more than one stop animation at a time
3060                         this.isFollowing = false;
3061
3062                         this.stopListeningTo($(document));
3063
3064                         if (shouldRevert && revertDuration && !this.isHidden) { // do a revert animation?
3065                                 this.isAnimating = true;
3066                                 this.el.animate({
3067                                         top: this.top0,
3068                                         left: this.left0
3069                                 }, {
3070                                         duration: revertDuration,
3071                                         complete: complete
3072                                 });
3073                         }
3074                         else {
3075                                 complete();
3076                         }
3077                 }
3078         },
3079
3080
3081         // Gets the tracking element. Create it if necessary
3082         getEl: function() {
3083                 var el = this.el;
3084
3085                 if (!el) {
3086                         el = this.el = this.sourceEl.clone()
3087                                 .addClass(this.options.additionalClass || '')
3088                                 .css({
3089                                         position: 'absolute',
3090                                         visibility: '', // in case original element was hidden (commonly through hideEvents())
3091                                         display: this.isHidden ? 'none' : '', // for when initially hidden
3092                                         margin: 0,
3093                                         right: 'auto', // erase and set width instead
3094                                         bottom: 'auto', // erase and set height instead
3095                                         width: this.sourceEl.width(), // explicit height in case there was a 'right' value
3096                                         height: this.sourceEl.height(), // explicit width in case there was a 'bottom' value
3097                                         opacity: this.options.opacity || '',
3098                                         zIndex: this.options.zIndex
3099                                 });
3100
3101                         // we don't want long taps or any mouse interaction causing selection/menus.
3102                         // would use preventSelection(), but that prevents selectstart, causing problems.
3103                         el.addClass('fc-unselectable');
3104
3105                         el.appendTo(this.parentEl);
3106                 }
3107
3108                 return el;
3109         },
3110
3111
3112         // Removes the tracking element if it has already been created
3113         removeElement: function() {
3114                 if (this.el) {
3115                         this.el.remove();
3116                         this.el = null;
3117                 }
3118         },
3119
3120
3121         // Update the CSS position of the tracking element
3122         updatePosition: function() {
3123                 var sourceOffset;
3124                 var origin;
3125
3126                 this.getEl(); // ensure this.el
3127
3128                 // make sure origin info was computed
3129                 if (this.top0 === null) {
3130                         sourceOffset = this.sourceEl.offset();
3131                         origin = this.el.offsetParent().offset();
3132                         this.top0 = sourceOffset.top - origin.top;
3133                         this.left0 = sourceOffset.left - origin.left;
3134                 }
3135
3136                 this.el.css({
3137                         top: this.top0 + this.topDelta,
3138                         left: this.left0 + this.leftDelta
3139                 });
3140         },
3141
3142
3143         // Gets called when the user moves the mouse
3144         handleMove: function(ev) {
3145                 this.topDelta = getEvY(ev) - this.y0;
3146                 this.leftDelta = getEvX(ev) - this.x0;
3147
3148                 if (!this.isHidden) {
3149                         this.updatePosition();
3150                 }
3151         },
3152
3153
3154         // Temporarily makes the tracking element invisible. Can be called before following starts
3155         hide: function() {
3156                 if (!this.isHidden) {
3157                         this.isHidden = true;
3158                         if (this.el) {
3159                                 this.el.hide();
3160                         }
3161                 }
3162         },
3163
3164
3165         // Show the tracking element after it has been temporarily hidden
3166         show: function() {
3167                 if (this.isHidden) {
3168                         this.isHidden = false;
3169                         this.updatePosition();
3170                         this.getEl().show();
3171                 }
3172         }
3173
3174 });
3175
3176 ;;
3177
3178 /* An abstract class comprised of a "grid" of areas that each represent a specific datetime
3179 ----------------------------------------------------------------------------------------------------------------------*/
3180
3181 var Grid = FC.Grid = Class.extend(ListenerMixin, MouseIgnorerMixin, {
3182
3183         // self-config, overridable by subclasses
3184         hasDayInteractions: true, // can user click/select ranges of time?
3185
3186         view: null, // a View object
3187         isRTL: null, // shortcut to the view's isRTL option
3188
3189         start: null,
3190         end: null,
3191
3192         el: null, // the containing element
3193         elsByFill: null, // a hash of jQuery element sets used for rendering each fill. Keyed by fill name.
3194
3195         // derived from options
3196         eventTimeFormat: null,
3197         displayEventTime: null,
3198         displayEventEnd: null,
3199
3200         minResizeDuration: null, // TODO: hack. set by subclasses. minumum event resize duration
3201
3202         // if defined, holds the unit identified (ex: "year" or "month") that determines the level of granularity
3203         // of the date areas. if not defined, assumes to be day and time granularity.
3204         // TODO: port isTimeScale into same system?
3205         largeUnit: null,
3206
3207         dayDragListener: null,
3208         segDragListener: null,
3209         segResizeListener: null,
3210         externalDragListener: null,
3211
3212
3213         constructor: function(view) {
3214                 this.view = view;
3215                 this.isRTL = view.opt('isRTL');
3216                 this.elsByFill = {};
3217
3218                 this.dayDragListener = this.buildDayDragListener();
3219                 this.initMouseIgnoring();
3220         },
3221
3222
3223         /* Options
3224         ------------------------------------------------------------------------------------------------------------------*/
3225
3226
3227         // Generates the format string used for event time text, if not explicitly defined by 'timeFormat'
3228         computeEventTimeFormat: function() {
3229                 return this.view.opt('smallTimeFormat');
3230         },
3231
3232
3233         // Determines whether events should have their end times displayed, if not explicitly defined by 'displayEventTime'.
3234         // Only applies to non-all-day events.
3235         computeDisplayEventTime: function() {
3236                 return true;
3237         },
3238
3239
3240         // Determines whether events should have their end times displayed, if not explicitly defined by 'displayEventEnd'
3241         computeDisplayEventEnd: function() {
3242                 return true;
3243         },
3244
3245
3246         /* Dates
3247         ------------------------------------------------------------------------------------------------------------------*/
3248
3249
3250         // Tells the grid about what period of time to display.
3251         // Any date-related internal data should be generated.
3252         setRange: function(range) {
3253                 this.start = range.start.clone();
3254                 this.end = range.end.clone();
3255
3256                 this.rangeUpdated();
3257                 this.processRangeOptions();
3258         },
3259
3260
3261         // Called when internal variables that rely on the range should be updated
3262         rangeUpdated: function() {
3263         },
3264
3265
3266         // Updates values that rely on options and also relate to range
3267         processRangeOptions: function() {
3268                 var view = this.view;
3269                 var displayEventTime;
3270                 var displayEventEnd;
3271
3272                 this.eventTimeFormat =
3273                         view.opt('eventTimeFormat') ||
3274                         view.opt('timeFormat') || // deprecated
3275                         this.computeEventTimeFormat();
3276
3277                 displayEventTime = view.opt('displayEventTime');
3278                 if (displayEventTime == null) {
3279                         displayEventTime = this.computeDisplayEventTime(); // might be based off of range
3280                 }
3281
3282                 displayEventEnd = view.opt('displayEventEnd');
3283                 if (displayEventEnd == null) {
3284                         displayEventEnd = this.computeDisplayEventEnd(); // might be based off of range
3285                 }
3286
3287                 this.displayEventTime = displayEventTime;
3288                 this.displayEventEnd = displayEventEnd;
3289         },
3290
3291
3292         // Converts a span (has unzoned start/end and any other grid-specific location information)
3293         // into an array of segments (pieces of events whose format is decided by the grid).
3294         spanToSegs: function(span) {
3295                 // subclasses must implement
3296         },
3297
3298
3299         // Diffs the two dates, returning a duration, based on granularity of the grid
3300         // TODO: port isTimeScale into this system?
3301         diffDates: function(a, b) {
3302                 if (this.largeUnit) {
3303                         return diffByUnit(a, b, this.largeUnit);
3304                 }
3305                 else {
3306                         return diffDayTime(a, b);
3307                 }
3308         },
3309
3310
3311         /* Hit Area
3312         ------------------------------------------------------------------------------------------------------------------*/
3313
3314
3315         // Called before one or more queryHit calls might happen. Should prepare any cached coordinates for queryHit
3316         prepareHits: function() {
3317         },
3318
3319
3320         // Called when queryHit calls have subsided. Good place to clear any coordinate caches.
3321         releaseHits: function() {
3322         },
3323
3324
3325         // Given coordinates from the topleft of the document, return data about the date-related area underneath.
3326         // Can return an object with arbitrary properties (although top/right/left/bottom are encouraged).
3327         // Must have a `grid` property, a reference to this current grid. TODO: avoid this
3328         // The returned object will be processed by getHitSpan and getHitEl.
3329         queryHit: function(leftOffset, topOffset) {
3330         },
3331
3332
3333         // Given position-level information about a date-related area within the grid,
3334         // should return an object with at least a start/end date. Can provide other information as well.
3335         getHitSpan: function(hit) {
3336         },
3337
3338
3339         // Given position-level information about a date-related area within the grid,
3340         // should return a jQuery element that best represents it. passed to dayClick callback.
3341         getHitEl: function(hit) {
3342         },
3343
3344
3345         /* Rendering
3346         ------------------------------------------------------------------------------------------------------------------*/
3347
3348
3349         // Sets the container element that the grid should render inside of.
3350         // Does other DOM-related initializations.
3351         setElement: function(el) {
3352                 this.el = el;
3353
3354                 if (this.hasDayInteractions) {
3355                         preventSelection(el);
3356
3357                         this.bindDayHandler('touchstart', this.dayTouchStart);
3358                         this.bindDayHandler('mousedown', this.dayMousedown);
3359                 }
3360
3361                 // attach event-element-related handlers. in Grid.events
3362                 // same garbage collection note as above.
3363                 this.bindSegHandlers();
3364
3365                 this.bindGlobalHandlers();
3366         },
3367
3368
3369         bindDayHandler: function(name, handler) {
3370                 var _this = this;
3371
3372                 // attach a handler to the grid's root element.
3373                 // jQuery will take care of unregistering them when removeElement gets called.
3374                 this.el.on(name, function(ev) {
3375                         if (
3376                                 !$(ev.target).is(
3377                                         _this.segSelector + ',' + // directly on an event element
3378                                         _this.segSelector + ' *,' + // within an event element
3379                                         '.fc-more,' + // a "more.." link
3380                                         'a[data-goto]' // a clickable nav link
3381                                 )
3382                         ) {
3383                                 return handler.call(_this, ev);
3384                         }
3385                 });
3386         },
3387
3388
3389         // Removes the grid's container element from the DOM. Undoes any other DOM-related attachments.
3390         // DOES NOT remove any content beforehand (doesn't clear events or call unrenderDates), unlike View
3391         removeElement: function() {
3392                 this.unbindGlobalHandlers();
3393                 this.clearDragListeners();
3394
3395                 this.el.remove();
3396
3397                 // NOTE: we don't null-out this.el for the same reasons we don't do it within View::removeElement
3398         },
3399
3400
3401         // Renders the basic structure of grid view before any content is rendered
3402         renderSkeleton: function() {
3403                 // subclasses should implement
3404         },
3405
3406
3407         // Renders the grid's date-related content (like areas that represent days/times).
3408         // Assumes setRange has already been called and the skeleton has already been rendered.
3409         renderDates: function() {
3410                 // subclasses should implement
3411         },
3412
3413
3414         // Unrenders the grid's date-related content
3415         unrenderDates: function() {
3416                 // subclasses should implement
3417         },
3418
3419
3420         /* Handlers
3421         ------------------------------------------------------------------------------------------------------------------*/
3422
3423
3424         // Binds DOM handlers to elements that reside outside the grid, such as the document
3425         bindGlobalHandlers: function() {
3426                 this.listenTo($(document), {
3427                         dragstart: this.externalDragStart, // jqui
3428                         sortstart: this.externalDragStart // jqui
3429                 });
3430         },
3431
3432
3433         // Unbinds DOM handlers from elements that reside outside the grid
3434         unbindGlobalHandlers: function() {
3435                 this.stopListeningTo($(document));
3436         },
3437
3438
3439         // Process a mousedown on an element that represents a day. For day clicking and selecting.
3440         dayMousedown: function(ev) {
3441                 if (!this.isIgnoringMouse) {
3442                         this.dayDragListener.startInteraction(ev, {
3443                                 //distance: 5, // needs more work if we want dayClick to fire correctly
3444                         });
3445                 }
3446         },
3447
3448
3449         dayTouchStart: function(ev) {
3450                 var view = this.view;
3451
3452                 // HACK to prevent a user's clickaway for unselecting a range or an event
3453                 // from causing a dayClick.
3454                 if (view.isSelected || view.selectedEvent) {
3455                         this.tempIgnoreMouse();
3456                 }
3457
3458                 this.dayDragListener.startInteraction(ev, {
3459                         delay: this.view.opt('longPressDelay')
3460                 });
3461         },
3462
3463
3464         // Creates a listener that tracks the user's drag across day elements.
3465         // For day clicking and selecting.
3466         buildDayDragListener: function() {
3467                 var _this = this;
3468                 var view = this.view;
3469                 var isSelectable = view.opt('selectable');
3470                 var dayClickHit; // null if invalid dayClick
3471                 var selectionSpan; // null if invalid selection
3472
3473                 // this listener tracks a mousedown on a day element, and a subsequent drag.
3474                 // if the drag ends on the same day, it is a 'dayClick'.
3475                 // if 'selectable' is enabled, this listener also detects selections.
3476                 var dragListener = new HitDragListener(this, {
3477                         scroll: view.opt('dragScroll'),
3478                         interactionStart: function() {
3479                                 dayClickHit = dragListener.origHit; // for dayClick, where no dragging happens
3480                                 selectionSpan = null;
3481                         },
3482                         dragStart: function() {
3483                                 view.unselect(); // since we could be rendering a new selection, we want to clear any old one
3484                         },
3485                         hitOver: function(hit, isOrig, origHit) {
3486                                 if (origHit) { // click needs to have started on a hit
3487
3488                                         // if user dragged to another cell at any point, it can no longer be a dayClick
3489                                         if (!isOrig) {
3490                                                 dayClickHit = null;
3491                                         }
3492
3493                                         if (isSelectable) {
3494                                                 selectionSpan = _this.computeSelection(
3495                                                         _this.getHitSpan(origHit),
3496                                                         _this.getHitSpan(hit)
3497                                                 );
3498                                                 if (selectionSpan) {
3499                                                         _this.renderSelection(selectionSpan);
3500                                                 }
3501                                                 else if (selectionSpan === false) {
3502                                                         disableCursor();
3503                                                 }
3504                                         }
3505                                 }
3506                         },
3507                         hitOut: function() { // called before mouse moves to a different hit OR moved out of all hits
3508                                 dayClickHit = null;
3509                                 selectionSpan = null;
3510                                 _this.unrenderSelection();
3511                         },
3512                         hitDone: function() { // called after a hitOut OR before a dragEnd
3513                                 enableCursor();
3514                         },
3515                         interactionEnd: function(ev, isCancelled) {
3516                                 if (!isCancelled) {
3517                                         if (
3518                                                 dayClickHit &&
3519                                                 !_this.isIgnoringMouse // see hack in dayTouchStart
3520                                         ) {
3521                                                 view.triggerDayClick(
3522                                                         _this.getHitSpan(dayClickHit),
3523                                                         _this.getHitEl(dayClickHit),
3524                                                         ev
3525                                                 );
3526                                         }
3527                                         if (selectionSpan) {
3528                                                 // the selection will already have been rendered. just report it
3529                                                 view.reportSelection(selectionSpan, ev);
3530                                         }
3531                                 }
3532                         }
3533                 });
3534
3535                 return dragListener;
3536         },
3537
3538
3539         // Kills all in-progress dragging.
3540         // Useful for when public API methods that result in re-rendering are invoked during a drag.
3541         // Also useful for when touch devices misbehave and don't fire their touchend.
3542         clearDragListeners: function() {
3543                 this.dayDragListener.endInteraction();
3544
3545                 if (this.segDragListener) {
3546                         this.segDragListener.endInteraction(); // will clear this.segDragListener
3547                 }
3548                 if (this.segResizeListener) {
3549                         this.segResizeListener.endInteraction(); // will clear this.segResizeListener
3550                 }
3551                 if (this.externalDragListener) {
3552                         this.externalDragListener.endInteraction(); // will clear this.externalDragListener
3553                 }
3554         },
3555
3556
3557         /* Event Helper
3558         ------------------------------------------------------------------------------------------------------------------*/
3559         // TODO: should probably move this to Grid.events, like we did event dragging / resizing
3560
3561
3562         // Renders a mock event at the given event location, which contains zoned start/end properties.
3563         // Returns all mock event elements.
3564         renderEventLocationHelper: function(eventLocation, sourceSeg) {
3565                 var fakeEvent = this.fabricateHelperEvent(eventLocation, sourceSeg);
3566
3567                 return this.renderHelper(fakeEvent, sourceSeg); // do the actual rendering
3568         },
3569
3570
3571         // Builds a fake event given zoned event date properties and a segment is should be inspired from.
3572         // The range's end can be null, in which case the mock event that is rendered will have a null end time.
3573         // `sourceSeg` is the internal segment object involved in the drag. If null, something external is dragging.
3574         fabricateHelperEvent: function(eventLocation, sourceSeg) {
3575                 var fakeEvent = sourceSeg ? createObject(sourceSeg.event) : {}; // mask the original event object if possible
3576
3577                 fakeEvent.start = eventLocation.start.clone();
3578                 fakeEvent.end = eventLocation.end ? eventLocation.end.clone() : null;
3579                 fakeEvent.allDay = null; // force it to be freshly computed by normalizeEventDates
3580                 this.view.calendar.normalizeEventDates(fakeEvent);
3581
3582                 // this extra className will be useful for differentiating real events from mock events in CSS
3583                 fakeEvent.className = (fakeEvent.className || []).concat('fc-helper');
3584
3585                 // if something external is being dragged in, don't render a resizer
3586                 if (!sourceSeg) {
3587                         fakeEvent.editable = false;
3588                 }
3589
3590                 return fakeEvent;
3591         },
3592
3593
3594         // Renders a mock event. Given zoned event date properties.
3595         // Must return all mock event elements.
3596         renderHelper: function(eventLocation, sourceSeg) {
3597                 // subclasses must implement
3598         },
3599
3600
3601         // Unrenders a mock event
3602         unrenderHelper: function() {
3603                 // subclasses must implement
3604         },
3605
3606
3607         /* Selection
3608         ------------------------------------------------------------------------------------------------------------------*/
3609
3610
3611         // Renders a visual indication of a selection. Will highlight by default but can be overridden by subclasses.
3612         // Given a span (unzoned start/end and other misc data)
3613         renderSelection: function(span) {
3614                 this.renderHighlight(span);
3615         },
3616
3617
3618         // Unrenders any visual indications of a selection. Will unrender a highlight by default.
3619         unrenderSelection: function() {
3620                 this.unrenderHighlight();
3621         },
3622
3623
3624         // Given the first and last date-spans of a selection, returns another date-span object.
3625         // Subclasses can override and provide additional data in the span object. Will be passed to renderSelection().
3626         // Will return false if the selection is invalid and this should be indicated to the user.
3627         // Will return null/undefined if a selection invalid but no error should be reported.
3628         computeSelection: function(span0, span1) {
3629                 var span = this.computeSelectionSpan(span0, span1);
3630
3631                 if (span && !this.view.calendar.isSelectionSpanAllowed(span)) {
3632                         return false;
3633                 }
3634
3635                 return span;
3636         },
3637
3638
3639         // Given two spans, must return the combination of the two.
3640         // TODO: do this separation of concerns (combining VS validation) for event dnd/resize too.
3641         computeSelectionSpan: function(span0, span1) {
3642                 var dates = [ span0.start, span0.end, span1.start, span1.end ];
3643
3644                 dates.sort(compareNumbers); // sorts chronologically. works with Moments
3645
3646                 return { start: dates[0].clone(), end: dates[3].clone() };
3647         },
3648
3649
3650         /* Highlight
3651         ------------------------------------------------------------------------------------------------------------------*/
3652
3653
3654         // Renders an emphasis on the given date range. Given a span (unzoned start/end and other misc data)
3655         renderHighlight: function(span) {
3656                 this.renderFill('highlight', this.spanToSegs(span));
3657         },
3658
3659
3660         // Unrenders the emphasis on a date range
3661         unrenderHighlight: function() {
3662                 this.unrenderFill('highlight');
3663         },
3664
3665
3666         // Generates an array of classNames for rendering the highlight. Used by the fill system.
3667         highlightSegClasses: function() {
3668                 return [ 'fc-highlight' ];
3669         },
3670
3671
3672         /* Business Hours
3673         ------------------------------------------------------------------------------------------------------------------*/
3674
3675
3676         renderBusinessHours: function() {
3677         },
3678
3679
3680         unrenderBusinessHours: function() {
3681         },
3682
3683
3684         /* Now Indicator
3685         ------------------------------------------------------------------------------------------------------------------*/
3686
3687
3688         getNowIndicatorUnit: function() {
3689         },
3690
3691
3692         renderNowIndicator: function(date) {
3693         },
3694
3695
3696         unrenderNowIndicator: function() {
3697         },
3698
3699
3700         /* Fill System (highlight, background events, business hours)
3701         --------------------------------------------------------------------------------------------------------------------
3702         TODO: remove this system. like we did in TimeGrid
3703         */
3704
3705
3706         // Renders a set of rectangles over the given segments of time.
3707         // MUST RETURN a subset of segs, the segs that were actually rendered.
3708         // Responsible for populating this.elsByFill. TODO: better API for expressing this requirement
3709         renderFill: function(type, segs) {
3710                 // subclasses must implement
3711         },
3712
3713
3714         // Unrenders a specific type of fill that is currently rendered on the grid
3715         unrenderFill: function(type) {
3716                 var el = this.elsByFill[type];
3717
3718                 if (el) {
3719                         el.remove();
3720                         delete this.elsByFill[type];
3721                 }
3722         },
3723
3724
3725         // Renders and assigns an `el` property for each fill segment. Generic enough to work with different types.
3726         // Only returns segments that successfully rendered.
3727         // To be harnessed by renderFill (implemented by subclasses).
3728         // Analagous to renderFgSegEls.
3729         renderFillSegEls: function(type, segs) {
3730                 var _this = this;
3731                 var segElMethod = this[type + 'SegEl'];
3732                 var html = '';
3733                 var renderedSegs = [];
3734                 var i;
3735
3736                 if (segs.length) {
3737
3738                         // build a large concatenation of segment HTML
3739                         for (i = 0; i < segs.length; i++) {
3740                                 html += this.fillSegHtml(type, segs[i]);
3741                         }
3742
3743                         // Grab individual elements from the combined HTML string. Use each as the default rendering.
3744                         // Then, compute the 'el' for each segment.
3745                         $(html).each(function(i, node) {
3746                                 var seg = segs[i];
3747                                 var el = $(node);
3748
3749                                 // allow custom filter methods per-type
3750                                 if (segElMethod) {
3751                                         el = segElMethod.call(_this, seg, el);
3752                                 }
3753
3754                                 if (el) { // custom filters did not cancel the render
3755                                         el = $(el); // allow custom filter to return raw DOM node
3756
3757                                         // correct element type? (would be bad if a non-TD were inserted into a table for example)
3758                                         if (el.is(_this.fillSegTag)) {
3759                                                 seg.el = el;
3760                                                 renderedSegs.push(seg);
3761                                         }
3762                                 }
3763                         });
3764                 }
3765
3766                 return renderedSegs;
3767         },
3768
3769
3770         fillSegTag: 'div', // subclasses can override
3771
3772
3773         // Builds the HTML needed for one fill segment. Generic enough to work with different types.
3774         fillSegHtml: function(type, seg) {
3775
3776                 // custom hooks per-type
3777                 var classesMethod = this[type + 'SegClasses'];
3778                 var cssMethod = this[type + 'SegCss'];
3779
3780                 var classes = classesMethod ? classesMethod.call(this, seg) : [];
3781                 var css = cssToStr(cssMethod ? cssMethod.call(this, seg) : {});
3782
3783                 return '<' + this.fillSegTag +
3784                         (classes.length ? ' class="' + classes.join(' ') + '"' : '') +
3785                         (css ? ' style="' + css + '"' : '') +
3786                         ' />';
3787         },
3788
3789
3790
3791         /* Generic rendering utilities for subclasses
3792         ------------------------------------------------------------------------------------------------------------------*/
3793
3794
3795         // Computes HTML classNames for a single-day element
3796         getDayClasses: function(date) {
3797                 var view = this.view;
3798                 var today = view.calendar.getNow();
3799                 var classes = [ 'fc-' + dayIDs[date.day()] ];
3800
3801                 if (
3802                         view.intervalDuration.as('months') == 1 &&
3803                         date.month() != view.intervalStart.month()
3804                 ) {
3805                         classes.push('fc-other-month');
3806                 }
3807
3808                 if (date.isSame(today, 'day')) {
3809                         classes.push(
3810                                 'fc-today',
3811                                 view.highlightStateClass
3812                         );
3813                 }
3814                 else if (date < today) {
3815                         classes.push('fc-past');
3816                 }
3817                 else {
3818                         classes.push('fc-future');
3819                 }
3820
3821                 return classes;
3822         }
3823
3824 });
3825
3826 ;;
3827
3828 /* Event-rendering and event-interaction methods for the abstract Grid class
3829 ----------------------------------------------------------------------------------------------------------------------*/
3830
3831 Grid.mixin({
3832
3833         // self-config, overridable by subclasses
3834         segSelector: '.fc-event-container > *', // what constitutes an event element?
3835
3836         mousedOverSeg: null, // the segment object the user's mouse is over. null if over nothing
3837         isDraggingSeg: false, // is a segment being dragged? boolean
3838         isResizingSeg: false, // is a segment being resized? boolean
3839         isDraggingExternal: false, // jqui-dragging an external element? boolean
3840         segs: null, // the *event* segments currently rendered in the grid. TODO: rename to `eventSegs`
3841
3842
3843         // Renders the given events onto the grid
3844         renderEvents: function(events) {
3845                 var bgEvents = [];
3846                 var fgEvents = [];
3847                 var i;
3848
3849                 for (i = 0; i < events.length; i++) {
3850                         (isBgEvent(events[i]) ? bgEvents : fgEvents).push(events[i]);
3851                 }
3852
3853                 this.segs = [].concat( // record all segs
3854                         this.renderBgEvents(bgEvents),
3855                         this.renderFgEvents(fgEvents)
3856                 );
3857         },
3858
3859
3860         renderBgEvents: function(events) {
3861                 var segs = this.eventsToSegs(events);
3862
3863                 // renderBgSegs might return a subset of segs, segs that were actually rendered
3864                 return this.renderBgSegs(segs) || segs;
3865         },
3866
3867
3868         renderFgEvents: function(events) {
3869                 var segs = this.eventsToSegs(events);
3870
3871                 // renderFgSegs might return a subset of segs, segs that were actually rendered
3872                 return this.renderFgSegs(segs) || segs;
3873         },
3874
3875
3876         // Unrenders all events currently rendered on the grid
3877         unrenderEvents: function() {
3878                 this.handleSegMouseout(); // trigger an eventMouseout if user's mouse is over an event
3879                 this.clearDragListeners();
3880
3881                 this.unrenderFgSegs();
3882                 this.unrenderBgSegs();
3883
3884                 this.segs = null;
3885         },
3886
3887
3888         // Retrieves all rendered segment objects currently rendered on the grid
3889         getEventSegs: function() {
3890                 return this.segs || [];
3891         },
3892
3893
3894         /* Foreground Segment Rendering
3895         ------------------------------------------------------------------------------------------------------------------*/
3896
3897
3898         // Renders foreground event segments onto the grid. May return a subset of segs that were rendered.
3899         renderFgSegs: function(segs) {
3900                 // subclasses must implement
3901         },
3902
3903
3904         // Unrenders all currently rendered foreground segments
3905         unrenderFgSegs: function() {
3906                 // subclasses must implement
3907         },
3908
3909
3910         // Renders and assigns an `el` property for each foreground event segment.
3911         // Only returns segments that successfully rendered.
3912         // A utility that subclasses may use.
3913         renderFgSegEls: function(segs, disableResizing) {
3914                 var view = this.view;
3915                 var html = '';
3916                 var renderedSegs = [];
3917                 var i;
3918
3919                 if (segs.length) { // don't build an empty html string
3920
3921                         // build a large concatenation of event segment HTML
3922                         for (i = 0; i < segs.length; i++) {
3923                                 html += this.fgSegHtml(segs[i], disableResizing);
3924                         }
3925
3926                         // Grab individual elements from the combined HTML string. Use each as the default rendering.
3927                         // Then, compute the 'el' for each segment. An el might be null if the eventRender callback returned false.
3928                         $(html).each(function(i, node) {
3929                                 var seg = segs[i];
3930                                 var el = view.resolveEventEl(seg.event, $(node));
3931
3932                                 if (el) {
3933                                         el.data('fc-seg', seg); // used by handlers
3934                                         seg.el = el;
3935                                         renderedSegs.push(seg);
3936                                 }
3937                         });
3938                 }
3939
3940                 return renderedSegs;
3941         },
3942
3943
3944         // Generates the HTML for the default rendering of a foreground event segment. Used by renderFgSegEls()
3945         fgSegHtml: function(seg, disableResizing) {
3946                 // subclasses should implement
3947         },
3948
3949
3950         /* Background Segment Rendering
3951         ------------------------------------------------------------------------------------------------------------------*/
3952
3953
3954         // Renders the given background event segments onto the grid.
3955         // Returns a subset of the segs that were actually rendered.
3956         renderBgSegs: function(segs) {
3957                 return this.renderFill('bgEvent', segs);
3958         },
3959
3960
3961         // Unrenders all the currently rendered background event segments
3962         unrenderBgSegs: function() {
3963                 this.unrenderFill('bgEvent');
3964         },
3965
3966
3967         // Renders a background event element, given the default rendering. Called by the fill system.
3968         bgEventSegEl: function(seg, el) {
3969                 return this.view.resolveEventEl(seg.event, el); // will filter through eventRender
3970         },
3971
3972
3973         // Generates an array of classNames to be used for the default rendering of a background event.
3974         // Called by fillSegHtml.
3975         bgEventSegClasses: function(seg) {
3976                 var event = seg.event;
3977                 var source = event.source || {};
3978
3979                 return [ 'fc-bgevent' ].concat(
3980                         event.className,
3981                         source.className || []
3982                 );
3983         },
3984
3985
3986         // Generates a semicolon-separated CSS string to be used for the default rendering of a background event.
3987         // Called by fillSegHtml.
3988         bgEventSegCss: function(seg) {
3989                 return {
3990                         'background-color': this.getSegSkinCss(seg)['background-color']
3991                 };
3992         },
3993
3994
3995         // Generates an array of classNames to be used for the rendering business hours overlay. Called by the fill system.
3996         // Called by fillSegHtml.
3997         businessHoursSegClasses: function(seg) {
3998                 return [ 'fc-nonbusiness', 'fc-bgevent' ];
3999         },
4000
4001
4002         /* Business Hours
4003         ------------------------------------------------------------------------------------------------------------------*/
4004
4005
4006         // Compute business hour segs for the grid's current date range.
4007         // Caller must ask if whole-day business hours are needed.
4008         buildBusinessHourSegs: function(wholeDay) {
4009                 var events = this.view.calendar.getCurrentBusinessHourEvents(wholeDay);
4010
4011                 // HACK. Eventually refactor business hours "events" system.
4012                 // If no events are given, but businessHours is activated, this means the entire visible range should be
4013                 // marked as *not* business-hours, via inverse-background rendering.
4014                 if (
4015                         !events.length &&
4016                         this.view.calendar.options.businessHours // don't access view option. doesn't update with dynamic options
4017                 ) {
4018                         events = [
4019                                 $.extend({}, BUSINESS_HOUR_EVENT_DEFAULTS, {
4020                                         start: this.view.end, // guaranteed out-of-range
4021                                         end: this.view.end,   // "
4022                                         dow: null
4023                                 })
4024                         ];
4025                 }
4026
4027                 return this.eventsToSegs(events);
4028         },
4029
4030
4031         /* Handlers
4032         ------------------------------------------------------------------------------------------------------------------*/
4033
4034
4035         // Attaches event-element-related handlers for *all* rendered event segments of the view.
4036         bindSegHandlers: function() {
4037                 this.bindSegHandlersToEl(this.el);
4038         },
4039
4040
4041         // Attaches event-element-related handlers to an arbitrary container element. leverages bubbling.
4042         bindSegHandlersToEl: function(el) {
4043                 this.bindSegHandlerToEl(el, 'touchstart', this.handleSegTouchStart);
4044                 this.bindSegHandlerToEl(el, 'touchend', this.handleSegTouchEnd);
4045                 this.bindSegHandlerToEl(el, 'mouseenter', this.handleSegMouseover);
4046                 this.bindSegHandlerToEl(el, 'mouseleave', this.handleSegMouseout);
4047                 this.bindSegHandlerToEl(el, 'mousedown', this.handleSegMousedown);
4048                 this.bindSegHandlerToEl(el, 'click', this.handleSegClick);
4049         },
4050
4051
4052         // Executes a handler for any a user-interaction on a segment.
4053         // Handler gets called with (seg, ev), and with the `this` context of the Grid
4054         bindSegHandlerToEl: function(el, name, handler) {
4055                 var _this = this;
4056
4057                 el.on(name, this.segSelector, function(ev) {
4058                         var seg = $(this).data('fc-seg'); // grab segment data. put there by View::renderEvents
4059
4060                         // only call the handlers if there is not a drag/resize in progress
4061                         if (seg && !_this.isDraggingSeg && !_this.isResizingSeg) {
4062                                 return handler.call(_this, seg, ev); // context will be the Grid
4063                         }
4064                 });
4065         },
4066
4067
4068         handleSegClick: function(seg, ev) {
4069                 var res = this.view.trigger('eventClick', seg.el[0], seg.event, ev); // can return `false` to cancel
4070                 if (res === false) {
4071                         ev.preventDefault();
4072                 }
4073         },
4074
4075
4076         // Updates internal state and triggers handlers for when an event element is moused over
4077         handleSegMouseover: function(seg, ev) {
4078                 if (
4079                         !this.isIgnoringMouse &&
4080                         !this.mousedOverSeg
4081                 ) {
4082                         this.mousedOverSeg = seg;
4083                         if (this.view.isEventResizable(seg.event)) {
4084                                 seg.el.addClass('fc-allow-mouse-resize');
4085                         }
4086                         this.view.trigger('eventMouseover', seg.el[0], seg.event, ev);
4087                 }
4088         },
4089
4090
4091         // Updates internal state and triggers handlers for when an event element is moused out.
4092         // Can be given no arguments, in which case it will mouseout the segment that was previously moused over.
4093         handleSegMouseout: function(seg, ev) {
4094                 ev = ev || {}; // if given no args, make a mock mouse event
4095
4096                 if (this.mousedOverSeg) {
4097                         seg = seg || this.mousedOverSeg; // if given no args, use the currently moused-over segment
4098                         this.mousedOverSeg = null;
4099                         if (this.view.isEventResizable(seg.event)) {
4100                                 seg.el.removeClass('fc-allow-mouse-resize');
4101                         }
4102                         this.view.trigger('eventMouseout', seg.el[0], seg.event, ev);
4103                 }
4104         },
4105
4106
4107         handleSegMousedown: function(seg, ev) {
4108                 var isResizing = this.startSegResize(seg, ev, { distance: 5 });
4109
4110                 if (!isResizing && this.view.isEventDraggable(seg.event)) {
4111                         this.buildSegDragListener(seg)
4112                                 .startInteraction(ev, {
4113                                         distance: 5
4114                                 });
4115                 }
4116         },
4117
4118
4119         handleSegTouchStart: function(seg, ev) {
4120                 var view = this.view;
4121                 var event = seg.event;
4122                 var isSelected = view.isEventSelected(event);
4123                 var isDraggable = view.isEventDraggable(event);
4124                 var isResizable = view.isEventResizable(event);
4125                 var isResizing = false;
4126                 var dragListener;
4127
4128                 if (isSelected && isResizable) {
4129                         // only allow resizing of the event is selected
4130                         isResizing = this.startSegResize(seg, ev);
4131                 }
4132
4133                 if (!isResizing && (isDraggable || isResizable)) { // allowed to be selected?
4134
4135                         dragListener = isDraggable ?
4136                                 this.buildSegDragListener(seg) :
4137                                 this.buildSegSelectListener(seg); // seg isn't draggable, but still needs to be selected
4138
4139                         dragListener.startInteraction(ev, { // won't start if already started
4140                                 delay: isSelected ? 0 : this.view.opt('longPressDelay') // do delay if not already selected
4141                         });
4142                 }
4143
4144                 // a long tap simulates a mouseover. ignore this bogus mouseover.
4145                 this.tempIgnoreMouse();
4146         },
4147
4148
4149         handleSegTouchEnd: function(seg, ev) {
4150                 // touchstart+touchend = click, which simulates a mouseover.
4151                 // ignore this bogus mouseover.
4152                 this.tempIgnoreMouse();
4153         },
4154
4155
4156         // returns boolean whether resizing actually started or not.
4157         // assumes the seg allows resizing.
4158         // `dragOptions` are optional.
4159         startSegResize: function(seg, ev, dragOptions) {
4160                 if ($(ev.target).is('.fc-resizer')) {
4161                         this.buildSegResizeListener(seg, $(ev.target).is('.fc-start-resizer'))
4162                                 .startInteraction(ev, dragOptions);
4163                         return true;
4164                 }
4165                 return false;
4166         },
4167
4168
4169
4170         /* Event Dragging
4171         ------------------------------------------------------------------------------------------------------------------*/
4172
4173
4174         // Builds a listener that will track user-dragging on an event segment.
4175         // Generic enough to work with any type of Grid.
4176         // Has side effect of setting/unsetting `segDragListener`
4177         buildSegDragListener: function(seg) {
4178                 var _this = this;
4179                 var view = this.view;
4180                 var calendar = view.calendar;
4181                 var el = seg.el;
4182                 var event = seg.event;
4183                 var isDragging;
4184                 var mouseFollower; // A clone of the original element that will move with the mouse
4185                 var dropLocation; // zoned event date properties
4186
4187                 if (this.segDragListener) {
4188                         return this.segDragListener;
4189                 }
4190
4191                 // Tracks mouse movement over the *view's* coordinate map. Allows dragging and dropping between subcomponents
4192                 // of the view.
4193                 var dragListener = this.segDragListener = new HitDragListener(view, {
4194                         scroll: view.opt('dragScroll'),
4195                         subjectEl: el,
4196                         subjectCenter: true,
4197                         interactionStart: function(ev) {
4198                                 seg.component = _this; // for renderDrag
4199                                 isDragging = false;
4200                                 mouseFollower = new MouseFollower(seg.el, {
4201                                         additionalClass: 'fc-dragging',
4202                                         parentEl: view.el,
4203                                         opacity: dragListener.isTouch ? null : view.opt('dragOpacity'),
4204                                         revertDuration: view.opt('dragRevertDuration'),
4205                                         zIndex: 2 // one above the .fc-view
4206                                 });
4207                                 mouseFollower.hide(); // don't show until we know this is a real drag
4208                                 mouseFollower.start(ev);
4209                         },
4210                         dragStart: function(ev) {
4211                                 if (dragListener.isTouch && !view.isEventSelected(event)) {
4212                                         // if not previously selected, will fire after a delay. then, select the event
4213                                         view.selectEvent(event);
4214                                 }
4215                                 isDragging = true;
4216                                 _this.handleSegMouseout(seg, ev); // ensure a mouseout on the manipulated event has been reported
4217                                 _this.segDragStart(seg, ev);
4218                                 view.hideEvent(event); // hide all event segments. our mouseFollower will take over
4219                         },
4220                         hitOver: function(hit, isOrig, origHit) {
4221                                 var dragHelperEls;
4222
4223                                 // starting hit could be forced (DayGrid.limit)
4224                                 if (seg.hit) {
4225                                         origHit = seg.hit;
4226                                 }
4227
4228                                 // since we are querying the parent view, might not belong to this grid
4229                                 dropLocation = _this.computeEventDrop(
4230                                         origHit.component.getHitSpan(origHit),
4231                                         hit.component.getHitSpan(hit),
4232                                         event
4233                                 );
4234
4235                                 if (dropLocation && !calendar.isEventSpanAllowed(_this.eventToSpan(dropLocation), event)) {
4236                                         disableCursor();
4237                                         dropLocation = null;
4238                                 }
4239
4240                                 // if a valid drop location, have the subclass render a visual indication
4241                                 if (dropLocation && (dragHelperEls = view.renderDrag(dropLocation, seg))) {
4242
4243                                         dragHelperEls.addClass('fc-dragging');
4244                                         if (!dragListener.isTouch) {
4245                                                 _this.applyDragOpacity(dragHelperEls);
4246                                         }
4247
4248                                         mouseFollower.hide(); // if the subclass is already using a mock event "helper", hide our own
4249                                 }
4250                                 else {
4251                                         mouseFollower.show(); // otherwise, have the helper follow the mouse (no snapping)
4252                                 }
4253
4254                                 if (isOrig) {
4255                                         dropLocation = null; // needs to have moved hits to be a valid drop
4256                                 }
4257                         },
4258                         hitOut: function() { // called before mouse moves to a different hit OR moved out of all hits
4259                                 view.unrenderDrag(); // unrender whatever was done in renderDrag
4260                                 mouseFollower.show(); // show in case we are moving out of all hits
4261                                 dropLocation = null;
4262                         },
4263                         hitDone: function() { // Called after a hitOut OR before a dragEnd
4264                                 enableCursor();
4265                         },
4266                         interactionEnd: function(ev) {
4267                                 delete seg.component; // prevent side effects
4268
4269                                 // do revert animation if hasn't changed. calls a callback when finished (whether animation or not)
4270                                 mouseFollower.stop(!dropLocation, function() {
4271                                         if (isDragging) {
4272                                                 view.unrenderDrag();
4273                                                 view.showEvent(event);
4274                                                 _this.segDragStop(seg, ev);
4275                                         }
4276                                         if (dropLocation) {
4277                                                 view.reportEventDrop(event, dropLocation, this.largeUnit, el, ev);
4278                                         }
4279                                 });
4280                                 _this.segDragListener = null;
4281                         }
4282                 });
4283
4284                 return dragListener;
4285         },
4286
4287
4288         // seg isn't draggable, but let's use a generic DragListener
4289         // simply for the delay, so it can be selected.
4290         // Has side effect of setting/unsetting `segDragListener`
4291         buildSegSelectListener: function(seg) {
4292                 var _this = this;
4293                 var view = this.view;
4294                 var event = seg.event;
4295
4296                 if (this.segDragListener) {
4297                         return this.segDragListener;
4298                 }
4299
4300                 var dragListener = this.segDragListener = new DragListener({
4301                         dragStart: function(ev) {
4302                                 if (dragListener.isTouch && !view.isEventSelected(event)) {
4303                                         // if not previously selected, will fire after a delay. then, select the event
4304                                         view.selectEvent(event);
4305                                 }
4306                         },
4307                         interactionEnd: function(ev) {
4308                                 _this.segDragListener = null;
4309                         }
4310                 });
4311
4312                 return dragListener;
4313         },
4314
4315
4316         // Called before event segment dragging starts
4317         segDragStart: function(seg, ev) {
4318                 this.isDraggingSeg = true;
4319                 this.view.trigger('eventDragStart', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy
4320         },
4321
4322
4323         // Called after event segment dragging stops
4324         segDragStop: function(seg, ev) {
4325                 this.isDraggingSeg = false;
4326                 this.view.trigger('eventDragStop', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy
4327         },
4328
4329
4330         // Given the spans an event drag began, and the span event was dropped, calculates the new zoned start/end/allDay
4331         // values for the event. Subclasses may override and set additional properties to be used by renderDrag.
4332         // A falsy returned value indicates an invalid drop.
4333         // DOES NOT consider overlap/constraint.
4334         computeEventDrop: function(startSpan, endSpan, event) {
4335                 var calendar = this.view.calendar;
4336                 var dragStart = startSpan.start;
4337                 var dragEnd = endSpan.start;
4338                 var delta;
4339                 var dropLocation; // zoned event date properties
4340
4341                 if (dragStart.hasTime() === dragEnd.hasTime()) {
4342                         delta = this.diffDates(dragEnd, dragStart);
4343
4344                         // if an all-day event was in a timed area and it was dragged to a different time,
4345                         // guarantee an end and adjust start/end to have times
4346                         if (event.allDay && durationHasTime(delta)) {
4347                                 dropLocation = {
4348                                         start: event.start.clone(),
4349                                         end: calendar.getEventEnd(event), // will be an ambig day
4350                                         allDay: false // for normalizeEventTimes
4351                                 };
4352                                 calendar.normalizeEventTimes(dropLocation);
4353                         }
4354                         // othewise, work off existing values
4355                         else {
4356                                 dropLocation = pluckEventDateProps(event);
4357                         }
4358
4359                         dropLocation.start.add(delta);
4360                         if (dropLocation.end) {
4361                                 dropLocation.end.add(delta);
4362                         }
4363                 }
4364                 else {
4365                         // if switching from day <-> timed, start should be reset to the dropped date, and the end cleared
4366                         dropLocation = {
4367                                 start: dragEnd.clone(),
4368                                 end: null, // end should be cleared
4369                                 allDay: !dragEnd.hasTime()
4370                         };
4371                 }
4372
4373                 return dropLocation;
4374         },
4375
4376
4377         // Utility for apply dragOpacity to a jQuery set
4378         applyDragOpacity: function(els) {
4379                 var opacity = this.view.opt('dragOpacity');
4380
4381                 if (opacity != null) {
4382                         els.css('opacity', opacity);
4383                 }
4384         },
4385
4386
4387         /* External Element Dragging
4388         ------------------------------------------------------------------------------------------------------------------*/
4389
4390
4391         // Called when a jQuery UI drag is initiated anywhere in the DOM
4392         externalDragStart: function(ev, ui) {
4393                 var view = this.view;
4394                 var el;
4395                 var accept;
4396
4397                 if (view.opt('droppable')) { // only listen if this setting is on
4398                         el = $((ui ? ui.item : null) || ev.target);
4399
4400                         // Test that the dragged element passes the dropAccept selector or filter function.
4401                         // FYI, the default is "*" (matches all)
4402                         accept = view.opt('dropAccept');
4403                         if ($.isFunction(accept) ? accept.call(el[0], el) : el.is(accept)) {
4404                                 if (!this.isDraggingExternal) { // prevent double-listening if fired twice
4405                                         this.listenToExternalDrag(el, ev, ui);
4406                                 }
4407                         }
4408                 }
4409         },
4410
4411
4412         // Called when a jQuery UI drag starts and it needs to be monitored for dropping
4413         listenToExternalDrag: function(el, ev, ui) {
4414                 var _this = this;
4415                 var calendar = this.view.calendar;
4416                 var meta = getDraggedElMeta(el); // extra data about event drop, including possible event to create
4417                 var dropLocation; // a null value signals an unsuccessful drag
4418
4419                 // listener that tracks mouse movement over date-associated pixel regions
4420                 var dragListener = _this.externalDragListener = new HitDragListener(this, {
4421                         interactionStart: function() {
4422                                 _this.isDraggingExternal = true;
4423                         },
4424                         hitOver: function(hit) {
4425                                 dropLocation = _this.computeExternalDrop(
4426                                         hit.component.getHitSpan(hit), // since we are querying the parent view, might not belong to this grid
4427                                         meta
4428                                 );
4429
4430                                 if ( // invalid hit?
4431                                         dropLocation &&
4432                                         !calendar.isExternalSpanAllowed(_this.eventToSpan(dropLocation), dropLocation, meta.eventProps)
4433                                 ) {
4434                                         disableCursor();
4435                                         dropLocation = null;
4436                                 }
4437
4438                                 if (dropLocation) {
4439                                         _this.renderDrag(dropLocation); // called without a seg parameter
4440                                 }
4441                         },
4442                         hitOut: function() {
4443                                 dropLocation = null; // signal unsuccessful
4444                         },
4445                         hitDone: function() { // Called after a hitOut OR before a dragEnd
4446                                 enableCursor();
4447                                 _this.unrenderDrag();
4448                         },
4449                         interactionEnd: function(ev) {
4450                                 if (dropLocation) { // element was dropped on a valid hit
4451                                         _this.view.reportExternalDrop(meta, dropLocation, el, ev, ui);
4452                                 }
4453                                 _this.isDraggingExternal = false;
4454                                 _this.externalDragListener = null;
4455                         }
4456                 });
4457
4458                 dragListener.startDrag(ev); // start listening immediately
4459         },
4460
4461
4462         // Given a hit to be dropped upon, and misc data associated with the jqui drag (guaranteed to be a plain object),
4463         // returns the zoned start/end dates for the event that would result from the hypothetical drop. end might be null.
4464         // Returning a null value signals an invalid drop hit.
4465         // DOES NOT consider overlap/constraint.
4466         computeExternalDrop: function(span, meta) {
4467                 var calendar = this.view.calendar;
4468                 var dropLocation = {
4469                         start: calendar.applyTimezone(span.start), // simulate a zoned event start date
4470                         end: null
4471                 };
4472
4473                 // if dropped on an all-day span, and element's metadata specified a time, set it
4474                 if (meta.startTime && !dropLocation.start.hasTime()) {
4475                         dropLocation.start.time(meta.startTime);
4476                 }
4477
4478                 if (meta.duration) {
4479                         dropLocation.end = dropLocation.start.clone().add(meta.duration);
4480                 }
4481
4482                 return dropLocation;
4483         },
4484
4485
4486
4487         /* Drag Rendering (for both events and an external elements)
4488         ------------------------------------------------------------------------------------------------------------------*/
4489
4490
4491         // Renders a visual indication of an event or external element being dragged.
4492         // `dropLocation` contains hypothetical start/end/allDay values the event would have if dropped. end can be null.
4493         // `seg` is the internal segment object that is being dragged. If dragging an external element, `seg` is null.
4494         // A truthy returned value indicates this method has rendered a helper element.
4495         // Must return elements used for any mock events.
4496         renderDrag: function(dropLocation, seg) {
4497                 // subclasses must implement
4498         },
4499
4500
4501         // Unrenders a visual indication of an event or external element being dragged
4502         unrenderDrag: function() {
4503                 // subclasses must implement
4504         },
4505
4506
4507         /* Resizing
4508         ------------------------------------------------------------------------------------------------------------------*/
4509
4510
4511         // Creates a listener that tracks the user as they resize an event segment.
4512         // Generic enough to work with any type of Grid.
4513         buildSegResizeListener: function(seg, isStart) {
4514                 var _this = this;
4515                 var view = this.view;
4516                 var calendar = view.calendar;
4517                 var el = seg.el;
4518                 var event = seg.event;
4519                 var eventEnd = calendar.getEventEnd(event);
4520                 var isDragging;
4521                 var resizeLocation; // zoned event date properties. falsy if invalid resize
4522
4523                 // Tracks mouse movement over the *grid's* coordinate map
4524                 var dragListener = this.segResizeListener = new HitDragListener(this, {
4525                         scroll: view.opt('dragScroll'),
4526                         subjectEl: el,
4527                         interactionStart: function() {
4528                                 isDragging = false;
4529                         },
4530                         dragStart: function(ev) {
4531                                 isDragging = true;
4532                                 _this.handleSegMouseout(seg, ev); // ensure a mouseout on the manipulated event has been reported
4533                                 _this.segResizeStart(seg, ev);
4534                         },
4535                         hitOver: function(hit, isOrig, origHit) {
4536                                 var origHitSpan = _this.getHitSpan(origHit);
4537                                 var hitSpan = _this.getHitSpan(hit);
4538
4539                                 resizeLocation = isStart ?
4540                                         _this.computeEventStartResize(origHitSpan, hitSpan, event) :
4541                                         _this.computeEventEndResize(origHitSpan, hitSpan, event);
4542
4543                                 if (resizeLocation) {
4544                                         if (!calendar.isEventSpanAllowed(_this.eventToSpan(resizeLocation), event)) {
4545                                                 disableCursor();
4546                                                 resizeLocation = null;
4547                                         }
4548                                         // no change? (FYI, event dates might have zones)
4549                                         else if (
4550                                                 resizeLocation.start.isSame(event.start.clone().stripZone()) &&
4551                                                 resizeLocation.end.isSame(eventEnd.clone().stripZone())
4552                                         ) {
4553                                                 resizeLocation = null;
4554                                         }
4555                                 }
4556
4557                                 if (resizeLocation) {
4558                                         view.hideEvent(event);
4559                                         _this.renderEventResize(resizeLocation, seg);
4560                                 }
4561                         },
4562                         hitOut: function() { // called before mouse moves to a different hit OR moved out of all hits
4563                                 resizeLocation = null;
4564                         },
4565                         hitDone: function() { // resets the rendering to show the original event
4566                                 _this.unrenderEventResize();
4567                                 view.showEvent(event);
4568                                 enableCursor();
4569                         },
4570                         interactionEnd: function(ev) {
4571                                 if (isDragging) {
4572                                         _this.segResizeStop(seg, ev);
4573                                 }
4574                                 if (resizeLocation) { // valid date to resize to?
4575                                         view.reportEventResize(event, resizeLocation, this.largeUnit, el, ev);
4576                                 }
4577                                 _this.segResizeListener = null;
4578                         }
4579                 });
4580
4581                 return dragListener;
4582         },
4583
4584
4585         // Called before event segment resizing starts
4586         segResizeStart: function(seg, ev) {
4587                 this.isResizingSeg = true;
4588                 this.view.trigger('eventResizeStart', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy
4589         },
4590
4591
4592         // Called after event segment resizing stops
4593         segResizeStop: function(seg, ev) {
4594                 this.isResizingSeg = false;
4595                 this.view.trigger('eventResizeStop', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy
4596         },
4597
4598
4599         // Returns new date-information for an event segment being resized from its start
4600         computeEventStartResize: function(startSpan, endSpan, event) {
4601                 return this.computeEventResize('start', startSpan, endSpan, event);
4602         },
4603
4604
4605         // Returns new date-information for an event segment being resized from its end
4606         computeEventEndResize: function(startSpan, endSpan, event) {
4607                 return this.computeEventResize('end', startSpan, endSpan, event);
4608         },
4609
4610
4611         // Returns new zoned date information for an event segment being resized from its start OR end
4612         // `type` is either 'start' or 'end'.
4613         // DOES NOT consider overlap/constraint.
4614         computeEventResize: function(type, startSpan, endSpan, event) {
4615                 var calendar = this.view.calendar;
4616                 var delta = this.diffDates(endSpan[type], startSpan[type]);
4617                 var resizeLocation; // zoned event date properties
4618                 var defaultDuration;
4619
4620                 // build original values to work from, guaranteeing a start and end
4621                 resizeLocation = {
4622                         start: event.start.clone(),
4623                         end: calendar.getEventEnd(event),
4624                         allDay: event.allDay
4625                 };
4626
4627                 // if an all-day event was in a timed area and was resized to a time, adjust start/end to have times
4628                 if (resizeLocation.allDay && durationHasTime(delta)) {
4629                         resizeLocation.allDay = false;
4630                         calendar.normalizeEventTimes(resizeLocation);
4631                 }
4632
4633                 resizeLocation[type].add(delta); // apply delta to start or end
4634
4635                 // if the event was compressed too small, find a new reasonable duration for it
4636                 if (!resizeLocation.start.isBefore(resizeLocation.end)) {
4637
4638                         defaultDuration =
4639                                 this.minResizeDuration || // TODO: hack
4640                                 (event.allDay ?
4641                                         calendar.defaultAllDayEventDuration :
4642                                         calendar.defaultTimedEventDuration);
4643
4644                         if (type == 'start') { // resizing the start?
4645                                 resizeLocation.start = resizeLocation.end.clone().subtract(defaultDuration);
4646                         }
4647                         else { // resizing the end?
4648                                 resizeLocation.end = resizeLocation.start.clone().add(defaultDuration);
4649                         }
4650                 }
4651
4652                 return resizeLocation;
4653         },
4654
4655
4656         // Renders a visual indication of an event being resized.
4657         // `range` has the updated dates of the event. `seg` is the original segment object involved in the drag.
4658         // Must return elements used for any mock events.
4659         renderEventResize: function(range, seg) {
4660                 // subclasses must implement
4661         },
4662
4663
4664         // Unrenders a visual indication of an event being resized.
4665         unrenderEventResize: function() {
4666                 // subclasses must implement
4667         },
4668
4669
4670         /* Rendering Utils
4671         ------------------------------------------------------------------------------------------------------------------*/
4672
4673
4674         // Compute the text that should be displayed on an event's element.
4675         // `range` can be the Event object itself, or something range-like, with at least a `start`.
4676         // If event times are disabled, or the event has no time, will return a blank string.
4677         // If not specified, formatStr will default to the eventTimeFormat setting,
4678         // and displayEnd will default to the displayEventEnd setting.
4679         getEventTimeText: function(range, formatStr, displayEnd) {
4680
4681                 if (formatStr == null) {
4682                         formatStr = this.eventTimeFormat;
4683                 }
4684
4685                 if (displayEnd == null) {
4686                         displayEnd = this.displayEventEnd;
4687                 }
4688
4689                 if (this.displayEventTime && range.start.hasTime()) {
4690                         if (displayEnd && range.end) {
4691                                 return this.view.formatRange(range, formatStr);
4692                         }
4693                         else {
4694                                 return range.start.format(formatStr);
4695                         }
4696                 }
4697
4698                 return '';
4699         },
4700
4701
4702         // Generic utility for generating the HTML classNames for an event segment's element
4703         getSegClasses: function(seg, isDraggable, isResizable) {
4704                 var view = this.view;
4705                 var classes = [
4706                         'fc-event',
4707                         seg.isStart ? 'fc-start' : 'fc-not-start',
4708                         seg.isEnd ? 'fc-end' : 'fc-not-end'
4709                 ].concat(this.getSegCustomClasses(seg));
4710
4711                 if (isDraggable) {
4712                         classes.push('fc-draggable');
4713                 }
4714                 if (isResizable) {
4715                         classes.push('fc-resizable');
4716                 }
4717
4718                 // event is currently selected? attach a className.
4719                 if (view.isEventSelected(seg.event)) {
4720                         classes.push('fc-selected');
4721                 }
4722
4723                 return classes;
4724         },
4725
4726
4727         // List of classes that were defined by the caller of the API in some way
4728         getSegCustomClasses: function(seg) {
4729                 var event = seg.event;
4730
4731                 return [].concat(
4732                         event.className, // guaranteed to be an array
4733                         event.source ? event.source.className : []
4734                 );
4735         },
4736
4737
4738         // Utility for generating event skin-related CSS properties
4739         getSegSkinCss: function(seg) {
4740                 return {
4741                         'background-color': this.getSegBackgroundColor(seg),
4742                         'border-color': this.getSegBorderColor(seg),
4743                         color: this.getSegTextColor(seg)
4744                 };
4745         },
4746
4747
4748         // Queries for caller-specified color, then falls back to default
4749         getSegBackgroundColor: function(seg) {
4750                 return seg.event.backgroundColor ||
4751                         seg.event.color ||
4752                         this.getSegDefaultBackgroundColor(seg);
4753         },
4754
4755
4756         getSegDefaultBackgroundColor: function(seg) {
4757                 var source = seg.event.source || {};
4758
4759                 return source.backgroundColor ||
4760                         source.color ||
4761                         this.view.opt('eventBackgroundColor') ||
4762                         this.view.opt('eventColor');
4763         },
4764
4765
4766         // Queries for caller-specified color, then falls back to default
4767         getSegBorderColor: function(seg) {
4768                 return seg.event.borderColor ||
4769                         seg.event.color ||
4770                         this.getSegDefaultBorderColor(seg);
4771         },
4772
4773
4774         getSegDefaultBorderColor: function(seg) {
4775                 var source = seg.event.source || {};
4776
4777                 return source.borderColor ||
4778                         source.color ||
4779                         this.view.opt('eventBorderColor') ||
4780                         this.view.opt('eventColor');
4781         },
4782
4783
4784         // Queries for caller-specified color, then falls back to default
4785         getSegTextColor: function(seg) {
4786                 return seg.event.textColor ||
4787                         this.getSegDefaultTextColor(seg);
4788         },
4789
4790
4791         getSegDefaultTextColor: function(seg) {
4792                 var source = seg.event.source || {};
4793
4794                 return source.textColor ||
4795                         this.view.opt('eventTextColor');
4796         },
4797
4798
4799         /* Converting events -> eventRange -> eventSpan -> eventSegs
4800         ------------------------------------------------------------------------------------------------------------------*/
4801
4802
4803         // Generates an array of segments for the given single event
4804         // Can accept an event "location" as well (which only has start/end and no allDay)
4805         eventToSegs: function(event) {
4806                 return this.eventsToSegs([ event ]);
4807         },
4808
4809
4810         eventToSpan: function(event) {
4811                 return this.eventToSpans(event)[0];
4812         },
4813
4814
4815         // Generates spans (always unzoned) for the given event.
4816         // Does not do any inverting for inverse-background events.
4817         // Can accept an event "location" as well (which only has start/end and no allDay)
4818         eventToSpans: function(event) {
4819                 var range = this.eventToRange(event);
4820                 return this.eventRangeToSpans(range, event);
4821         },
4822
4823
4824
4825         // Converts an array of event objects into an array of event segment objects.
4826         // A custom `segSliceFunc` may be given for arbitrarily slicing up events.
4827         // Doesn't guarantee an order for the resulting array.
4828         eventsToSegs: function(allEvents, segSliceFunc) {
4829                 var _this = this;
4830                 var eventsById = groupEventsById(allEvents);
4831                 var segs = [];
4832
4833                 $.each(eventsById, function(id, events) {
4834                         var ranges = [];
4835                         var i;
4836
4837                         for (i = 0; i < events.length; i++) {
4838                                 ranges.push(_this.eventToRange(events[i]));
4839                         }
4840
4841                         // inverse-background events (utilize only the first event in calculations)
4842                         if (isInverseBgEvent(events[0])) {
4843                                 ranges = _this.invertRanges(ranges);
4844
4845                                 for (i = 0; i < ranges.length; i++) {
4846                                         segs.push.apply(segs, // append to
4847                                                 _this.eventRangeToSegs(ranges[i], events[0], segSliceFunc));
4848                                 }
4849                         }
4850                         // normal event ranges
4851                         else {
4852                                 for (i = 0; i < ranges.length; i++) {
4853                                         segs.push.apply(segs, // append to
4854                                                 _this.eventRangeToSegs(ranges[i], events[i], segSliceFunc));
4855                                 }
4856                         }
4857                 });
4858
4859                 return segs;
4860         },
4861
4862
4863         // Generates the unzoned start/end dates an event appears to occupy
4864         // Can accept an event "location" as well (which only has start/end and no allDay)
4865         eventToRange: function(event) {
4866                 var calendar = this.view.calendar;
4867                 var start = event.start.clone().stripZone();
4868                 var end = (
4869                                 event.end ?
4870                                         event.end.clone() :
4871                                         // derive the end from the start and allDay. compute allDay if necessary
4872                                         calendar.getDefaultEventEnd(
4873                                                 event.allDay != null ?
4874                                                         event.allDay :
4875                                                         !event.start.hasTime(),
4876                                                 event.start
4877                                         )
4878                         ).stripZone();
4879
4880                 // hack: dynamic locale change forgets to upate stored event localed
4881                 calendar.localizeMoment(start);
4882                 calendar.localizeMoment(end);
4883
4884                 return { start: start, end: end };
4885         },
4886
4887
4888         // Given an event's range (unzoned start/end), and the event itself,
4889         // slice into segments (using the segSliceFunc function if specified)
4890         eventRangeToSegs: function(range, event, segSliceFunc) {
4891                 var spans = this.eventRangeToSpans(range, event);
4892                 var segs = [];
4893                 var i;
4894
4895                 for (i = 0; i < spans.length; i++) {
4896                         segs.push.apply(segs, // append to
4897                                 this.eventSpanToSegs(spans[i], event, segSliceFunc));
4898                 }
4899
4900                 return segs;
4901         },
4902
4903
4904         // Given an event's unzoned date range, return an array of "span" objects.
4905         // Subclasses can override.
4906         eventRangeToSpans: function(range, event) {
4907                 return [ $.extend({}, range) ]; // copy into a single-item array
4908         },
4909
4910
4911         // Given an event's span (unzoned start/end and other misc data), and the event itself,
4912         // slices into segments and attaches event-derived properties to them.
4913         eventSpanToSegs: function(span, event, segSliceFunc) {
4914                 var segs = segSliceFunc ? segSliceFunc(span) : this.spanToSegs(span);
4915                 var i, seg;
4916
4917                 for (i = 0; i < segs.length; i++) {
4918                         seg = segs[i];
4919                         seg.event = event;
4920                         seg.eventStartMS = +span.start; // TODO: not the best name after making spans unzoned
4921                         seg.eventDurationMS = span.end - span.start;
4922                 }
4923
4924                 return segs;
4925         },
4926
4927
4928         // Produces a new array of range objects that will cover all the time NOT covered by the given ranges.
4929         // SIDE EFFECT: will mutate the given array and will use its date references.
4930         invertRanges: function(ranges) {
4931                 var view = this.view;
4932                 var viewStart = view.start.clone(); // need a copy
4933                 var viewEnd = view.end.clone(); // need a copy
4934                 var inverseRanges = [];
4935                 var start = viewStart; // the end of the previous range. the start of the new range
4936                 var i, range;
4937
4938                 // ranges need to be in order. required for our date-walking algorithm
4939                 ranges.sort(compareRanges);
4940
4941                 for (i = 0; i < ranges.length; i++) {
4942                         range = ranges[i];
4943
4944                         // add the span of time before the event (if there is any)
4945                         if (range.start > start) { // compare millisecond time (skip any ambig logic)
4946                                 inverseRanges.push({
4947                                         start: start,
4948                                         end: range.start
4949                                 });
4950                         }
4951
4952                         start = range.end;
4953                 }
4954
4955                 // add the span of time after the last event (if there is any)
4956                 if (start < viewEnd) { // compare millisecond time (skip any ambig logic)
4957                         inverseRanges.push({
4958                                 start: start,
4959                                 end: viewEnd
4960                         });
4961                 }
4962
4963                 return inverseRanges;
4964         },
4965
4966
4967         sortEventSegs: function(segs) {
4968                 segs.sort(proxy(this, 'compareEventSegs'));
4969         },
4970
4971
4972         // A cmp function for determining which segments should take visual priority
4973         compareEventSegs: function(seg1, seg2) {
4974                 return seg1.eventStartMS - seg2.eventStartMS || // earlier events go first
4975                         seg2.eventDurationMS - seg1.eventDurationMS || // tie? longer events go first
4976                         seg2.event.allDay - seg1.event.allDay || // tie? put all-day events first (booleans cast to 0/1)
4977                         compareByFieldSpecs(seg1.event, seg2.event, this.view.eventOrderSpecs);
4978         }
4979
4980 });
4981
4982
4983 /* Utilities
4984 ----------------------------------------------------------------------------------------------------------------------*/
4985
4986
4987 function pluckEventDateProps(event) {
4988         return {
4989                 start: event.start.clone(),
4990                 end: event.end ? event.end.clone() : null,
4991                 allDay: event.allDay // keep it the same
4992         };
4993 }
4994 FC.pluckEventDateProps = pluckEventDateProps;
4995
4996
4997 function isBgEvent(event) { // returns true if background OR inverse-background
4998         var rendering = getEventRendering(event);
4999         return rendering === 'background' || rendering === 'inverse-background';
5000 }
5001 FC.isBgEvent = isBgEvent; // export
5002
5003
5004 function isInverseBgEvent(event) {
5005         return getEventRendering(event) === 'inverse-background';
5006 }
5007
5008
5009 function getEventRendering(event) {
5010         return firstDefined((event.source || {}).rendering, event.rendering);
5011 }
5012
5013
5014 function groupEventsById(events) {
5015         var eventsById = {};
5016         var i, event;
5017
5018         for (i = 0; i < events.length; i++) {
5019                 event = events[i];
5020                 (eventsById[event._id] || (eventsById[event._id] = [])).push(event);
5021         }
5022
5023         return eventsById;
5024 }
5025
5026
5027 // A cmp function for determining which non-inverted "ranges" (see above) happen earlier
5028 function compareRanges(range1, range2) {
5029         return range1.start - range2.start; // earlier ranges go first
5030 }
5031
5032
5033 /* External-Dragging-Element Data
5034 ----------------------------------------------------------------------------------------------------------------------*/
5035
5036 // Require all HTML5 data-* attributes used by FullCalendar to have this prefix.
5037 // A value of '' will query attributes like data-event. A value of 'fc' will query attributes like data-fc-event.
5038 FC.dataAttrPrefix = '';
5039
5040 // Given a jQuery element that might represent a dragged FullCalendar event, returns an intermediate data structure
5041 // to be used for Event Object creation.
5042 // A defined `.eventProps`, even when empty, indicates that an event should be created.
5043 function getDraggedElMeta(el) {
5044         var prefix = FC.dataAttrPrefix;
5045         var eventProps; // properties for creating the event, not related to date/time
5046         var startTime; // a Duration
5047         var duration;
5048         var stick;
5049
5050         if (prefix) { prefix += '-'; }
5051         eventProps = el.data(prefix + 'event') || null;
5052
5053         if (eventProps) {
5054                 if (typeof eventProps === 'object') {
5055                         eventProps = $.extend({}, eventProps); // make a copy
5056                 }
5057                 else { // something like 1 or true. still signal event creation
5058                         eventProps = {};
5059                 }
5060
5061                 // pluck special-cased date/time properties
5062                 startTime = eventProps.start;
5063                 if (startTime == null) { startTime = eventProps.time; } // accept 'time' as well
5064                 duration = eventProps.duration;
5065                 stick = eventProps.stick;
5066                 delete eventProps.start;
5067                 delete eventProps.time;
5068                 delete eventProps.duration;
5069                 delete eventProps.stick;
5070         }
5071
5072         // fallback to standalone attribute values for each of the date/time properties
5073         if (startTime == null) { startTime = el.data(prefix + 'start'); }
5074         if (startTime == null) { startTime = el.data(prefix + 'time'); } // accept 'time' as well
5075         if (duration == null) { duration = el.data(prefix + 'duration'); }
5076         if (stick == null) { stick = el.data(prefix + 'stick'); }
5077
5078         // massage into correct data types
5079         startTime = startTime != null ? moment.duration(startTime) : null;
5080         duration = duration != null ? moment.duration(duration) : null;
5081         stick = Boolean(stick);
5082
5083         return { eventProps: eventProps, startTime: startTime, duration: duration, stick: stick };
5084 }
5085
5086
5087 ;;
5088
5089 /*
5090 A set of rendering and date-related methods for a visual component comprised of one or more rows of day columns.
5091 Prerequisite: the object being mixed into needs to be a *Grid*
5092 */
5093 var DayTableMixin = FC.DayTableMixin = {
5094
5095         breakOnWeeks: false, // should create a new row for each week?
5096         dayDates: null, // whole-day dates for each column. left to right
5097         dayIndices: null, // for each day from start, the offset
5098         daysPerRow: null,
5099         rowCnt: null,
5100         colCnt: null,
5101         colHeadFormat: null,
5102
5103
5104         // Populates internal variables used for date calculation and rendering
5105         updateDayTable: function() {
5106                 var view = this.view;
5107                 var date = this.start.clone();
5108                 var dayIndex = -1;
5109                 var dayIndices = [];
5110                 var dayDates = [];
5111                 var daysPerRow;
5112                 var firstDay;
5113                 var rowCnt;
5114
5115                 while (date.isBefore(this.end)) { // loop each day from start to end
5116                         if (view.isHiddenDay(date)) {
5117                                 dayIndices.push(dayIndex + 0.5); // mark that it's between indices
5118                         }
5119                         else {
5120                                 dayIndex++;
5121                                 dayIndices.push(dayIndex);
5122                                 dayDates.push(date.clone());
5123                         }
5124                         date.add(1, 'days');
5125                 }
5126
5127                 if (this.breakOnWeeks) {
5128                         // count columns until the day-of-week repeats
5129                         firstDay = dayDates[0].day();
5130                         for (daysPerRow = 1; daysPerRow < dayDates.length; daysPerRow++) {
5131                                 if (dayDates[daysPerRow].day() == firstDay) {
5132                                         break;
5133                                 }
5134                         }
5135                         rowCnt = Math.ceil(dayDates.length / daysPerRow);
5136                 }
5137                 else {
5138                         rowCnt = 1;
5139                         daysPerRow = dayDates.length;
5140                 }
5141
5142                 this.dayDates = dayDates;
5143                 this.dayIndices = dayIndices;
5144                 this.daysPerRow = daysPerRow;
5145                 this.rowCnt = rowCnt;
5146                 
5147                 this.updateDayTableCols();
5148         },
5149
5150
5151         // Computes and assigned the colCnt property and updates any options that may be computed from it
5152         updateDayTableCols: function() {
5153                 this.colCnt = this.computeColCnt();
5154                 this.colHeadFormat = this.view.opt('columnFormat') || this.computeColHeadFormat();
5155         },
5156
5157
5158         // Determines how many columns there should be in the table
5159         computeColCnt: function() {
5160                 return this.daysPerRow;
5161         },
5162
5163
5164         // Computes the ambiguously-timed moment for the given cell
5165         getCellDate: function(row, col) {
5166                 return this.dayDates[
5167                                 this.getCellDayIndex(row, col)
5168                         ].clone();
5169         },
5170
5171
5172         // Computes the ambiguously-timed date range for the given cell
5173         getCellRange: function(row, col) {
5174                 var start = this.getCellDate(row, col);
5175                 var end = start.clone().add(1, 'days');
5176
5177                 return { start: start, end: end };
5178         },
5179
5180
5181         // Returns the number of day cells, chronologically, from the first of the grid (0-based)
5182         getCellDayIndex: function(row, col) {
5183                 return row * this.daysPerRow + this.getColDayIndex(col);
5184         },
5185
5186
5187         // Returns the numner of day cells, chronologically, from the first cell in *any given row*
5188         getColDayIndex: function(col) {
5189                 if (this.isRTL) {
5190                         return this.colCnt - 1 - col;
5191                 }
5192                 else {
5193                         return col;
5194                 }
5195         },
5196
5197
5198         // Given a date, returns its chronolocial cell-index from the first cell of the grid.
5199         // If the date lies between cells (because of hiddenDays), returns a floating-point value between offsets.
5200         // If before the first offset, returns a negative number.
5201         // If after the last offset, returns an offset past the last cell offset.
5202         // Only works for *start* dates of cells. Will not work for exclusive end dates for cells.
5203         getDateDayIndex: function(date) {
5204                 var dayIndices = this.dayIndices;
5205                 var dayOffset = date.diff(this.start, 'days');
5206
5207                 if (dayOffset < 0) {
5208                         return dayIndices[0] - 1;
5209                 }
5210                 else if (dayOffset >= dayIndices.length) {
5211                         return dayIndices[dayIndices.length - 1] + 1;
5212                 }
5213                 else {
5214                         return dayIndices[dayOffset];
5215                 }
5216         },
5217
5218
5219         /* Options
5220         ------------------------------------------------------------------------------------------------------------------*/
5221
5222
5223         // Computes a default column header formatting string if `colFormat` is not explicitly defined
5224         computeColHeadFormat: function() {
5225                 // if more than one week row, or if there are a lot of columns with not much space,
5226                 // put just the day numbers will be in each cell
5227                 if (this.rowCnt > 1 || this.colCnt > 10) {
5228                         return 'ddd'; // "Sat"
5229                 }
5230                 // multiple days, so full single date string WON'T be in title text
5231                 else if (this.colCnt > 1) {
5232                         return this.view.opt('dayOfMonthFormat'); // "Sat 12/10"
5233                 }
5234                 // single day, so full single date string will probably be in title text
5235                 else {
5236                         return 'dddd'; // "Saturday"
5237                 }
5238         },
5239
5240
5241         /* Slicing
5242         ------------------------------------------------------------------------------------------------------------------*/
5243
5244
5245         // Slices up a date range into a segment for every week-row it intersects with
5246         sliceRangeByRow: function(range) {
5247                 var daysPerRow = this.daysPerRow;
5248                 var normalRange = this.view.computeDayRange(range); // make whole-day range, considering nextDayThreshold
5249                 var rangeFirst = this.getDateDayIndex(normalRange.start); // inclusive first index
5250                 var rangeLast = this.getDateDayIndex(normalRange.end.clone().subtract(1, 'days')); // inclusive last index
5251                 var segs = [];
5252                 var row;
5253                 var rowFirst, rowLast; // inclusive day-index range for current row
5254                 var segFirst, segLast; // inclusive day-index range for segment
5255
5256                 for (row = 0; row < this.rowCnt; row++) {
5257                         rowFirst = row * daysPerRow;
5258                         rowLast = rowFirst + daysPerRow - 1;
5259
5260                         // intersect segment's offset range with the row's
5261                         segFirst = Math.max(rangeFirst, rowFirst);
5262                         segLast = Math.min(rangeLast, rowLast);
5263
5264                         // deal with in-between indices
5265                         segFirst = Math.ceil(segFirst); // in-between starts round to next cell
5266                         segLast = Math.floor(segLast); // in-between ends round to prev cell
5267
5268                         if (segFirst <= segLast) { // was there any intersection with the current row?
5269                                 segs.push({
5270                                         row: row,
5271
5272                                         // normalize to start of row
5273                                         firstRowDayIndex: segFirst - rowFirst,
5274                                         lastRowDayIndex: segLast - rowFirst,
5275
5276                                         // must be matching integers to be the segment's start/end
5277                                         isStart: segFirst === rangeFirst,
5278                                         isEnd: segLast === rangeLast
5279                                 });
5280                         }
5281                 }
5282
5283                 return segs;
5284         },
5285
5286
5287         // Slices up a date range into a segment for every day-cell it intersects with.
5288         // TODO: make more DRY with sliceRangeByRow somehow.
5289         sliceRangeByDay: function(range) {
5290                 var daysPerRow = this.daysPerRow;
5291                 var normalRange = this.view.computeDayRange(range); // make whole-day range, considering nextDayThreshold
5292                 var rangeFirst = this.getDateDayIndex(normalRange.start); // inclusive first index
5293                 var rangeLast = this.getDateDayIndex(normalRange.end.clone().subtract(1, 'days')); // inclusive last index
5294                 var segs = [];
5295                 var row;
5296                 var rowFirst, rowLast; // inclusive day-index range for current row
5297                 var i;
5298                 var segFirst, segLast; // inclusive day-index range for segment
5299
5300                 for (row = 0; row < this.rowCnt; row++) {
5301                         rowFirst = row * daysPerRow;
5302                         rowLast = rowFirst + daysPerRow - 1;
5303
5304                         for (i = rowFirst; i <= rowLast; i++) {
5305
5306                                 // intersect segment's offset range with the row's
5307                                 segFirst = Math.max(rangeFirst, i);
5308                                 segLast = Math.min(rangeLast, i);
5309
5310                                 // deal with in-between indices
5311                                 segFirst = Math.ceil(segFirst); // in-between starts round to next cell
5312                                 segLast = Math.floor(segLast); // in-between ends round to prev cell
5313
5314                                 if (segFirst <= segLast) { // was there any intersection with the current row?
5315                                         segs.push({
5316                                                 row: row,
5317
5318                                                 // normalize to start of row
5319                                                 firstRowDayIndex: segFirst - rowFirst,
5320                                                 lastRowDayIndex: segLast - rowFirst,
5321
5322                                                 // must be matching integers to be the segment's start/end
5323                                                 isStart: segFirst === rangeFirst,
5324                                                 isEnd: segLast === rangeLast
5325                                         });
5326                                 }
5327                         }
5328                 }
5329
5330                 return segs;
5331         },
5332
5333
5334         /* Header Rendering
5335         ------------------------------------------------------------------------------------------------------------------*/
5336
5337
5338         renderHeadHtml: function() {
5339                 var view = this.view;
5340
5341                 return '' +
5342                         '<div class="fc-row ' + view.widgetHeaderClass + '">' +
5343                                 '<table>' +
5344                                         '<thead>' +
5345                                                 this.renderHeadTrHtml() +
5346                                         '</thead>' +
5347                                 '</table>' +
5348                         '</div>';
5349         },
5350
5351
5352         renderHeadIntroHtml: function() {
5353                 return this.renderIntroHtml(); // fall back to generic
5354         },
5355
5356
5357         renderHeadTrHtml: function() {
5358                 return '' +
5359                         '<tr>' +
5360                                 (this.isRTL ? '' : this.renderHeadIntroHtml()) +
5361                                 this.renderHeadDateCellsHtml() +
5362                                 (this.isRTL ? this.renderHeadIntroHtml() : '') +
5363                         '</tr>';
5364         },
5365
5366
5367         renderHeadDateCellsHtml: function() {
5368                 var htmls = [];
5369                 var col, date;
5370
5371                 for (col = 0; col < this.colCnt; col++) {
5372                         date = this.getCellDate(0, col);
5373                         htmls.push(this.renderHeadDateCellHtml(date));
5374                 }
5375
5376                 return htmls.join('');
5377         },
5378
5379
5380         // TODO: when internalApiVersion, accept an object for HTML attributes
5381         // (colspan should be no different)
5382         renderHeadDateCellHtml: function(date, colspan, otherAttrs) {
5383                 var view = this.view;
5384
5385                 return '' +
5386                         '<th class="fc-day-header ' + view.widgetHeaderClass + ' fc-' + dayIDs[date.day()] + '"' +
5387                                 (this.rowCnt === 1 ?
5388                                         ' data-date="' + date.format('YYYY-MM-DD') + '"' :
5389                                         '') +
5390                                 (colspan > 1 ?
5391                                         ' colspan="' + colspan + '"' :
5392                                         '') +
5393                                 (otherAttrs ?
5394                                         ' ' + otherAttrs :
5395                                         '') +
5396                                 '>' +
5397                                 // don't make a link if the heading could represent multiple days, or if there's only one day (forceOff)
5398                                 view.buildGotoAnchorHtml(
5399                                         { date: date, forceOff: this.rowCnt > 1 || this.colCnt === 1 },
5400                                         htmlEscape(date.format(this.colHeadFormat)) // inner HTML
5401                                 ) +
5402                         '</th>';
5403         },
5404
5405
5406         /* Background Rendering
5407         ------------------------------------------------------------------------------------------------------------------*/
5408
5409
5410         renderBgTrHtml: function(row) {
5411                 return '' +
5412                         '<tr>' +
5413                                 (this.isRTL ? '' : this.renderBgIntroHtml(row)) +
5414                                 this.renderBgCellsHtml(row) +
5415                                 (this.isRTL ? this.renderBgIntroHtml(row) : '') +
5416                         '</tr>';
5417         },
5418
5419
5420         renderBgIntroHtml: function(row) {
5421                 return this.renderIntroHtml(); // fall back to generic
5422         },
5423
5424
5425         renderBgCellsHtml: function(row) {
5426                 var htmls = [];
5427                 var col, date;
5428
5429                 for (col = 0; col < this.colCnt; col++) {
5430                         date = this.getCellDate(row, col);
5431                         htmls.push(this.renderBgCellHtml(date));
5432                 }
5433
5434                 return htmls.join('');
5435         },
5436
5437
5438         renderBgCellHtml: function(date, otherAttrs) {
5439                 var view = this.view;
5440                 var classes = this.getDayClasses(date);
5441
5442                 classes.unshift('fc-day', view.widgetContentClass);
5443
5444                 return '<td class="' + classes.join(' ') + '"' +
5445                         ' data-date="' + date.format('YYYY-MM-DD') + '"' + // if date has a time, won't format it
5446                         (otherAttrs ?
5447                                 ' ' + otherAttrs :
5448                                 '') +
5449                         '></td>';
5450         },
5451
5452
5453         /* Generic
5454         ------------------------------------------------------------------------------------------------------------------*/
5455
5456
5457         // Generates the default HTML intro for any row. User classes should override
5458         renderIntroHtml: function() {
5459         },
5460
5461
5462         // TODO: a generic method for dealing with <tr>, RTL, intro
5463         // when increment internalApiVersion
5464         // wrapTr (scheduler)
5465
5466
5467         /* Utils
5468         ------------------------------------------------------------------------------------------------------------------*/
5469
5470
5471         // Applies the generic "intro" and "outro" HTML to the given cells.
5472         // Intro means the leftmost cell when the calendar is LTR and the rightmost cell when RTL. Vice-versa for outro.
5473         bookendCells: function(trEl) {
5474                 var introHtml = this.renderIntroHtml();
5475
5476                 if (introHtml) {
5477                         if (this.isRTL) {
5478                                 trEl.append(introHtml);
5479                         }
5480                         else {
5481                                 trEl.prepend(introHtml);
5482                         }
5483                 }
5484         }
5485
5486 };
5487
5488 ;;
5489
5490 /* A component that renders a grid of whole-days that runs horizontally. There can be multiple rows, one per week.
5491 ----------------------------------------------------------------------------------------------------------------------*/
5492
5493 var DayGrid = FC.DayGrid = Grid.extend(DayTableMixin, {
5494
5495         numbersVisible: false, // should render a row for day/week numbers? set by outside view. TODO: make internal
5496         bottomCoordPadding: 0, // hack for extending the hit area for the last row of the coordinate grid
5497
5498         rowEls: null, // set of fake row elements
5499         cellEls: null, // set of whole-day elements comprising the row's background
5500         helperEls: null, // set of cell skeleton elements for rendering the mock event "helper"
5501
5502         rowCoordCache: null,
5503         colCoordCache: null,
5504
5505
5506         // Renders the rows and columns into the component's `this.el`, which should already be assigned.
5507         // isRigid determins whether the individual rows should ignore the contents and be a constant height.
5508         // Relies on the view's colCnt and rowCnt. In the future, this component should probably be self-sufficient.
5509         renderDates: function(isRigid) {
5510                 var view = this.view;
5511                 var rowCnt = this.rowCnt;
5512                 var colCnt = this.colCnt;
5513                 var html = '';
5514                 var row;
5515                 var col;
5516
5517                 for (row = 0; row < rowCnt; row++) {
5518                         html += this.renderDayRowHtml(row, isRigid);
5519                 }
5520                 this.el.html(html);
5521
5522                 this.rowEls = this.el.find('.fc-row');
5523                 this.cellEls = this.el.find('.fc-day');
5524
5525                 this.rowCoordCache = new CoordCache({
5526                         els: this.rowEls,
5527                         isVertical: true
5528                 });
5529                 this.colCoordCache = new CoordCache({
5530                         els: this.cellEls.slice(0, this.colCnt), // only the first row
5531                         isHorizontal: true
5532                 });
5533
5534                 // trigger dayRender with each cell's element
5535                 for (row = 0; row < rowCnt; row++) {
5536                         for (col = 0; col < colCnt; col++) {
5537                                 view.trigger(
5538                                         'dayRender',
5539                                         null,
5540                                         this.getCellDate(row, col),
5541                                         this.getCellEl(row, col)
5542                                 );
5543                         }
5544                 }
5545         },
5546
5547
5548         unrenderDates: function() {
5549                 this.removeSegPopover();
5550         },
5551
5552
5553         renderBusinessHours: function() {
5554                 var segs = this.buildBusinessHourSegs(true); // wholeDay=true
5555                 this.renderFill('businessHours', segs, 'bgevent');
5556         },
5557
5558
5559         unrenderBusinessHours: function() {
5560                 this.unrenderFill('businessHours');
5561         },
5562
5563
5564         // Generates the HTML for a single row, which is a div that wraps a table.
5565         // `row` is the row number.
5566         renderDayRowHtml: function(row, isRigid) {
5567                 var view = this.view;
5568                 var classes = [ 'fc-row', 'fc-week', view.widgetContentClass ];
5569
5570                 if (isRigid) {
5571                         classes.push('fc-rigid');
5572                 }
5573
5574                 return '' +
5575                         '<div class="' + classes.join(' ') + '">' +
5576                                 '<div class="fc-bg">' +
5577                                         '<table>' +
5578                                                 this.renderBgTrHtml(row) +
5579                                         '</table>' +
5580                                 '</div>' +
5581                                 '<div class="fc-content-skeleton">' +
5582                                         '<table>' +
5583                                                 (this.numbersVisible ?
5584                                                         '<thead>' +
5585                                                                 this.renderNumberTrHtml(row) +
5586                                                         '</thead>' :
5587                                                         ''
5588                                                         ) +
5589                                         '</table>' +
5590                                 '</div>' +
5591                         '</div>';
5592         },
5593
5594
5595         /* Grid Number Rendering
5596         ------------------------------------------------------------------------------------------------------------------*/
5597
5598
5599         renderNumberTrHtml: function(row) {
5600                 return '' +
5601                         '<tr>' +
5602                                 (this.isRTL ? '' : this.renderNumberIntroHtml(row)) +
5603                                 this.renderNumberCellsHtml(row) +
5604                                 (this.isRTL ? this.renderNumberIntroHtml(row) : '') +
5605                         '</tr>';
5606         },
5607
5608
5609         renderNumberIntroHtml: function(row) {
5610                 return this.renderIntroHtml();
5611         },
5612
5613
5614         renderNumberCellsHtml: function(row) {
5615                 var htmls = [];
5616                 var col, date;
5617
5618                 for (col = 0; col < this.colCnt; col++) {
5619                         date = this.getCellDate(row, col);
5620                         htmls.push(this.renderNumberCellHtml(date));
5621                 }
5622
5623                 return htmls.join('');
5624         },
5625
5626
5627         // Generates the HTML for the <td>s of the "number" row in the DayGrid's content skeleton.
5628         // The number row will only exist if either day numbers or week numbers are turned on.
5629         renderNumberCellHtml: function(date) {
5630                 var html = '';
5631                 var classes;
5632                 var weekCalcFirstDoW;
5633
5634                 if (!this.view.dayNumbersVisible && !this.view.cellWeekNumbersVisible) {
5635                         // no numbers in day cell (week number must be along the side)
5636                         return '<td/>'; //  will create an empty space above events :(
5637                 }
5638
5639                 classes = this.getDayClasses(date);
5640                 classes.unshift('fc-day-top');
5641
5642                 if (this.view.cellWeekNumbersVisible) {
5643                         // To determine the day of week number change under ISO, we cannot
5644                         // rely on moment.js methods such as firstDayOfWeek() or weekday(),
5645                         // because they rely on the locale's dow (possibly overridden by
5646                         // our firstDay option), which may not be Monday. We cannot change
5647                         // dow, because that would affect the calendar start day as well.
5648                         if (date._locale._fullCalendar_weekCalc === 'ISO') {
5649                                 weekCalcFirstDoW = 1;  // Monday by ISO 8601 definition
5650                         }
5651                         else {
5652                                 weekCalcFirstDoW = date._locale.firstDayOfWeek();
5653                         }
5654                 }
5655
5656                 html += '<td class="' + classes.join(' ') + '" data-date="' + date.format() + '">';
5657
5658                 if (this.view.cellWeekNumbersVisible && (date.day() == weekCalcFirstDoW)) {
5659                         html += this.view.buildGotoAnchorHtml(
5660                                 { date: date, type: 'week' },
5661                                 { 'class': 'fc-week-number' },
5662                                 date.format('w') // inner HTML
5663                         );
5664                 }
5665
5666                 if (this.view.dayNumbersVisible) {
5667                         html += this.view.buildGotoAnchorHtml(
5668                                 date,
5669                                 { 'class': 'fc-day-number' },
5670                                 date.date() // inner HTML
5671                         );
5672                 }
5673
5674                 html += '</td>';
5675
5676                 return html;
5677         },
5678
5679
5680         /* Options
5681         ------------------------------------------------------------------------------------------------------------------*/
5682
5683
5684         // Computes a default event time formatting string if `timeFormat` is not explicitly defined
5685         computeEventTimeFormat: function() {
5686                 return this.view.opt('extraSmallTimeFormat'); // like "6p" or "6:30p"
5687         },
5688
5689
5690         // Computes a default `displayEventEnd` value if one is not expliclty defined
5691         computeDisplayEventEnd: function() {
5692                 return this.colCnt == 1; // we'll likely have space if there's only one day
5693         },
5694
5695
5696         /* Dates
5697         ------------------------------------------------------------------------------------------------------------------*/
5698
5699
5700         rangeUpdated: function() {
5701                 this.updateDayTable();
5702         },
5703
5704
5705         // Slices up the given span (unzoned start/end with other misc data) into an array of segments
5706         spanToSegs: function(span) {
5707                 var segs = this.sliceRangeByRow(span);
5708                 var i, seg;
5709
5710                 for (i = 0; i < segs.length; i++) {
5711                         seg = segs[i];
5712                         if (this.isRTL) {
5713                                 seg.leftCol = this.daysPerRow - 1 - seg.lastRowDayIndex;
5714                                 seg.rightCol = this.daysPerRow - 1 - seg.firstRowDayIndex;
5715                         }
5716                         else {
5717                                 seg.leftCol = seg.firstRowDayIndex;
5718                                 seg.rightCol = seg.lastRowDayIndex;
5719                         }
5720                 }
5721
5722                 return segs;
5723         },
5724
5725
5726         /* Hit System
5727         ------------------------------------------------------------------------------------------------------------------*/
5728
5729
5730         prepareHits: function() {
5731                 this.colCoordCache.build();
5732                 this.rowCoordCache.build();
5733                 this.rowCoordCache.bottoms[this.rowCnt - 1] += this.bottomCoordPadding; // hack
5734         },
5735
5736
5737         releaseHits: function() {
5738                 this.colCoordCache.clear();
5739                 this.rowCoordCache.clear();
5740         },
5741
5742
5743         queryHit: function(leftOffset, topOffset) {
5744                 if (this.colCoordCache.isLeftInBounds(leftOffset) && this.rowCoordCache.isTopInBounds(topOffset)) {
5745                         var col = this.colCoordCache.getHorizontalIndex(leftOffset);
5746                         var row = this.rowCoordCache.getVerticalIndex(topOffset);
5747
5748                         if (row != null && col != null) {
5749                                 return this.getCellHit(row, col);
5750                         }
5751                 }
5752         },
5753
5754
5755         getHitSpan: function(hit) {
5756                 return this.getCellRange(hit.row, hit.col);
5757         },
5758
5759
5760         getHitEl: function(hit) {
5761                 return this.getCellEl(hit.row, hit.col);
5762         },
5763
5764
5765         /* Cell System
5766         ------------------------------------------------------------------------------------------------------------------*/
5767         // FYI: the first column is the leftmost column, regardless of date
5768
5769
5770         getCellHit: function(row, col) {
5771                 return {
5772                         row: row,
5773                         col: col,
5774                         component: this, // needed unfortunately :(
5775                         left: this.colCoordCache.getLeftOffset(col),
5776                         right: this.colCoordCache.getRightOffset(col),
5777                         top: this.rowCoordCache.getTopOffset(row),
5778                         bottom: this.rowCoordCache.getBottomOffset(row)
5779                 };
5780         },
5781
5782
5783         getCellEl: function(row, col) {
5784                 return this.cellEls.eq(row * this.colCnt + col);
5785         },
5786
5787
5788         /* Event Drag Visualization
5789         ------------------------------------------------------------------------------------------------------------------*/
5790         // TODO: move to DayGrid.event, similar to what we did with Grid's drag methods
5791
5792
5793         // Renders a visual indication of an event or external element being dragged.
5794         // `eventLocation` has zoned start and end (optional)
5795         renderDrag: function(eventLocation, seg) {
5796
5797                 // always render a highlight underneath
5798                 this.renderHighlight(this.eventToSpan(eventLocation));
5799
5800                 // if a segment from the same calendar but another component is being dragged, render a helper event
5801                 if (seg && seg.component !== this) {
5802                         return this.renderEventLocationHelper(eventLocation, seg); // returns mock event elements
5803                 }
5804         },
5805
5806
5807         // Unrenders any visual indication of a hovering event
5808         unrenderDrag: function() {
5809                 this.unrenderHighlight();
5810                 this.unrenderHelper();
5811         },
5812
5813
5814         /* Event Resize Visualization
5815         ------------------------------------------------------------------------------------------------------------------*/
5816
5817
5818         // Renders a visual indication of an event being resized
5819         renderEventResize: function(eventLocation, seg) {
5820                 this.renderHighlight(this.eventToSpan(eventLocation));
5821                 return this.renderEventLocationHelper(eventLocation, seg); // returns mock event elements
5822         },
5823
5824
5825         // Unrenders a visual indication of an event being resized
5826         unrenderEventResize: function() {
5827                 this.unrenderHighlight();
5828                 this.unrenderHelper();
5829         },
5830
5831
5832         /* Event Helper
5833         ------------------------------------------------------------------------------------------------------------------*/
5834
5835
5836         // Renders a mock "helper" event. `sourceSeg` is the associated internal segment object. It can be null.
5837         renderHelper: function(event, sourceSeg) {
5838                 var helperNodes = [];
5839                 var segs = this.eventToSegs(event);
5840                 var rowStructs;
5841
5842                 segs = this.renderFgSegEls(segs); // assigns each seg's el and returns a subset of segs that were rendered
5843                 rowStructs = this.renderSegRows(segs);
5844
5845                 // inject each new event skeleton into each associated row
5846                 this.rowEls.each(function(row, rowNode) {
5847                         var rowEl = $(rowNode); // the .fc-row
5848                         var skeletonEl = $('<div class="fc-helper-skeleton"><table/></div>'); // will be absolutely positioned
5849                         var skeletonTop;
5850
5851                         // If there is an original segment, match the top position. Otherwise, put it at the row's top level
5852                         if (sourceSeg && sourceSeg.row === row) {
5853                                 skeletonTop = sourceSeg.el.position().top;
5854                         }
5855                         else {
5856                                 skeletonTop = rowEl.find('.fc-content-skeleton tbody').position().top;
5857                         }
5858
5859                         skeletonEl.css('top', skeletonTop)
5860                                 .find('table')
5861                                         .append(rowStructs[row].tbodyEl);
5862
5863                         rowEl.append(skeletonEl);
5864                         helperNodes.push(skeletonEl[0]);
5865                 });
5866
5867                 return ( // must return the elements rendered
5868                         this.helperEls = $(helperNodes) // array -> jQuery set
5869                 );
5870         },
5871
5872
5873         // Unrenders any visual indication of a mock helper event
5874         unrenderHelper: function() {
5875                 if (this.helperEls) {
5876                         this.helperEls.remove();
5877                         this.helperEls = null;
5878                 }
5879         },
5880
5881
5882         /* Fill System (highlight, background events, business hours)
5883         ------------------------------------------------------------------------------------------------------------------*/
5884
5885
5886         fillSegTag: 'td', // override the default tag name
5887
5888
5889         // Renders a set of rectangles over the given segments of days.
5890         // Only returns segments that successfully rendered.
5891         renderFill: function(type, segs, className) {
5892                 var nodes = [];
5893                 var i, seg;
5894                 var skeletonEl;
5895
5896                 segs = this.renderFillSegEls(type, segs); // assignes `.el` to each seg. returns successfully rendered segs
5897
5898                 for (i = 0; i < segs.length; i++) {
5899                         seg = segs[i];
5900                         skeletonEl = this.renderFillRow(type, seg, className);
5901                         this.rowEls.eq(seg.row).append(skeletonEl);
5902                         nodes.push(skeletonEl[0]);
5903                 }
5904
5905                 this.elsByFill[type] = $(nodes);
5906
5907                 return segs;
5908         },
5909
5910
5911         // Generates the HTML needed for one row of a fill. Requires the seg's el to be rendered.
5912         renderFillRow: function(type, seg, className) {
5913                 var colCnt = this.colCnt;
5914                 var startCol = seg.leftCol;
5915                 var endCol = seg.rightCol + 1;
5916                 var skeletonEl;
5917                 var trEl;
5918
5919                 className = className || type.toLowerCase();
5920
5921                 skeletonEl = $(
5922                         '<div class="fc-' + className + '-skeleton">' +
5923                                 '<table><tr/></table>' +
5924                         '</div>'
5925                 );
5926                 trEl = skeletonEl.find('tr');
5927
5928                 if (startCol > 0) {
5929                         trEl.append('<td colspan="' + startCol + '"/>');
5930                 }
5931
5932                 trEl.append(
5933                         seg.el.attr('colspan', endCol - startCol)
5934                 );
5935
5936                 if (endCol < colCnt) {
5937                         trEl.append('<td colspan="' + (colCnt - endCol) + '"/>');
5938                 }
5939
5940                 this.bookendCells(trEl);
5941
5942                 return skeletonEl;
5943         }
5944
5945 });
5946
5947 ;;
5948
5949 /* Event-rendering methods for the DayGrid class
5950 ----------------------------------------------------------------------------------------------------------------------*/
5951
5952 DayGrid.mixin({
5953
5954         rowStructs: null, // an array of objects, each holding information about a row's foreground event-rendering
5955
5956
5957         // Unrenders all events currently rendered on the grid
5958         unrenderEvents: function() {
5959                 this.removeSegPopover(); // removes the "more.." events popover
5960                 Grid.prototype.unrenderEvents.apply(this, arguments); // calls the super-method
5961         },
5962
5963
5964         // Retrieves all rendered segment objects currently rendered on the grid
5965         getEventSegs: function() {
5966                 return Grid.prototype.getEventSegs.call(this) // get the segments from the super-method
5967                         .concat(this.popoverSegs || []); // append the segments from the "more..." popover
5968         },
5969
5970
5971         // Renders the given background event segments onto the grid
5972         renderBgSegs: function(segs) {
5973
5974                 // don't render timed background events
5975                 var allDaySegs = $.grep(segs, function(seg) {
5976                         return seg.event.allDay;
5977                 });
5978
5979                 return Grid.prototype.renderBgSegs.call(this, allDaySegs); // call the super-method
5980         },
5981
5982
5983         // Renders the given foreground event segments onto the grid
5984         renderFgSegs: function(segs) {
5985                 var rowStructs;
5986
5987                 // render an `.el` on each seg
5988                 // returns a subset of the segs. segs that were actually rendered
5989                 segs = this.renderFgSegEls(segs);
5990
5991                 rowStructs = this.rowStructs = this.renderSegRows(segs);
5992
5993                 // append to each row's content skeleton
5994                 this.rowEls.each(function(i, rowNode) {
5995                         $(rowNode).find('.fc-content-skeleton > table').append(
5996                                 rowStructs[i].tbodyEl
5997                         );
5998                 });
5999
6000                 return segs; // return only the segs that were actually rendered
6001         },
6002
6003
6004         // Unrenders all currently rendered foreground event segments
6005         unrenderFgSegs: function() {
6006                 var rowStructs = this.rowStructs || [];
6007                 var rowStruct;
6008
6009                 while ((rowStruct = rowStructs.pop())) {
6010                         rowStruct.tbodyEl.remove();
6011                 }
6012
6013                 this.rowStructs = null;
6014         },
6015
6016
6017         // Uses the given events array to generate <tbody> elements that should be appended to each row's content skeleton.
6018         // Returns an array of rowStruct objects (see the bottom of `renderSegRow`).
6019         // PRECONDITION: each segment shoud already have a rendered and assigned `.el`
6020         renderSegRows: function(segs) {
6021                 var rowStructs = [];
6022                 var segRows;
6023                 var row;
6024
6025                 segRows = this.groupSegRows(segs); // group into nested arrays
6026
6027                 // iterate each row of segment groupings
6028                 for (row = 0; row < segRows.length; row++) {
6029                         rowStructs.push(
6030                                 this.renderSegRow(row, segRows[row])
6031                         );
6032                 }
6033
6034                 return rowStructs;
6035         },
6036
6037
6038         // Builds the HTML to be used for the default element for an individual segment
6039         fgSegHtml: function(seg, disableResizing) {
6040                 var view = this.view;
6041                 var event = seg.event;
6042                 var isDraggable = view.isEventDraggable(event);
6043                 var isResizableFromStart = !disableResizing && event.allDay &&
6044                         seg.isStart && view.isEventResizableFromStart(event);
6045                 var isResizableFromEnd = !disableResizing && event.allDay &&
6046                         seg.isEnd && view.isEventResizableFromEnd(event);
6047                 var classes = this.getSegClasses(seg, isDraggable, isResizableFromStart || isResizableFromEnd);
6048                 var skinCss = cssToStr(this.getSegSkinCss(seg));
6049                 var timeHtml = '';
6050                 var timeText;
6051                 var titleHtml;
6052
6053                 classes.unshift('fc-day-grid-event', 'fc-h-event');
6054
6055                 // Only display a timed events time if it is the starting segment
6056                 if (seg.isStart) {
6057                         timeText = this.getEventTimeText(event);
6058                         if (timeText) {
6059                                 timeHtml = '<span class="fc-time">' + htmlEscape(timeText) + '</span>';
6060                         }
6061                 }
6062
6063                 titleHtml =
6064                         '<span class="fc-title">' +
6065                                 (htmlEscape(event.title || '') || '&nbsp;') + // we always want one line of height
6066                         '</span>';
6067                 
6068                 return '<a class="' + classes.join(' ') + '"' +
6069                                 (event.url ?
6070                                         ' href="' + htmlEscape(event.url) + '"' :
6071                                         ''
6072                                         ) +
6073                                 (skinCss ?
6074                                         ' style="' + skinCss + '"' :
6075                                         ''
6076                                         ) +
6077                         '>' +
6078                                 '<div class="fc-content">' +
6079                                         (this.isRTL ?
6080                                                 titleHtml + ' ' + timeHtml : // put a natural space in between
6081                                                 timeHtml + ' ' + titleHtml   //
6082                                                 ) +
6083                                 '</div>' +
6084                                 (isResizableFromStart ?
6085                                         '<div class="fc-resizer fc-start-resizer" />' :
6086                                         ''
6087                                         ) +
6088                                 (isResizableFromEnd ?
6089                                         '<div class="fc-resizer fc-end-resizer" />' :
6090                                         ''
6091                                         ) +
6092                         '</a>';
6093         },
6094
6095
6096         // Given a row # and an array of segments all in the same row, render a <tbody> element, a skeleton that contains
6097         // the segments. Returns object with a bunch of internal data about how the render was calculated.
6098         // NOTE: modifies rowSegs
6099         renderSegRow: function(row, rowSegs) {
6100                 var colCnt = this.colCnt;
6101                 var segLevels = this.buildSegLevels(rowSegs); // group into sub-arrays of levels
6102                 var levelCnt = Math.max(1, segLevels.length); // ensure at least one level
6103                 var tbody = $('<tbody/>');
6104                 var segMatrix = []; // lookup for which segments are rendered into which level+col cells
6105                 var cellMatrix = []; // lookup for all <td> elements of the level+col matrix
6106                 var loneCellMatrix = []; // lookup for <td> elements that only take up a single column
6107                 var i, levelSegs;
6108                 var col;
6109                 var tr;
6110                 var j, seg;
6111                 var td;
6112
6113                 // populates empty cells from the current column (`col`) to `endCol`
6114                 function emptyCellsUntil(endCol) {
6115                         while (col < endCol) {
6116                                 // try to grab a cell from the level above and extend its rowspan. otherwise, create a fresh cell
6117                                 td = (loneCellMatrix[i - 1] || [])[col];
6118                                 if (td) {
6119                                         td.attr(
6120                                                 'rowspan',
6121                                                 parseInt(td.attr('rowspan') || 1, 10) + 1
6122                                         );
6123                                 }
6124                                 else {
6125                                         td = $('<td/>');
6126                                         tr.append(td);
6127                                 }
6128                                 cellMatrix[i][col] = td;
6129                                 loneCellMatrix[i][col] = td;
6130                                 col++;
6131                         }
6132                 }
6133
6134                 for (i = 0; i < levelCnt; i++) { // iterate through all levels
6135                         levelSegs = segLevels[i];
6136                         col = 0;
6137                         tr = $('<tr/>');
6138
6139                         segMatrix.push([]);
6140                         cellMatrix.push([]);
6141                         loneCellMatrix.push([]);
6142
6143                         // levelCnt might be 1 even though there are no actual levels. protect against this.
6144                         // this single empty row is useful for styling.
6145                         if (levelSegs) {
6146                                 for (j = 0; j < levelSegs.length; j++) { // iterate through segments in level
6147                                         seg = levelSegs[j];
6148
6149                                         emptyCellsUntil(seg.leftCol);
6150
6151                                         // create a container that occupies or more columns. append the event element.
6152                                         td = $('<td class="fc-event-container"/>').append(seg.el);
6153                                         if (seg.leftCol != seg.rightCol) {
6154                                                 td.attr('colspan', seg.rightCol - seg.leftCol + 1);
6155                                         }
6156                                         else { // a single-column segment
6157                                                 loneCellMatrix[i][col] = td;
6158                                         }
6159
6160                                         while (col <= seg.rightCol) {
6161                                                 cellMatrix[i][col] = td;
6162                                                 segMatrix[i][col] = seg;
6163                                                 col++;
6164                                         }
6165
6166                                         tr.append(td);
6167                                 }
6168                         }
6169
6170                         emptyCellsUntil(colCnt); // finish off the row
6171                         this.bookendCells(tr);
6172                         tbody.append(tr);
6173                 }
6174
6175                 return { // a "rowStruct"
6176                         row: row, // the row number
6177                         tbodyEl: tbody,
6178                         cellMatrix: cellMatrix,
6179                         segMatrix: segMatrix,
6180                         segLevels: segLevels,
6181                         segs: rowSegs
6182                 };
6183         },
6184
6185
6186         // Stacks a flat array of segments, which are all assumed to be in the same row, into subarrays of vertical levels.
6187         // NOTE: modifies segs
6188         buildSegLevels: function(segs) {
6189                 var levels = [];
6190                 var i, seg;
6191                 var j;
6192
6193                 // Give preference to elements with certain criteria, so they have
6194                 // a chance to be closer to the top.
6195                 this.sortEventSegs(segs);
6196                 
6197                 for (i = 0; i < segs.length; i++) {
6198                         seg = segs[i];
6199
6200                         // loop through levels, starting with the topmost, until the segment doesn't collide with other segments
6201                         for (j = 0; j < levels.length; j++) {
6202                                 if (!isDaySegCollision(seg, levels[j])) {
6203                                         break;
6204                                 }
6205                         }
6206                         // `j` now holds the desired subrow index
6207                         seg.level = j;
6208
6209                         // create new level array if needed and append segment
6210                         (levels[j] || (levels[j] = [])).push(seg);
6211                 }
6212
6213                 // order segments left-to-right. very important if calendar is RTL
6214                 for (j = 0; j < levels.length; j++) {
6215                         levels[j].sort(compareDaySegCols);
6216                 }
6217
6218                 return levels;
6219         },
6220
6221
6222         // Given a flat array of segments, return an array of sub-arrays, grouped by each segment's row
6223         groupSegRows: function(segs) {
6224                 var segRows = [];
6225                 var i;
6226
6227                 for (i = 0; i < this.rowCnt; i++) {
6228                         segRows.push([]);
6229                 }
6230
6231                 for (i = 0; i < segs.length; i++) {
6232                         segRows[segs[i].row].push(segs[i]);
6233                 }
6234
6235                 return segRows;
6236         }
6237
6238 });
6239
6240
6241 // Computes whether two segments' columns collide. They are assumed to be in the same row.
6242 function isDaySegCollision(seg, otherSegs) {
6243         var i, otherSeg;
6244
6245         for (i = 0; i < otherSegs.length; i++) {
6246                 otherSeg = otherSegs[i];
6247
6248                 if (
6249                         otherSeg.leftCol <= seg.rightCol &&
6250                         otherSeg.rightCol >= seg.leftCol
6251                 ) {
6252                         return true;
6253                 }
6254         }
6255
6256         return false;
6257 }
6258
6259
6260 // A cmp function for determining the leftmost event
6261 function compareDaySegCols(a, b) {
6262         return a.leftCol - b.leftCol;
6263 }
6264
6265 ;;
6266
6267 /* Methods relate to limiting the number events for a given day on a DayGrid
6268 ----------------------------------------------------------------------------------------------------------------------*/
6269 // NOTE: all the segs being passed around in here are foreground segs
6270
6271 DayGrid.mixin({
6272
6273         segPopover: null, // the Popover that holds events that can't fit in a cell. null when not visible
6274         popoverSegs: null, // an array of segment objects that the segPopover holds. null when not visible
6275
6276
6277         removeSegPopover: function() {
6278                 if (this.segPopover) {
6279                         this.segPopover.hide(); // in handler, will call segPopover's removeElement
6280                 }
6281         },
6282
6283
6284         // Limits the number of "levels" (vertically stacking layers of events) for each row of the grid.
6285         // `levelLimit` can be false (don't limit), a number, or true (should be computed).
6286         limitRows: function(levelLimit) {
6287                 var rowStructs = this.rowStructs || [];
6288                 var row; // row #
6289                 var rowLevelLimit;
6290
6291                 for (row = 0; row < rowStructs.length; row++) {
6292                         this.unlimitRow(row);
6293
6294                         if (!levelLimit) {
6295                                 rowLevelLimit = false;
6296                         }
6297                         else if (typeof levelLimit === 'number') {
6298                                 rowLevelLimit = levelLimit;
6299                         }
6300                         else {
6301                                 rowLevelLimit = this.computeRowLevelLimit(row);
6302                         }
6303
6304                         if (rowLevelLimit !== false) {
6305                                 this.limitRow(row, rowLevelLimit);
6306                         }
6307                 }
6308         },
6309
6310
6311         // Computes the number of levels a row will accomodate without going outside its bounds.
6312         // Assumes the row is "rigid" (maintains a constant height regardless of what is inside).
6313         // `row` is the row number.
6314         computeRowLevelLimit: function(row) {
6315                 var rowEl = this.rowEls.eq(row); // the containing "fake" row div
6316                 var rowHeight = rowEl.height(); // TODO: cache somehow?
6317                 var trEls = this.rowStructs[row].tbodyEl.children();
6318                 var i, trEl;
6319                 var trHeight;
6320
6321                 function iterInnerHeights(i, childNode) {
6322                         trHeight = Math.max(trHeight, $(childNode).outerHeight());
6323                 }
6324
6325                 // Reveal one level <tr> at a time and stop when we find one out of bounds
6326                 for (i = 0; i < trEls.length; i++) {
6327                         trEl = trEls.eq(i).removeClass('fc-limited'); // reset to original state (reveal)
6328
6329                         // with rowspans>1 and IE8, trEl.outerHeight() would return the height of the largest cell,
6330                         // so instead, find the tallest inner content element.
6331                         trHeight = 0;
6332                         trEl.find('> td > :first-child').each(iterInnerHeights);
6333
6334                         if (trEl.position().top + trHeight > rowHeight) {
6335                                 return i;
6336                         }
6337                 }
6338
6339                 return false; // should not limit at all
6340         },
6341
6342
6343         // Limits the given grid row to the maximum number of levels and injects "more" links if necessary.
6344         // `row` is the row number.
6345         // `levelLimit` is a number for the maximum (inclusive) number of levels allowed.
6346         limitRow: function(row, levelLimit) {
6347                 var _this = this;
6348                 var rowStruct = this.rowStructs[row];
6349                 var moreNodes = []; // array of "more" <a> links and <td> DOM nodes
6350                 var col = 0; // col #, left-to-right (not chronologically)
6351                 var levelSegs; // array of segment objects in the last allowable level, ordered left-to-right
6352                 var cellMatrix; // a matrix (by level, then column) of all <td> jQuery elements in the row
6353                 var limitedNodes; // array of temporarily hidden level <tr> and segment <td> DOM nodes
6354                 var i, seg;
6355                 var segsBelow; // array of segment objects below `seg` in the current `col`
6356                 var totalSegsBelow; // total number of segments below `seg` in any of the columns `seg` occupies
6357                 var colSegsBelow; // array of segment arrays, below seg, one for each column (offset from segs's first column)
6358                 var td, rowspan;
6359                 var segMoreNodes; // array of "more" <td> cells that will stand-in for the current seg's cell
6360                 var j;
6361                 var moreTd, moreWrap, moreLink;
6362
6363                 // Iterates through empty level cells and places "more" links inside if need be
6364                 function emptyCellsUntil(endCol) { // goes from current `col` to `endCol`
6365                         while (col < endCol) {
6366                                 segsBelow = _this.getCellSegs(row, col, levelLimit);
6367                                 if (segsBelow.length) {
6368                                         td = cellMatrix[levelLimit - 1][col];
6369                                         moreLink = _this.renderMoreLink(row, col, segsBelow);
6370                                         moreWrap = $('<div/>').append(moreLink);
6371                                         td.append(moreWrap);
6372                                         moreNodes.push(moreWrap[0]);
6373                                 }
6374                                 col++;
6375                         }
6376                 }
6377
6378                 if (levelLimit && levelLimit < rowStruct.segLevels.length) { // is it actually over the limit?
6379                         levelSegs = rowStruct.segLevels[levelLimit - 1];
6380                         cellMatrix = rowStruct.cellMatrix;
6381
6382                         limitedNodes = rowStruct.tbodyEl.children().slice(levelLimit) // get level <tr> elements past the limit
6383                                 .addClass('fc-limited').get(); // hide elements and get a simple DOM-nodes array
6384
6385                         // iterate though segments in the last allowable level
6386                         for (i = 0; i < levelSegs.length; i++) {
6387                                 seg = levelSegs[i];
6388                                 emptyCellsUntil(seg.leftCol); // process empty cells before the segment
6389
6390                                 // determine *all* segments below `seg` that occupy the same columns
6391                                 colSegsBelow = [];
6392                                 totalSegsBelow = 0;
6393                                 while (col <= seg.rightCol) {
6394                                         segsBelow = this.getCellSegs(row, col, levelLimit);
6395                                         colSegsBelow.push(segsBelow);
6396                                         totalSegsBelow += segsBelow.length;
6397                                         col++;
6398                                 }
6399
6400                                 if (totalSegsBelow) { // do we need to replace this segment with one or many "more" links?
6401                                         td = cellMatrix[levelLimit - 1][seg.leftCol]; // the segment's parent cell
6402                                         rowspan = td.attr('rowspan') || 1;
6403                                         segMoreNodes = [];
6404
6405                                         // make a replacement <td> for each column the segment occupies. will be one for each colspan
6406                                         for (j = 0; j < colSegsBelow.length; j++) {
6407                                                 moreTd = $('<td class="fc-more-cell"/>').attr('rowspan', rowspan);
6408                                                 segsBelow = colSegsBelow[j];
6409                                                 moreLink = this.renderMoreLink(
6410                                                         row,
6411                                                         seg.leftCol + j,
6412                                                         [ seg ].concat(segsBelow) // count seg as hidden too
6413                                                 );
6414                                                 moreWrap = $('<div/>').append(moreLink);
6415                                                 moreTd.append(moreWrap);
6416                                                 segMoreNodes.push(moreTd[0]);
6417                                                 moreNodes.push(moreTd[0]);
6418                                         }
6419
6420                                         td.addClass('fc-limited').after($(segMoreNodes)); // hide original <td> and inject replacements
6421                                         limitedNodes.push(td[0]);
6422                                 }
6423                         }
6424
6425                         emptyCellsUntil(this.colCnt); // finish off the level
6426                         rowStruct.moreEls = $(moreNodes); // for easy undoing later
6427                         rowStruct.limitedEls = $(limitedNodes); // for easy undoing later
6428                 }
6429         },
6430
6431
6432         // Reveals all levels and removes all "more"-related elements for a grid's row.
6433         // `row` is a row number.
6434         unlimitRow: function(row) {
6435                 var rowStruct = this.rowStructs[row];
6436
6437                 if (rowStruct.moreEls) {
6438                         rowStruct.moreEls.remove();
6439                         rowStruct.moreEls = null;
6440                 }
6441
6442                 if (rowStruct.limitedEls) {
6443                         rowStruct.limitedEls.removeClass('fc-limited');
6444                         rowStruct.limitedEls = null;
6445                 }
6446         },
6447
6448
6449         // Renders an <a> element that represents hidden event element for a cell.
6450         // Responsible for attaching click handler as well.
6451         renderMoreLink: function(row, col, hiddenSegs) {
6452                 var _this = this;
6453                 var view = this.view;
6454
6455                 return $('<a class="fc-more"/>')
6456                         .text(
6457                                 this.getMoreLinkText(hiddenSegs.length)
6458                         )
6459                         .on('click', function(ev) {
6460                                 var clickOption = view.opt('eventLimitClick');
6461                                 var date = _this.getCellDate(row, col);
6462                                 var moreEl = $(this);
6463                                 var dayEl = _this.getCellEl(row, col);
6464                                 var allSegs = _this.getCellSegs(row, col);
6465
6466                                 // rescope the segments to be within the cell's date
6467                                 var reslicedAllSegs = _this.resliceDaySegs(allSegs, date);
6468                                 var reslicedHiddenSegs = _this.resliceDaySegs(hiddenSegs, date);
6469
6470                                 if (typeof clickOption === 'function') {
6471                                         // the returned value can be an atomic option
6472                                         clickOption = view.trigger('eventLimitClick', null, {
6473                                                 date: date,
6474                                                 dayEl: dayEl,
6475                                                 moreEl: moreEl,
6476                                                 segs: reslicedAllSegs,
6477                                                 hiddenSegs: reslicedHiddenSegs
6478                                         }, ev);
6479                                 }
6480
6481                                 if (clickOption === 'popover') {
6482                                         _this.showSegPopover(row, col, moreEl, reslicedAllSegs);
6483                                 }
6484                                 else if (typeof clickOption === 'string') { // a view name
6485                                         view.calendar.zoomTo(date, clickOption);
6486                                 }
6487                         });
6488         },
6489
6490
6491         // Reveals the popover that displays all events within a cell
6492         showSegPopover: function(row, col, moreLink, segs) {
6493                 var _this = this;
6494                 var view = this.view;
6495                 var moreWrap = moreLink.parent(); // the <div> wrapper around the <a>
6496                 var topEl; // the element we want to match the top coordinate of
6497                 var options;
6498
6499                 if (this.rowCnt == 1) {
6500                         topEl = view.el; // will cause the popover to cover any sort of header
6501                 }
6502                 else {
6503                         topEl = this.rowEls.eq(row); // will align with top of row
6504                 }
6505
6506                 options = {
6507                         className: 'fc-more-popover',
6508                         content: this.renderSegPopoverContent(row, col, segs),
6509                         parentEl: this.view.el, // attach to root of view. guarantees outside of scrollbars.
6510                         top: topEl.offset().top,
6511                         autoHide: true, // when the user clicks elsewhere, hide the popover
6512                         viewportConstrain: view.opt('popoverViewportConstrain'),
6513                         hide: function() {
6514                                 // kill everything when the popover is hidden
6515                                 _this.segPopover.removeElement();
6516                                 _this.segPopover = null;
6517                                 _this.popoverSegs = null;
6518                         }
6519                 };
6520
6521                 // Determine horizontal coordinate.
6522                 // We use the moreWrap instead of the <td> to avoid border confusion.
6523                 if (this.isRTL) {
6524                         options.right = moreWrap.offset().left + moreWrap.outerWidth() + 1; // +1 to be over cell border
6525                 }
6526                 else {
6527                         options.left = moreWrap.offset().left - 1; // -1 to be over cell border
6528                 }
6529
6530                 this.segPopover = new Popover(options);
6531                 this.segPopover.show();
6532
6533                 // the popover doesn't live within the grid's container element, and thus won't get the event
6534                 // delegated-handlers for free. attach event-related handlers to the popover.
6535                 this.bindSegHandlersToEl(this.segPopover.el);
6536         },
6537
6538
6539         // Builds the inner DOM contents of the segment popover
6540         renderSegPopoverContent: function(row, col, segs) {
6541                 var view = this.view;
6542                 var isTheme = view.opt('theme');
6543                 var title = this.getCellDate(row, col).format(view.opt('dayPopoverFormat'));
6544                 var content = $(
6545                         '<div class="fc-header ' + view.widgetHeaderClass + '">' +
6546                                 '<span class="fc-close ' +
6547                                         (isTheme ? 'ui-icon ui-icon-closethick' : 'fc-icon fc-icon-x') +
6548                                 '"></span>' +
6549                                 '<span class="fc-title">' +
6550                                         htmlEscape(title) +
6551                                 '</span>' +
6552                                 '<div class="fc-clear"/>' +
6553                         '</div>' +
6554                         '<div class="fc-body ' + view.widgetContentClass + '">' +
6555                                 '<div class="fc-event-container"></div>' +
6556                         '</div>'
6557                 );
6558                 var segContainer = content.find('.fc-event-container');
6559                 var i;
6560
6561                 // render each seg's `el` and only return the visible segs
6562                 segs = this.renderFgSegEls(segs, true); // disableResizing=true
6563                 this.popoverSegs = segs;
6564
6565                 for (i = 0; i < segs.length; i++) {
6566
6567                         // because segments in the popover are not part of a grid coordinate system, provide a hint to any
6568                         // grids that want to do drag-n-drop about which cell it came from
6569                         this.prepareHits();
6570                         segs[i].hit = this.getCellHit(row, col);
6571                         this.releaseHits();
6572
6573                         segContainer.append(segs[i].el);
6574                 }
6575
6576                 return content;
6577         },
6578
6579
6580         // Given the events within an array of segment objects, reslice them to be in a single day
6581         resliceDaySegs: function(segs, dayDate) {
6582
6583                 // build an array of the original events
6584                 var events = $.map(segs, function(seg) {
6585                         return seg.event;
6586                 });
6587
6588                 var dayStart = dayDate.clone();
6589                 var dayEnd = dayStart.clone().add(1, 'days');
6590                 var dayRange = { start: dayStart, end: dayEnd };
6591
6592                 // slice the events with a custom slicing function
6593                 segs = this.eventsToSegs(
6594                         events,
6595                         function(range) {
6596                                 var seg = intersectRanges(range, dayRange); // undefind if no intersection
6597                                 return seg ? [ seg ] : []; // must return an array of segments
6598                         }
6599                 );
6600
6601                 // force an order because eventsToSegs doesn't guarantee one
6602                 this.sortEventSegs(segs);
6603
6604                 return segs;
6605         },
6606
6607
6608         // Generates the text that should be inside a "more" link, given the number of events it represents
6609         getMoreLinkText: function(num) {
6610                 var opt = this.view.opt('eventLimitText');
6611
6612                 if (typeof opt === 'function') {
6613                         return opt(num);
6614                 }
6615                 else {
6616                         return '+' + num + ' ' + opt;
6617                 }
6618         },
6619
6620
6621         // Returns segments within a given cell.
6622         // If `startLevel` is specified, returns only events including and below that level. Otherwise returns all segs.
6623         getCellSegs: function(row, col, startLevel) {
6624                 var segMatrix = this.rowStructs[row].segMatrix;
6625                 var level = startLevel || 0;
6626                 var segs = [];
6627                 var seg;
6628
6629                 while (level < segMatrix.length) {
6630                         seg = segMatrix[level][col];
6631                         if (seg) {
6632                                 segs.push(seg);
6633                         }
6634                         level++;
6635                 }
6636
6637                 return segs;
6638         }
6639
6640 });
6641
6642 ;;
6643
6644 /* A component that renders one or more columns of vertical time slots
6645 ----------------------------------------------------------------------------------------------------------------------*/
6646 // We mixin DayTable, even though there is only a single row of days
6647
6648 var TimeGrid = FC.TimeGrid = Grid.extend(DayTableMixin, {
6649
6650         slotDuration: null, // duration of a "slot", a distinct time segment on given day, visualized by lines
6651         snapDuration: null, // granularity of time for dragging and selecting
6652         snapsPerSlot: null,
6653         minTime: null, // Duration object that denotes the first visible time of any given day
6654         maxTime: null, // Duration object that denotes the exclusive visible end time of any given day
6655         labelFormat: null, // formatting string for times running along vertical axis
6656         labelInterval: null, // duration of how often a label should be displayed for a slot
6657
6658         colEls: null, // cells elements in the day-row background
6659         slatContainerEl: null, // div that wraps all the slat rows
6660         slatEls: null, // elements running horizontally across all columns
6661         nowIndicatorEls: null,
6662
6663         colCoordCache: null,
6664         slatCoordCache: null,
6665
6666
6667         constructor: function() {
6668                 Grid.apply(this, arguments); // call the super-constructor
6669
6670                 this.processOptions();
6671         },
6672
6673
6674         // Renders the time grid into `this.el`, which should already be assigned.
6675         // Relies on the view's colCnt. In the future, this component should probably be self-sufficient.
6676         renderDates: function() {
6677                 this.el.html(this.renderHtml());
6678                 this.colEls = this.el.find('.fc-day');
6679                 this.slatContainerEl = this.el.find('.fc-slats');
6680                 this.slatEls = this.slatContainerEl.find('tr');
6681
6682                 this.colCoordCache = new CoordCache({
6683                         els: this.colEls,
6684                         isHorizontal: true
6685                 });
6686                 this.slatCoordCache = new CoordCache({
6687                         els: this.slatEls,
6688                         isVertical: true
6689                 });
6690
6691                 this.renderContentSkeleton();
6692         },
6693
6694
6695         // Renders the basic HTML skeleton for the grid
6696         renderHtml: function() {
6697                 return '' +
6698                         '<div class="fc-bg">' +
6699                                 '<table>' +
6700                                         this.renderBgTrHtml(0) + // row=0
6701                                 '</table>' +
6702                         '</div>' +
6703                         '<div class="fc-slats">' +
6704                                 '<table>' +
6705                                         this.renderSlatRowHtml() +
6706                                 '</table>' +
6707                         '</div>';
6708         },
6709
6710
6711         // Generates the HTML for the horizontal "slats" that run width-wise. Has a time axis on a side. Depends on RTL.
6712         renderSlatRowHtml: function() {
6713                 var view = this.view;
6714                 var isRTL = this.isRTL;
6715                 var html = '';
6716                 var slotTime = moment.duration(+this.minTime); // wish there was .clone() for durations
6717                 var slotDate; // will be on the view's first day, but we only care about its time
6718                 var isLabeled;
6719                 var axisHtml;
6720
6721                 // Calculate the time for each slot
6722                 while (slotTime < this.maxTime) {
6723                         slotDate = this.start.clone().time(slotTime);
6724                         isLabeled = isInt(divideDurationByDuration(slotTime, this.labelInterval));
6725
6726                         axisHtml =
6727                                 '<td class="fc-axis fc-time ' + view.widgetContentClass + '" ' + view.axisStyleAttr() + '>' +
6728                                         (isLabeled ?
6729                                                 '<span>' + // for matchCellWidths
6730                                                         htmlEscape(slotDate.format(this.labelFormat)) +
6731                                                 '</span>' :
6732                                                 ''
6733                                                 ) +
6734                                 '</td>';
6735
6736                         html +=
6737                                 '<tr data-time="' + slotDate.format('HH:mm:ss') + '"' +
6738                                         (isLabeled ? '' : ' class="fc-minor"') +
6739                                         '>' +
6740                                         (!isRTL ? axisHtml : '') +
6741                                         '<td class="' + view.widgetContentClass + '"/>' +
6742                                         (isRTL ? axisHtml : '') +
6743                                 "</tr>";
6744
6745                         slotTime.add(this.slotDuration);
6746                 }
6747
6748                 return html;
6749         },
6750
6751
6752         /* Options
6753         ------------------------------------------------------------------------------------------------------------------*/
6754
6755
6756         // Parses various options into properties of this object
6757         processOptions: function() {
6758                 var view = this.view;
6759                 var slotDuration = view.opt('slotDuration');
6760                 var snapDuration = view.opt('snapDuration');
6761                 var input;
6762
6763                 slotDuration = moment.duration(slotDuration);
6764                 snapDuration = snapDuration ? moment.duration(snapDuration) : slotDuration;
6765
6766                 this.slotDuration = slotDuration;
6767                 this.snapDuration = snapDuration;
6768                 this.snapsPerSlot = slotDuration / snapDuration; // TODO: ensure an integer multiple?
6769
6770                 this.minResizeDuration = snapDuration; // hack
6771
6772                 this.minTime = moment.duration(view.opt('minTime'));
6773                 this.maxTime = moment.duration(view.opt('maxTime'));
6774
6775                 // might be an array value (for TimelineView).
6776                 // if so, getting the most granular entry (the last one probably).
6777                 input = view.opt('slotLabelFormat');
6778                 if ($.isArray(input)) {
6779                         input = input[input.length - 1];
6780                 }
6781
6782                 this.labelFormat =
6783                         input ||
6784                         view.opt('smallTimeFormat'); // the computed default
6785
6786                 input = view.opt('slotLabelInterval');
6787                 this.labelInterval = input ?
6788                         moment.duration(input) :
6789                         this.computeLabelInterval(slotDuration);
6790         },
6791
6792
6793         // Computes an automatic value for slotLabelInterval
6794         computeLabelInterval: function(slotDuration) {
6795                 var i;
6796                 var labelInterval;
6797                 var slotsPerLabel;
6798
6799                 // find the smallest stock label interval that results in more than one slots-per-label
6800                 for (i = AGENDA_STOCK_SUB_DURATIONS.length - 1; i >= 0; i--) {
6801                         labelInterval = moment.duration(AGENDA_STOCK_SUB_DURATIONS[i]);
6802                         slotsPerLabel = divideDurationByDuration(labelInterval, slotDuration);
6803                         if (isInt(slotsPerLabel) && slotsPerLabel > 1) {
6804                                 return labelInterval;
6805                         }
6806                 }
6807
6808                 return moment.duration(slotDuration); // fall back. clone
6809         },
6810
6811
6812         // Computes a default event time formatting string if `timeFormat` is not explicitly defined
6813         computeEventTimeFormat: function() {
6814                 return this.view.opt('noMeridiemTimeFormat'); // like "6:30" (no AM/PM)
6815         },
6816
6817
6818         // Computes a default `displayEventEnd` value if one is not expliclty defined
6819         computeDisplayEventEnd: function() {
6820                 return true;
6821         },
6822
6823
6824         /* Hit System
6825         ------------------------------------------------------------------------------------------------------------------*/
6826
6827
6828         prepareHits: function() {
6829                 this.colCoordCache.build();
6830                 this.slatCoordCache.build();
6831         },
6832
6833
6834         releaseHits: function() {
6835                 this.colCoordCache.clear();
6836                 // NOTE: don't clear slatCoordCache because we rely on it for computeTimeTop
6837         },
6838
6839
6840         queryHit: function(leftOffset, topOffset) {
6841                 var snapsPerSlot = this.snapsPerSlot;
6842                 var colCoordCache = this.colCoordCache;
6843                 var slatCoordCache = this.slatCoordCache;
6844
6845                 if (colCoordCache.isLeftInBounds(leftOffset) && slatCoordCache.isTopInBounds(topOffset)) {
6846                         var colIndex = colCoordCache.getHorizontalIndex(leftOffset);
6847                         var slatIndex = slatCoordCache.getVerticalIndex(topOffset);
6848
6849                         if (colIndex != null && slatIndex != null) {
6850                                 var slatTop = slatCoordCache.getTopOffset(slatIndex);
6851                                 var slatHeight = slatCoordCache.getHeight(slatIndex);
6852                                 var partial = (topOffset - slatTop) / slatHeight; // floating point number between 0 and 1
6853                                 var localSnapIndex = Math.floor(partial * snapsPerSlot); // the snap # relative to start of slat
6854                                 var snapIndex = slatIndex * snapsPerSlot + localSnapIndex;
6855                                 var snapTop = slatTop + (localSnapIndex / snapsPerSlot) * slatHeight;
6856                                 var snapBottom = slatTop + ((localSnapIndex + 1) / snapsPerSlot) * slatHeight;
6857
6858                                 return {
6859                                         col: colIndex,
6860                                         snap: snapIndex,
6861                                         component: this, // needed unfortunately :(
6862                                         left: colCoordCache.getLeftOffset(colIndex),
6863                                         right: colCoordCache.getRightOffset(colIndex),
6864                                         top: snapTop,
6865                                         bottom: snapBottom
6866                                 };
6867                         }
6868                 }
6869         },
6870
6871
6872         getHitSpan: function(hit) {
6873                 var start = this.getCellDate(0, hit.col); // row=0
6874                 var time = this.computeSnapTime(hit.snap); // pass in the snap-index
6875                 var end;
6876
6877                 start.time(time);
6878                 end = start.clone().add(this.snapDuration);
6879
6880                 return { start: start, end: end };
6881         },
6882
6883
6884         getHitEl: function(hit) {
6885                 return this.colEls.eq(hit.col);
6886         },
6887
6888
6889         /* Dates
6890         ------------------------------------------------------------------------------------------------------------------*/
6891
6892
6893         rangeUpdated: function() {
6894                 this.updateDayTable();
6895         },
6896
6897
6898         // Given a row number of the grid, representing a "snap", returns a time (Duration) from its start-of-day
6899         computeSnapTime: function(snapIndex) {
6900                 return moment.duration(this.minTime + this.snapDuration * snapIndex);
6901         },
6902
6903
6904         // Slices up the given span (unzoned start/end with other misc data) into an array of segments
6905         spanToSegs: function(span) {
6906                 var segs = this.sliceRangeByTimes(span);
6907                 var i;
6908
6909                 for (i = 0; i < segs.length; i++) {
6910                         if (this.isRTL) {
6911                                 segs[i].col = this.daysPerRow - 1 - segs[i].dayIndex;
6912                         }
6913                         else {
6914                                 segs[i].col = segs[i].dayIndex;
6915                         }
6916                 }
6917
6918                 return segs;
6919         },
6920
6921
6922         sliceRangeByTimes: function(range) {
6923                 var segs = [];
6924                 var seg;
6925                 var dayIndex;
6926                 var dayDate;
6927                 var dayRange;
6928
6929                 for (dayIndex = 0; dayIndex < this.daysPerRow; dayIndex++) {
6930                         dayDate = this.dayDates[dayIndex].clone(); // TODO: better API for this?
6931                         dayRange = {
6932                                 start: dayDate.clone().time(this.minTime),
6933                                 end: dayDate.clone().time(this.maxTime)
6934                         };
6935                         seg = intersectRanges(range, dayRange); // both will be ambig timezone
6936                         if (seg) {
6937                                 seg.dayIndex = dayIndex;
6938                                 segs.push(seg);
6939                         }
6940                 }
6941
6942                 return segs;
6943         },
6944
6945
6946         /* Coordinates
6947         ------------------------------------------------------------------------------------------------------------------*/
6948
6949
6950         updateSize: function(isResize) { // NOT a standard Grid method
6951                 this.slatCoordCache.build();
6952
6953                 if (isResize) {
6954                         this.updateSegVerticals(
6955                                 [].concat(this.fgSegs || [], this.bgSegs || [], this.businessSegs || [])
6956                         );
6957                 }
6958         },
6959
6960
6961         getTotalSlatHeight: function() {
6962                 return this.slatContainerEl.outerHeight();
6963         },
6964
6965
6966         // Computes the top coordinate, relative to the bounds of the grid, of the given date.
6967         // A `startOfDayDate` must be given for avoiding ambiguity over how to treat midnight.
6968         computeDateTop: function(date, startOfDayDate) {
6969                 return this.computeTimeTop(
6970                         moment.duration(
6971                                 date - startOfDayDate.clone().stripTime()
6972                         )
6973                 );
6974         },
6975
6976
6977         // Computes the top coordinate, relative to the bounds of the grid, of the given time (a Duration).
6978         computeTimeTop: function(time) {
6979                 var len = this.slatEls.length;
6980                 var slatCoverage = (time - this.minTime) / this.slotDuration; // floating-point value of # of slots covered
6981                 var slatIndex;
6982                 var slatRemainder;
6983
6984                 // compute a floating-point number for how many slats should be progressed through.
6985                 // from 0 to number of slats (inclusive)
6986                 // constrained because minTime/maxTime might be customized.
6987                 slatCoverage = Math.max(0, slatCoverage);
6988                 slatCoverage = Math.min(len, slatCoverage);
6989
6990                 // an integer index of the furthest whole slat
6991                 // from 0 to number slats (*exclusive*, so len-1)
6992                 slatIndex = Math.floor(slatCoverage);
6993                 slatIndex = Math.min(slatIndex, len - 1);
6994
6995                 // how much further through the slatIndex slat (from 0.0-1.0) must be covered in addition.
6996                 // could be 1.0 if slatCoverage is covering *all* the slots
6997                 slatRemainder = slatCoverage - slatIndex;
6998
6999                 return this.slatCoordCache.getTopPosition(slatIndex) +
7000                         this.slatCoordCache.getHeight(slatIndex) * slatRemainder;
7001         },
7002
7003
7004
7005         /* Event Drag Visualization
7006         ------------------------------------------------------------------------------------------------------------------*/
7007
7008
7009         // Renders a visual indication of an event being dragged over the specified date(s).
7010         // A returned value of `true` signals that a mock "helper" event has been rendered.
7011         renderDrag: function(eventLocation, seg) {
7012
7013                 if (seg) { // if there is event information for this drag, render a helper event
7014
7015                         // returns mock event elements
7016                         // signal that a helper has been rendered
7017                         return this.renderEventLocationHelper(eventLocation, seg);
7018                 }
7019                 else {
7020                         // otherwise, just render a highlight
7021                         this.renderHighlight(this.eventToSpan(eventLocation));
7022                 }
7023         },
7024
7025
7026         // Unrenders any visual indication of an event being dragged
7027         unrenderDrag: function() {
7028                 this.unrenderHelper();
7029                 this.unrenderHighlight();
7030         },
7031
7032
7033         /* Event Resize Visualization
7034         ------------------------------------------------------------------------------------------------------------------*/
7035
7036
7037         // Renders a visual indication of an event being resized
7038         renderEventResize: function(eventLocation, seg) {
7039                 return this.renderEventLocationHelper(eventLocation, seg); // returns mock event elements
7040         },
7041
7042
7043         // Unrenders any visual indication of an event being resized
7044         unrenderEventResize: function() {
7045                 this.unrenderHelper();
7046         },
7047
7048
7049         /* Event Helper
7050         ------------------------------------------------------------------------------------------------------------------*/
7051
7052
7053         // Renders a mock "helper" event. `sourceSeg` is the original segment object and might be null (an external drag)
7054         renderHelper: function(event, sourceSeg) {
7055                 return this.renderHelperSegs(this.eventToSegs(event), sourceSeg); // returns mock event elements
7056         },
7057
7058
7059         // Unrenders any mock helper event
7060         unrenderHelper: function() {
7061                 this.unrenderHelperSegs();
7062         },
7063
7064
7065         /* Business Hours
7066         ------------------------------------------------------------------------------------------------------------------*/
7067
7068
7069         renderBusinessHours: function() {
7070                 this.renderBusinessSegs(
7071                         this.buildBusinessHourSegs()
7072                 );
7073         },
7074
7075
7076         unrenderBusinessHours: function() {
7077                 this.unrenderBusinessSegs();
7078         },
7079
7080
7081         /* Now Indicator
7082         ------------------------------------------------------------------------------------------------------------------*/
7083
7084
7085         getNowIndicatorUnit: function() {
7086                 return 'minute'; // will refresh on the minute
7087         },
7088
7089
7090         renderNowIndicator: function(date) {
7091                 // seg system might be overkill, but it handles scenario where line needs to be rendered
7092                 //  more than once because of columns with the same date (resources columns for example)
7093                 var segs = this.spanToSegs({ start: date, end: date });
7094                 var top = this.computeDateTop(date, date);
7095                 var nodes = [];
7096                 var i;
7097
7098                 // render lines within the columns
7099                 for (i = 0; i < segs.length; i++) {
7100                         nodes.push($('<div class="fc-now-indicator fc-now-indicator-line"></div>')
7101                                 .css('top', top)
7102                                 .appendTo(this.colContainerEls.eq(segs[i].col))[0]);
7103                 }
7104
7105                 // render an arrow over the axis
7106                 if (segs.length > 0) { // is the current time in view?
7107                         nodes.push($('<div class="fc-now-indicator fc-now-indicator-arrow"></div>')
7108                                 .css('top', top)
7109                                 .appendTo(this.el.find('.fc-content-skeleton'))[0]);
7110                 }
7111
7112                 this.nowIndicatorEls = $(nodes);
7113         },
7114
7115
7116         unrenderNowIndicator: function() {
7117                 if (this.nowIndicatorEls) {
7118                         this.nowIndicatorEls.remove();
7119                         this.nowIndicatorEls = null;
7120                 }
7121         },
7122
7123
7124         /* Selection
7125         ------------------------------------------------------------------------------------------------------------------*/
7126
7127
7128         // Renders a visual indication of a selection. Overrides the default, which was to simply render a highlight.
7129         renderSelection: function(span) {
7130                 if (this.view.opt('selectHelper')) { // this setting signals that a mock helper event should be rendered
7131
7132                         // normally acceps an eventLocation, span has a start/end, which is good enough
7133                         this.renderEventLocationHelper(span);
7134                 }
7135                 else {
7136                         this.renderHighlight(span);
7137                 }
7138         },
7139
7140
7141         // Unrenders any visual indication of a selection
7142         unrenderSelection: function() {
7143                 this.unrenderHelper();
7144                 this.unrenderHighlight();
7145         },
7146
7147
7148         /* Highlight
7149         ------------------------------------------------------------------------------------------------------------------*/
7150
7151
7152         renderHighlight: function(span) {
7153                 this.renderHighlightSegs(this.spanToSegs(span));
7154         },
7155
7156
7157         unrenderHighlight: function() {
7158                 this.unrenderHighlightSegs();
7159         }
7160
7161 });
7162
7163 ;;
7164
7165 /* Methods for rendering SEGMENTS, pieces of content that live on the view
7166  ( this file is no longer just for events )
7167 ----------------------------------------------------------------------------------------------------------------------*/
7168
7169 TimeGrid.mixin({
7170
7171         colContainerEls: null, // containers for each column
7172
7173         // inner-containers for each column where different types of segs live
7174         fgContainerEls: null,
7175         bgContainerEls: null,
7176         helperContainerEls: null,
7177         highlightContainerEls: null,
7178         businessContainerEls: null,
7179
7180         // arrays of different types of displayed segments
7181         fgSegs: null,
7182         bgSegs: null,
7183         helperSegs: null,
7184         highlightSegs: null,
7185         businessSegs: null,
7186
7187
7188         // Renders the DOM that the view's content will live in
7189         renderContentSkeleton: function() {
7190                 var cellHtml = '';
7191                 var i;
7192                 var skeletonEl;
7193
7194                 for (i = 0; i < this.colCnt; i++) {
7195                         cellHtml +=
7196                                 '<td>' +
7197                                         '<div class="fc-content-col">' +
7198                                                 '<div class="fc-event-container fc-helper-container"></div>' +
7199                                                 '<div class="fc-event-container"></div>' +
7200                                                 '<div class="fc-highlight-container"></div>' +
7201                                                 '<div class="fc-bgevent-container"></div>' +
7202                                                 '<div class="fc-business-container"></div>' +
7203                                         '</div>' +
7204                                 '</td>';
7205                 }
7206
7207                 skeletonEl = $(
7208                         '<div class="fc-content-skeleton">' +
7209                                 '<table>' +
7210                                         '<tr>' + cellHtml + '</tr>' +
7211                                 '</table>' +
7212                         '</div>'
7213                 );
7214
7215                 this.colContainerEls = skeletonEl.find('.fc-content-col');
7216                 this.helperContainerEls = skeletonEl.find('.fc-helper-container');
7217                 this.fgContainerEls = skeletonEl.find('.fc-event-container:not(.fc-helper-container)');
7218                 this.bgContainerEls = skeletonEl.find('.fc-bgevent-container');
7219                 this.highlightContainerEls = skeletonEl.find('.fc-highlight-container');
7220                 this.businessContainerEls = skeletonEl.find('.fc-business-container');
7221
7222                 this.bookendCells(skeletonEl.find('tr')); // TODO: do this on string level
7223                 this.el.append(skeletonEl);
7224         },
7225
7226
7227         /* Foreground Events
7228         ------------------------------------------------------------------------------------------------------------------*/
7229
7230
7231         renderFgSegs: function(segs) {
7232                 segs = this.renderFgSegsIntoContainers(segs, this.fgContainerEls);
7233                 this.fgSegs = segs;
7234                 return segs; // needed for Grid::renderEvents
7235         },
7236
7237
7238         unrenderFgSegs: function() {
7239                 this.unrenderNamedSegs('fgSegs');
7240         },
7241
7242
7243         /* Foreground Helper Events
7244         ------------------------------------------------------------------------------------------------------------------*/
7245
7246
7247         renderHelperSegs: function(segs, sourceSeg) {
7248                 var helperEls = [];
7249                 var i, seg;
7250                 var sourceEl;
7251
7252                 segs = this.renderFgSegsIntoContainers(segs, this.helperContainerEls);
7253
7254                 // Try to make the segment that is in the same row as sourceSeg look the same
7255                 for (i = 0; i < segs.length; i++) {
7256                         seg = segs[i];
7257                         if (sourceSeg && sourceSeg.col === seg.col) {
7258                                 sourceEl = sourceSeg.el;
7259                                 seg.el.css({
7260                                         left: sourceEl.css('left'),
7261                                         right: sourceEl.css('right'),
7262                                         'margin-left': sourceEl.css('margin-left'),
7263                                         'margin-right': sourceEl.css('margin-right')
7264                                 });
7265                         }
7266                         helperEls.push(seg.el[0]);
7267                 }
7268
7269                 this.helperSegs = segs;
7270
7271                 return $(helperEls); // must return rendered helpers
7272         },
7273
7274
7275         unrenderHelperSegs: function() {
7276                 this.unrenderNamedSegs('helperSegs');
7277         },
7278
7279
7280         /* Background Events
7281         ------------------------------------------------------------------------------------------------------------------*/
7282
7283
7284         renderBgSegs: function(segs) {
7285                 segs = this.renderFillSegEls('bgEvent', segs); // TODO: old fill system
7286                 this.updateSegVerticals(segs);
7287                 this.attachSegsByCol(this.groupSegsByCol(segs), this.bgContainerEls);
7288                 this.bgSegs = segs;
7289                 return segs; // needed for Grid::renderEvents
7290         },
7291
7292
7293         unrenderBgSegs: function() {
7294                 this.unrenderNamedSegs('bgSegs');
7295         },
7296
7297
7298         /* Highlight
7299         ------------------------------------------------------------------------------------------------------------------*/
7300
7301
7302         renderHighlightSegs: function(segs) {
7303                 segs = this.renderFillSegEls('highlight', segs); // TODO: old fill system
7304                 this.updateSegVerticals(segs);
7305                 this.attachSegsByCol(this.groupSegsByCol(segs), this.highlightContainerEls);
7306                 this.highlightSegs = segs;
7307         },
7308
7309
7310         unrenderHighlightSegs: function() {
7311                 this.unrenderNamedSegs('highlightSegs');
7312         },
7313
7314
7315         /* Business Hours
7316         ------------------------------------------------------------------------------------------------------------------*/
7317
7318
7319         renderBusinessSegs: function(segs) {
7320                 segs = this.renderFillSegEls('businessHours', segs); // TODO: old fill system
7321                 this.updateSegVerticals(segs);
7322                 this.attachSegsByCol(this.groupSegsByCol(segs), this.businessContainerEls);
7323                 this.businessSegs = segs;
7324         },
7325
7326
7327         unrenderBusinessSegs: function() {
7328                 this.unrenderNamedSegs('businessSegs');
7329         },
7330
7331
7332         /* Seg Rendering Utils
7333         ------------------------------------------------------------------------------------------------------------------*/
7334
7335
7336         // Given a flat array of segments, return an array of sub-arrays, grouped by each segment's col
7337         groupSegsByCol: function(segs) {
7338                 var segsByCol = [];
7339                 var i;
7340
7341                 for (i = 0; i < this.colCnt; i++) {
7342                         segsByCol.push([]);
7343                 }
7344
7345                 for (i = 0; i < segs.length; i++) {
7346                         segsByCol[segs[i].col].push(segs[i]);
7347                 }
7348
7349                 return segsByCol;
7350         },
7351
7352
7353         // Given segments grouped by column, insert the segments' elements into a parallel array of container
7354         // elements, each living within a column.
7355         attachSegsByCol: function(segsByCol, containerEls) {
7356                 var col;
7357                 var segs;
7358                 var i;
7359
7360                 for (col = 0; col < this.colCnt; col++) { // iterate each column grouping
7361                         segs = segsByCol[col];
7362
7363                         for (i = 0; i < segs.length; i++) {
7364                                 containerEls.eq(col).append(segs[i].el);
7365                         }
7366                 }
7367         },
7368
7369
7370         // Given the name of a property of `this` object, assumed to be an array of segments,
7371         // loops through each segment and removes from DOM. Will null-out the property afterwards.
7372         unrenderNamedSegs: function(propName) {
7373                 var segs = this[propName];
7374                 var i;
7375
7376                 if (segs) {
7377                         for (i = 0; i < segs.length; i++) {
7378                                 segs[i].el.remove();
7379                         }
7380                         this[propName] = null;
7381                 }
7382         },
7383
7384
7385
7386         /* Foreground Event Rendering Utils
7387         ------------------------------------------------------------------------------------------------------------------*/
7388
7389
7390         // Given an array of foreground segments, render a DOM element for each, computes position,
7391         // and attaches to the column inner-container elements.
7392         renderFgSegsIntoContainers: function(segs, containerEls) {
7393                 var segsByCol;
7394                 var col;
7395
7396                 segs = this.renderFgSegEls(segs); // will call fgSegHtml
7397                 segsByCol = this.groupSegsByCol(segs);
7398
7399                 for (col = 0; col < this.colCnt; col++) {
7400                         this.updateFgSegCoords(segsByCol[col]);
7401                 }
7402
7403                 this.attachSegsByCol(segsByCol, containerEls);
7404
7405                 return segs;
7406         },
7407
7408
7409         // Renders the HTML for a single event segment's default rendering
7410         fgSegHtml: function(seg, disableResizing) {
7411                 var view = this.view;
7412                 var event = seg.event;
7413                 var isDraggable = view.isEventDraggable(event);
7414                 var isResizableFromStart = !disableResizing && seg.isStart && view.isEventResizableFromStart(event);
7415                 var isResizableFromEnd = !disableResizing && seg.isEnd && view.isEventResizableFromEnd(event);
7416                 var classes = this.getSegClasses(seg, isDraggable, isResizableFromStart || isResizableFromEnd);
7417                 var skinCss = cssToStr(this.getSegSkinCss(seg));
7418                 var timeText;
7419                 var fullTimeText; // more verbose time text. for the print stylesheet
7420                 var startTimeText; // just the start time text
7421
7422                 classes.unshift('fc-time-grid-event', 'fc-v-event');
7423
7424                 if (view.isMultiDayEvent(event)) { // if the event appears to span more than one day...
7425                         // Don't display time text on segments that run entirely through a day.
7426                         // That would appear as midnight-midnight and would look dumb.
7427                         // Otherwise, display the time text for the *segment's* times (like 6pm-midnight or midnight-10am)
7428                         if (seg.isStart || seg.isEnd) {
7429                                 timeText = this.getEventTimeText(seg);
7430                                 fullTimeText = this.getEventTimeText(seg, 'LT');
7431                                 startTimeText = this.getEventTimeText(seg, null, false); // displayEnd=false
7432                         }
7433                 } else {
7434                         // Display the normal time text for the *event's* times
7435                         timeText = this.getEventTimeText(event);
7436                         fullTimeText = this.getEventTimeText(event, 'LT');
7437                         startTimeText = this.getEventTimeText(event, null, false); // displayEnd=false
7438                 }
7439
7440                 return '<a class="' + classes.join(' ') + '"' +
7441                         (event.url ?
7442                                 ' href="' + htmlEscape(event.url) + '"' :
7443                                 ''
7444                                 ) +
7445                         (skinCss ?
7446                                 ' style="' + skinCss + '"' :
7447                                 ''
7448                                 ) +
7449                         '>' +
7450                                 '<div class="fc-content">' +
7451                                         (timeText ?
7452                                                 '<div class="fc-time"' +
7453                                                 ' data-start="' + htmlEscape(startTimeText) + '"' +
7454                                                 ' data-full="' + htmlEscape(fullTimeText) + '"' +
7455                                                 '>' +
7456                                                         '<span>' + htmlEscape(timeText) + '</span>' +
7457                                                 '</div>' :
7458                                                 ''
7459                                                 ) +
7460                                         (event.title ?
7461                                                 '<div class="fc-title">' +
7462                                                         htmlEscape(event.title) +
7463                                                 '</div>' :
7464                                                 ''
7465                                                 ) +
7466                                 '</div>' +
7467                                 '<div class="fc-bg"/>' +
7468                                 /* TODO: write CSS for this
7469                                 (isResizableFromStart ?
7470                                         '<div class="fc-resizer fc-start-resizer" />' :
7471                                         ''
7472                                         ) +
7473                                 */
7474                                 (isResizableFromEnd ?
7475                                         '<div class="fc-resizer fc-end-resizer" />' :
7476                                         ''
7477                                         ) +
7478                         '</a>';
7479         },
7480
7481
7482         /* Seg Position Utils
7483         ------------------------------------------------------------------------------------------------------------------*/
7484
7485
7486         // Refreshes the CSS top/bottom coordinates for each segment element.
7487         // Works when called after initial render, after a window resize/zoom for example.
7488         updateSegVerticals: function(segs) {
7489                 this.computeSegVerticals(segs);
7490                 this.assignSegVerticals(segs);
7491         },
7492
7493
7494         // For each segment in an array, computes and assigns its top and bottom properties
7495         computeSegVerticals: function(segs) {
7496                 var i, seg;
7497
7498                 for (i = 0; i < segs.length; i++) {
7499                         seg = segs[i];
7500                         seg.top = this.computeDateTop(seg.start, seg.start);
7501                         seg.bottom = this.computeDateTop(seg.end, seg.start);
7502                 }
7503         },
7504
7505
7506         // Given segments that already have their top/bottom properties computed, applies those values to
7507         // the segments' elements.
7508         assignSegVerticals: function(segs) {
7509                 var i, seg;
7510
7511                 for (i = 0; i < segs.length; i++) {
7512                         seg = segs[i];
7513                         seg.el.css(this.generateSegVerticalCss(seg));
7514                 }
7515         },
7516
7517
7518         // Generates an object with CSS properties for the top/bottom coordinates of a segment element
7519         generateSegVerticalCss: function(seg) {
7520                 return {
7521                         top: seg.top,
7522                         bottom: -seg.bottom // flipped because needs to be space beyond bottom edge of event container
7523                 };
7524         },
7525
7526
7527         /* Foreground Event Positioning Utils
7528         ------------------------------------------------------------------------------------------------------------------*/
7529
7530
7531         // Given segments that are assumed to all live in the *same column*,
7532         // compute their verical/horizontal coordinates and assign to their elements.
7533         updateFgSegCoords: function(segs) {
7534                 this.computeSegVerticals(segs); // horizontals relies on this
7535                 this.computeFgSegHorizontals(segs); // compute horizontal coordinates, z-index's, and reorder the array
7536                 this.assignSegVerticals(segs);
7537                 this.assignFgSegHorizontals(segs);
7538         },
7539
7540
7541         // Given an array of segments that are all in the same column, sets the backwardCoord and forwardCoord on each.
7542         // NOTE: Also reorders the given array by date!
7543         computeFgSegHorizontals: function(segs) {
7544                 var levels;
7545                 var level0;
7546                 var i;
7547
7548                 this.sortEventSegs(segs); // order by certain criteria
7549                 levels = buildSlotSegLevels(segs);
7550                 computeForwardSlotSegs(levels);
7551
7552                 if ((level0 = levels[0])) {
7553
7554                         for (i = 0; i < level0.length; i++) {
7555                                 computeSlotSegPressures(level0[i]);
7556                         }
7557
7558                         for (i = 0; i < level0.length; i++) {
7559                                 this.computeFgSegForwardBack(level0[i], 0, 0);
7560                         }
7561                 }
7562         },
7563
7564
7565         // Calculate seg.forwardCoord and seg.backwardCoord for the segment, where both values range
7566         // from 0 to 1. If the calendar is left-to-right, the seg.backwardCoord maps to "left" and
7567         // seg.forwardCoord maps to "right" (via percentage). Vice-versa if the calendar is right-to-left.
7568         //
7569         // The segment might be part of a "series", which means consecutive segments with the same pressure
7570         // who's width is unknown until an edge has been hit. `seriesBackwardPressure` is the number of
7571         // segments behind this one in the current series, and `seriesBackwardCoord` is the starting
7572         // coordinate of the first segment in the series.
7573         computeFgSegForwardBack: function(seg, seriesBackwardPressure, seriesBackwardCoord) {
7574                 var forwardSegs = seg.forwardSegs;
7575                 var i;
7576
7577                 if (seg.forwardCoord === undefined) { // not already computed
7578
7579                         if (!forwardSegs.length) {
7580
7581                                 // if there are no forward segments, this segment should butt up against the edge
7582                                 seg.forwardCoord = 1;
7583                         }
7584                         else {
7585
7586                                 // sort highest pressure first
7587                                 this.sortForwardSegs(forwardSegs);
7588
7589                                 // this segment's forwardCoord will be calculated from the backwardCoord of the
7590                                 // highest-pressure forward segment.
7591                                 this.computeFgSegForwardBack(forwardSegs[0], seriesBackwardPressure + 1, seriesBackwardCoord);
7592                                 seg.forwardCoord = forwardSegs[0].backwardCoord;
7593                         }
7594
7595                         // calculate the backwardCoord from the forwardCoord. consider the series
7596                         seg.backwardCoord = seg.forwardCoord -
7597                                 (seg.forwardCoord - seriesBackwardCoord) / // available width for series
7598                                 (seriesBackwardPressure + 1); // # of segments in the series
7599
7600                         // use this segment's coordinates to computed the coordinates of the less-pressurized
7601                         // forward segments
7602                         for (i=0; i<forwardSegs.length; i++) {
7603                                 this.computeFgSegForwardBack(forwardSegs[i], 0, seg.forwardCoord);
7604                         }
7605                 }
7606         },
7607
7608
7609         sortForwardSegs: function(forwardSegs) {
7610                 forwardSegs.sort(proxy(this, 'compareForwardSegs'));
7611         },
7612
7613
7614         // A cmp function for determining which forward segment to rely on more when computing coordinates.
7615         compareForwardSegs: function(seg1, seg2) {
7616                 // put higher-pressure first
7617                 return seg2.forwardPressure - seg1.forwardPressure ||
7618                         // put segments that are closer to initial edge first (and favor ones with no coords yet)
7619                         (seg1.backwardCoord || 0) - (seg2.backwardCoord || 0) ||
7620                         // do normal sorting...
7621                         this.compareEventSegs(seg1, seg2);
7622         },
7623
7624
7625         // Given foreground event segments that have already had their position coordinates computed,
7626         // assigns position-related CSS values to their elements.
7627         assignFgSegHorizontals: function(segs) {
7628                 var i, seg;
7629
7630                 for (i = 0; i < segs.length; i++) {
7631                         seg = segs[i];
7632                         seg.el.css(this.generateFgSegHorizontalCss(seg));
7633
7634                         // if the height is short, add a className for alternate styling
7635                         if (seg.bottom - seg.top < 30) {
7636                                 seg.el.addClass('fc-short');
7637                         }
7638                 }
7639         },
7640
7641
7642         // Generates an object with CSS properties/values that should be applied to an event segment element.
7643         // Contains important positioning-related properties that should be applied to any event element, customized or not.
7644         generateFgSegHorizontalCss: function(seg) {
7645                 var shouldOverlap = this.view.opt('slotEventOverlap');
7646                 var backwardCoord = seg.backwardCoord; // the left side if LTR. the right side if RTL. floating-point
7647                 var forwardCoord = seg.forwardCoord; // the right side if LTR. the left side if RTL. floating-point
7648                 var props = this.generateSegVerticalCss(seg); // get top/bottom first
7649                 var left; // amount of space from left edge, a fraction of the total width
7650                 var right; // amount of space from right edge, a fraction of the total width
7651
7652                 if (shouldOverlap) {
7653                         // double the width, but don't go beyond the maximum forward coordinate (1.0)
7654                         forwardCoord = Math.min(1, backwardCoord + (forwardCoord - backwardCoord) * 2);
7655                 }
7656
7657                 if (this.isRTL) {
7658                         left = 1 - forwardCoord;
7659                         right = backwardCoord;
7660                 }
7661                 else {
7662                         left = backwardCoord;
7663                         right = 1 - forwardCoord;
7664                 }
7665
7666                 props.zIndex = seg.level + 1; // convert from 0-base to 1-based
7667                 props.left = left * 100 + '%';
7668                 props.right = right * 100 + '%';
7669
7670                 if (shouldOverlap && seg.forwardPressure) {
7671                         // add padding to the edge so that forward stacked events don't cover the resizer's icon
7672                         props[this.isRTL ? 'marginLeft' : 'marginRight'] = 10 * 2; // 10 is a guesstimate of the icon's width
7673                 }
7674
7675                 return props;
7676         }
7677
7678 });
7679
7680
7681 // Builds an array of segments "levels". The first level will be the leftmost tier of segments if the calendar is
7682 // left-to-right, or the rightmost if the calendar is right-to-left. Assumes the segments are already ordered by date.
7683 function buildSlotSegLevels(segs) {
7684         var levels = [];
7685         var i, seg;
7686         var j;
7687
7688         for (i=0; i<segs.length; i++) {
7689                 seg = segs[i];
7690
7691                 // go through all the levels and stop on the first level where there are no collisions
7692                 for (j=0; j<levels.length; j++) {
7693                         if (!computeSlotSegCollisions(seg, levels[j]).length) {
7694                                 break;
7695                         }
7696                 }
7697
7698                 seg.level = j;
7699
7700                 (levels[j] || (levels[j] = [])).push(seg);
7701         }
7702
7703         return levels;
7704 }
7705
7706
7707 // For every segment, figure out the other segments that are in subsequent
7708 // levels that also occupy the same vertical space. Accumulate in seg.forwardSegs
7709 function computeForwardSlotSegs(levels) {
7710         var i, level;
7711         var j, seg;
7712         var k;
7713
7714         for (i=0; i<levels.length; i++) {
7715                 level = levels[i];
7716
7717                 for (j=0; j<level.length; j++) {
7718                         seg = level[j];
7719
7720                         seg.forwardSegs = [];
7721                         for (k=i+1; k<levels.length; k++) {
7722                                 computeSlotSegCollisions(seg, levels[k], seg.forwardSegs);
7723                         }
7724                 }
7725         }
7726 }
7727
7728
7729 // Figure out which path forward (via seg.forwardSegs) results in the longest path until
7730 // the furthest edge is reached. The number of segments in this path will be seg.forwardPressure
7731 function computeSlotSegPressures(seg) {
7732         var forwardSegs = seg.forwardSegs;
7733         var forwardPressure = 0;
7734         var i, forwardSeg;
7735
7736         if (seg.forwardPressure === undefined) { // not already computed
7737
7738                 for (i=0; i<forwardSegs.length; i++) {
7739                         forwardSeg = forwardSegs[i];
7740
7741                         // figure out the child's maximum forward path
7742                         computeSlotSegPressures(forwardSeg);
7743
7744                         // either use the existing maximum, or use the child's forward pressure
7745                         // plus one (for the forwardSeg itself)
7746                         forwardPressure = Math.max(
7747                                 forwardPressure,
7748                                 1 + forwardSeg.forwardPressure
7749                         );
7750                 }
7751
7752                 seg.forwardPressure = forwardPressure;
7753         }
7754 }
7755
7756
7757 // Find all the segments in `otherSegs` that vertically collide with `seg`.
7758 // Append into an optionally-supplied `results` array and return.
7759 function computeSlotSegCollisions(seg, otherSegs, results) {
7760         results = results || [];
7761
7762         for (var i=0; i<otherSegs.length; i++) {
7763                 if (isSlotSegCollision(seg, otherSegs[i])) {
7764                         results.push(otherSegs[i]);
7765                 }
7766         }
7767
7768         return results;
7769 }
7770
7771
7772 // Do these segments occupy the same vertical space?
7773 function isSlotSegCollision(seg1, seg2) {
7774         return seg1.bottom > seg2.top && seg1.top < seg2.bottom;
7775 }
7776
7777 ;;
7778
7779 /* An abstract class from which other views inherit from
7780 ----------------------------------------------------------------------------------------------------------------------*/
7781
7782 var View = FC.View = Class.extend(EmitterMixin, ListenerMixin, {
7783
7784         type: null, // subclass' view name (string)
7785         name: null, // deprecated. use `type` instead
7786         title: null, // the text that will be displayed in the header's title
7787
7788         calendar: null, // owner Calendar object
7789         options: null, // hash containing all options. already merged with view-specific-options
7790         el: null, // the view's containing element. set by Calendar
7791
7792         displaying: null, // a promise representing the state of rendering. null if no render requested
7793         isSkeletonRendered: false,
7794         isEventsRendered: false,
7795
7796         // range the view is actually displaying (moments)
7797         start: null,
7798         end: null, // exclusive
7799
7800         // range the view is formally responsible for (moments)
7801         // may be different from start/end. for example, a month view might have 1st-31st, excluding padded dates
7802         intervalStart: null,
7803         intervalEnd: null, // exclusive
7804         intervalDuration: null,
7805         intervalUnit: null, // name of largest unit being displayed, like "month" or "week"
7806
7807         isRTL: false,
7808         isSelected: false, // boolean whether a range of time is user-selected or not
7809         selectedEvent: null,
7810
7811         eventOrderSpecs: null, // criteria for ordering events when they have same date/time
7812
7813         // classNames styled by jqui themes
7814         widgetHeaderClass: null,
7815         widgetContentClass: null,
7816         highlightStateClass: null,
7817
7818         // for date utils, computed from options
7819         nextDayThreshold: null,
7820         isHiddenDayHash: null,
7821
7822         // now indicator
7823         isNowIndicatorRendered: null,
7824         initialNowDate: null, // result first getNow call
7825         initialNowQueriedMs: null, // ms time the getNow was called
7826         nowIndicatorTimeoutID: null, // for refresh timing of now indicator
7827         nowIndicatorIntervalID: null, // "
7828
7829
7830         constructor: function(calendar, type, options, intervalDuration) {
7831
7832                 this.calendar = calendar;
7833                 this.type = this.name = type; // .name is deprecated
7834                 this.options = options;
7835                 this.intervalDuration = intervalDuration || moment.duration(1, 'day');
7836
7837                 this.nextDayThreshold = moment.duration(this.opt('nextDayThreshold'));
7838                 this.initThemingProps();
7839                 this.initHiddenDays();
7840                 this.isRTL = this.opt('isRTL');
7841
7842                 this.eventOrderSpecs = parseFieldSpecs(this.opt('eventOrder'));
7843
7844                 this.initialize();
7845         },
7846
7847
7848         // A good place for subclasses to initialize member variables
7849         initialize: function() {
7850                 // subclasses can implement
7851         },
7852
7853
7854         // Retrieves an option with the given name
7855         opt: function(name) {
7856                 return this.options[name];
7857         },
7858
7859
7860         // Triggers handlers that are view-related. Modifies args before passing to calendar.
7861         trigger: function(name, thisObj) { // arguments beyond thisObj are passed along
7862                 var calendar = this.calendar;
7863
7864                 return calendar.trigger.apply(
7865                         calendar,
7866                         [name, thisObj || this].concat(
7867                                 Array.prototype.slice.call(arguments, 2), // arguments beyond thisObj
7868                                 [ this ] // always make the last argument a reference to the view. TODO: deprecate
7869                         )
7870                 );
7871         },
7872
7873
7874         /* Dates
7875         ------------------------------------------------------------------------------------------------------------------*/
7876
7877
7878         // Updates all internal dates to center around the given current unzoned date.
7879         setDate: function(date) {
7880                 this.setRange(this.computeRange(date));
7881         },
7882
7883
7884         // Updates all internal dates for displaying the given unzoned range.
7885         setRange: function(range) {
7886                 $.extend(this, range); // assigns every property to this object's member variables
7887                 this.updateTitle();
7888         },
7889
7890
7891         // Given a single current unzoned date, produce information about what range to display.
7892         // Subclasses can override. Must return all properties.
7893         computeRange: function(date) {
7894                 var intervalUnit = computeIntervalUnit(this.intervalDuration);
7895                 var intervalStart = date.clone().startOf(intervalUnit);
7896                 var intervalEnd = intervalStart.clone().add(this.intervalDuration);
7897                 var start, end;
7898
7899                 // normalize the range's time-ambiguity
7900                 if (/year|month|week|day/.test(intervalUnit)) { // whole-days?
7901                         intervalStart.stripTime();
7902                         intervalEnd.stripTime();
7903                 }
7904                 else { // needs to have a time?
7905                         if (!intervalStart.hasTime()) {
7906                                 intervalStart = this.calendar.time(0); // give 00:00 time
7907                         }
7908                         if (!intervalEnd.hasTime()) {
7909                                 intervalEnd = this.calendar.time(0); // give 00:00 time
7910                         }
7911                 }
7912
7913                 start = intervalStart.clone();
7914                 start = this.skipHiddenDays(start);
7915                 end = intervalEnd.clone();
7916                 end = this.skipHiddenDays(end, -1, true); // exclusively move backwards
7917
7918                 return {
7919                         intervalUnit: intervalUnit,
7920                         intervalStart: intervalStart,
7921                         intervalEnd: intervalEnd,
7922                         start: start,
7923                         end: end
7924                 };
7925         },
7926
7927
7928         // Computes the new date when the user hits the prev button, given the current date
7929         computePrevDate: function(date) {
7930                 return this.massageCurrentDate(
7931                         date.clone().startOf(this.intervalUnit).subtract(this.intervalDuration), -1
7932                 );
7933         },
7934
7935
7936         // Computes the new date when the user hits the next button, given the current date
7937         computeNextDate: function(date) {
7938                 return this.massageCurrentDate(
7939                         date.clone().startOf(this.intervalUnit).add(this.intervalDuration)
7940                 );
7941         },
7942
7943
7944         // Given an arbitrarily calculated current date of the calendar, returns a date that is ensured to be completely
7945         // visible. `direction` is optional and indicates which direction the current date was being
7946         // incremented or decremented (1 or -1).
7947         massageCurrentDate: function(date, direction) {
7948                 if (this.intervalDuration.as('days') <= 1) { // if the view displays a single day or smaller
7949                         if (this.isHiddenDay(date)) {
7950                                 date = this.skipHiddenDays(date, direction);
7951                                 date.startOf('day');
7952                         }
7953                 }
7954
7955                 return date;
7956         },
7957
7958
7959         /* Title and Date Formatting
7960         ------------------------------------------------------------------------------------------------------------------*/
7961
7962
7963         // Sets the view's title property to the most updated computed value
7964         updateTitle: function() {
7965                 this.title = this.computeTitle();
7966         },
7967
7968
7969         // Computes what the title at the top of the calendar should be for this view
7970         computeTitle: function() {
7971                 return this.formatRange(
7972                         {
7973                                 // in case intervalStart/End has a time, make sure timezone is correct
7974                                 start: this.calendar.applyTimezone(this.intervalStart),
7975                                 end: this.calendar.applyTimezone(this.intervalEnd)
7976                         },
7977                         this.opt('titleFormat') || this.computeTitleFormat(),
7978                         this.opt('titleRangeSeparator')
7979                 );
7980         },
7981
7982
7983         // Generates the format string that should be used to generate the title for the current date range.
7984         // Attempts to compute the most appropriate format if not explicitly specified with `titleFormat`.
7985         computeTitleFormat: function() {
7986                 if (this.intervalUnit == 'year') {
7987                         return 'YYYY';
7988                 }
7989                 else if (this.intervalUnit == 'month') {
7990                         return this.opt('monthYearFormat'); // like "September 2014"
7991                 }
7992                 else if (this.intervalDuration.as('days') > 1) {
7993                         return 'll'; // multi-day range. shorter, like "Sep 9 - 10 2014"
7994                 }
7995                 else {
7996                         return 'LL'; // one day. longer, like "September 9 2014"
7997                 }
7998         },
7999
8000
8001         // Utility for formatting a range. Accepts a range object, formatting string, and optional separator.
8002         // Displays all-day ranges naturally, with an inclusive end. Takes the current isRTL into account.
8003         // The timezones of the dates within `range` will be respected.
8004         formatRange: function(range, formatStr, separator) {
8005                 var end = range.end;
8006
8007                 if (!end.hasTime()) { // all-day?
8008                         end = end.clone().subtract(1); // convert to inclusive. last ms of previous day
8009                 }
8010
8011                 return formatRange(range.start, end, formatStr, separator, this.opt('isRTL'));
8012         },
8013
8014
8015         getAllDayHtml: function() {
8016                 return this.opt('allDayHtml') || htmlEscape(this.opt('allDayText'));
8017         },
8018
8019
8020         /* Navigation
8021         ------------------------------------------------------------------------------------------------------------------*/
8022
8023
8024         // Generates HTML for an anchor to another view into the calendar.
8025         // Will either generate an <a> tag or a non-clickable <span> tag, depending on enabled settings.
8026         // `gotoOptions` can either be a moment input, or an object with the form:
8027         // { date, type, forceOff }
8028         // `type` is a view-type like "day" or "week". default value is "day".
8029         // `attrs` and `innerHtml` are use to generate the rest of the HTML tag.
8030         buildGotoAnchorHtml: function(gotoOptions, attrs, innerHtml) {
8031                 var date, type, forceOff;
8032                 var finalOptions;
8033
8034                 if ($.isPlainObject(gotoOptions)) {
8035                         date = gotoOptions.date;
8036                         type = gotoOptions.type;
8037                         forceOff = gotoOptions.forceOff;
8038                 }
8039                 else {
8040                         date = gotoOptions; // a single moment input
8041                 }
8042                 date = FC.moment(date); // if a string, parse it
8043
8044                 finalOptions = { // for serialization into the link
8045                         date: date.format('YYYY-MM-DD'),
8046                         type: type || 'day'
8047                 };
8048
8049                 if (typeof attrs === 'string') {
8050                         innerHtml = attrs;
8051                         attrs = null;
8052                 }
8053
8054                 attrs = attrs ? ' ' + attrsToStr(attrs) : ''; // will have a leading space
8055                 innerHtml = innerHtml || '';
8056
8057                 if (!forceOff && this.opt('navLinks')) {
8058                         return '<a' + attrs +
8059                                 ' data-goto="' + htmlEscape(JSON.stringify(finalOptions)) + '">' +
8060                                 innerHtml +
8061                                 '</a>';
8062                 }
8063                 else {
8064                         return '<span' + attrs + '>' +
8065                                 innerHtml +
8066                                 '</span>';
8067                 }
8068         },
8069
8070
8071         /* Rendering
8072         ------------------------------------------------------------------------------------------------------------------*/
8073
8074
8075         // Sets the container element that the view should render inside of.
8076         // Does other DOM-related initializations.
8077         setElement: function(el) {
8078                 this.el = el;
8079                 this.bindGlobalHandlers();
8080         },
8081
8082
8083         // Removes the view's container element from the DOM, clearing any content beforehand.
8084         // Undoes any other DOM-related attachments.
8085         removeElement: function() {
8086                 this.clear(); // clears all content
8087
8088                 // clean up the skeleton
8089                 if (this.isSkeletonRendered) {
8090                         this.unrenderSkeleton();
8091                         this.isSkeletonRendered = false;
8092                 }
8093
8094                 this.unbindGlobalHandlers();
8095
8096                 this.el.remove();
8097
8098                 // NOTE: don't null-out this.el in case the View was destroyed within an API callback.
8099                 // We don't null-out the View's other jQuery element references upon destroy,
8100                 //  so we shouldn't kill this.el either.
8101         },
8102
8103
8104         // Does everything necessary to display the view centered around the given unzoned date.
8105         // Does every type of rendering EXCEPT rendering events.
8106         // Is asychronous and returns a promise.
8107         display: function(date, explicitScrollState) {
8108                 var _this = this;
8109                 var prevScrollState = null;
8110
8111                 if (explicitScrollState != null && this.displaying) { // don't need prevScrollState if explicitScrollState
8112                         prevScrollState = this.queryScroll();
8113                 }
8114
8115                 this.calendar.freezeContentHeight();
8116
8117                 return syncThen(this.clear(), function() { // clear the content first
8118                         return (
8119                                 _this.displaying =
8120                                         syncThen(_this.displayView(date), function() { // displayView might return a promise
8121
8122                                                 // caller of display() wants a specific scroll state?
8123                                                 if (explicitScrollState != null) {
8124                                                         // we make an assumption that this is NOT the initial render,
8125                                                         // and thus don't need forceScroll (is inconveniently asynchronous)
8126                                                         _this.setScroll(explicitScrollState);
8127                                                 }
8128                                                 else {
8129                                                         _this.forceScroll(_this.computeInitialScroll(prevScrollState));
8130                                                 }
8131
8132                                                 _this.calendar.unfreezeContentHeight();
8133                                                 _this.triggerRender();
8134                                         })
8135                         );
8136                 });
8137         },
8138
8139
8140         // Does everything necessary to clear the content of the view.
8141         // Clears dates and events. Does not clear the skeleton.
8142         // Is asychronous and returns a promise.
8143         clear: function() {
8144                 var _this = this;
8145                 var displaying = this.displaying;
8146
8147                 if (displaying) { // previously displayed, or in the process of being displayed?
8148                         return syncThen(displaying, function() { // wait for the display to finish
8149                                 _this.displaying = null;
8150                                 _this.clearEvents();
8151                                 return _this.clearView(); // might return a promise. chain it
8152                         });
8153                 }
8154                 else {
8155                         return $.when(); // an immediately-resolved promise
8156                 }
8157         },
8158
8159
8160         // Displays the view's non-event content, such as date-related content or anything required by events.
8161         // Renders the view's non-content skeleton if necessary.
8162         // Can be asynchronous and return a promise.
8163         displayView: function(date) {
8164                 if (!this.isSkeletonRendered) {
8165                         this.renderSkeleton();
8166                         this.isSkeletonRendered = true;
8167                 }
8168                 if (date) {
8169                         this.setDate(date);
8170                 }
8171                 if (this.render) {
8172                         this.render(); // TODO: deprecate
8173                 }
8174                 this.renderDates();
8175                 this.updateSize();
8176                 this.renderBusinessHours(); // might need coordinates, so should go after updateSize()
8177                 this.startNowIndicator();
8178         },
8179
8180
8181         // Unrenders the view content that was rendered in displayView.
8182         // Can be asynchronous and return a promise.
8183         clearView: function() {
8184                 this.unselect();
8185                 this.stopNowIndicator();
8186                 this.triggerUnrender();
8187                 this.unrenderBusinessHours();
8188                 this.unrenderDates();
8189                 if (this.destroy) {
8190                         this.destroy(); // TODO: deprecate
8191                 }
8192         },
8193
8194
8195         // Renders the basic structure of the view before any content is rendered
8196         renderSkeleton: function() {
8197                 // subclasses should implement
8198         },
8199
8200
8201         // Unrenders the basic structure of the view
8202         unrenderSkeleton: function() {
8203                 // subclasses should implement
8204         },
8205
8206
8207         // Renders the view's date-related content.
8208         // Assumes setRange has already been called and the skeleton has already been rendered.
8209         renderDates: function() {
8210                 // subclasses should implement
8211         },
8212
8213
8214         // Unrenders the view's date-related content
8215         unrenderDates: function() {
8216                 // subclasses should override
8217         },
8218
8219
8220         // Signals that the view's content has been rendered
8221         triggerRender: function() {
8222                 this.trigger('viewRender', this, this, this.el);
8223         },
8224
8225
8226         // Signals that the view's content is about to be unrendered
8227         triggerUnrender: function() {
8228                 this.trigger('viewDestroy', this, this, this.el);
8229         },
8230
8231
8232         // Binds DOM handlers to elements that reside outside the view container, such as the document
8233         bindGlobalHandlers: function() {
8234                 this.listenTo($(document), 'mousedown', this.handleDocumentMousedown);
8235                 this.listenTo($(document), 'touchstart', this.processUnselect);
8236         },
8237
8238
8239         // Unbinds DOM handlers from elements that reside outside the view container
8240         unbindGlobalHandlers: function() {
8241                 this.stopListeningTo($(document));
8242         },
8243
8244
8245         // Initializes internal variables related to theming
8246         initThemingProps: function() {
8247                 var tm = this.opt('theme') ? 'ui' : 'fc';
8248
8249                 this.widgetHeaderClass = tm + '-widget-header';
8250                 this.widgetContentClass = tm + '-widget-content';
8251                 this.highlightStateClass = tm + '-state-highlight';
8252         },
8253
8254
8255         /* Business Hours
8256         ------------------------------------------------------------------------------------------------------------------*/
8257
8258
8259         // Renders business-hours onto the view. Assumes updateSize has already been called.
8260         renderBusinessHours: function() {
8261                 // subclasses should implement
8262         },
8263
8264
8265         // Unrenders previously-rendered business-hours
8266         unrenderBusinessHours: function() {
8267                 // subclasses should implement
8268         },
8269
8270
8271         /* Now Indicator
8272         ------------------------------------------------------------------------------------------------------------------*/
8273
8274
8275         // Immediately render the current time indicator and begins re-rendering it at an interval,
8276         // which is defined by this.getNowIndicatorUnit().
8277         // TODO: somehow do this for the current whole day's background too
8278         startNowIndicator: function() {
8279                 var _this = this;
8280                 var unit;
8281                 var update;
8282                 var delay; // ms wait value
8283
8284                 if (this.opt('nowIndicator')) {
8285                         unit = this.getNowIndicatorUnit();
8286                         if (unit) {
8287                                 update = proxy(this, 'updateNowIndicator'); // bind to `this`
8288
8289                                 this.initialNowDate = this.calendar.getNow();
8290                                 this.initialNowQueriedMs = +new Date();
8291                                 this.renderNowIndicator(this.initialNowDate);
8292                                 this.isNowIndicatorRendered = true;
8293
8294                                 // wait until the beginning of the next interval
8295                                 delay = this.initialNowDate.clone().startOf(unit).add(1, unit) - this.initialNowDate;
8296                                 this.nowIndicatorTimeoutID = setTimeout(function() {
8297                                         _this.nowIndicatorTimeoutID = null;
8298                                         update();
8299                                         delay = +moment.duration(1, unit);
8300                                         delay = Math.max(100, delay); // prevent too frequent
8301                                         _this.nowIndicatorIntervalID = setInterval(update, delay); // update every interval
8302                                 }, delay);
8303                         }
8304                 }
8305         },
8306
8307
8308         // rerenders the now indicator, computing the new current time from the amount of time that has passed
8309         // since the initial getNow call.
8310         updateNowIndicator: function() {
8311                 if (this.isNowIndicatorRendered) {
8312                         this.unrenderNowIndicator();
8313                         this.renderNowIndicator(
8314                                 this.initialNowDate.clone().add(new Date() - this.initialNowQueriedMs) // add ms
8315                         );
8316                 }
8317         },
8318
8319
8320         // Immediately unrenders the view's current time indicator and stops any re-rendering timers.
8321         // Won't cause side effects if indicator isn't rendered.
8322         stopNowIndicator: function() {
8323                 if (this.isNowIndicatorRendered) {
8324
8325                         if (this.nowIndicatorTimeoutID) {
8326                                 clearTimeout(this.nowIndicatorTimeoutID);
8327                                 this.nowIndicatorTimeoutID = null;
8328                         }
8329                         if (this.nowIndicatorIntervalID) {
8330                                 clearTimeout(this.nowIndicatorIntervalID);
8331                                 this.nowIndicatorIntervalID = null;
8332                         }
8333
8334                         this.unrenderNowIndicator();
8335                         this.isNowIndicatorRendered = false;
8336                 }
8337         },
8338
8339
8340         // Returns a string unit, like 'second' or 'minute' that defined how often the current time indicator
8341         // should be refreshed. If something falsy is returned, no time indicator is rendered at all.
8342         getNowIndicatorUnit: function() {
8343                 // subclasses should implement
8344         },
8345
8346
8347         // Renders a current time indicator at the given datetime
8348         renderNowIndicator: function(date) {
8349                 // subclasses should implement
8350         },
8351
8352
8353         // Undoes the rendering actions from renderNowIndicator
8354         unrenderNowIndicator: function() {
8355                 // subclasses should implement
8356         },
8357
8358
8359         /* Dimensions
8360         ------------------------------------------------------------------------------------------------------------------*/
8361
8362
8363         // Refreshes anything dependant upon sizing of the container element of the grid
8364         updateSize: function(isResize) {
8365                 var scrollState;
8366
8367                 if (isResize) {
8368                         scrollState = this.queryScroll();
8369                 }
8370
8371                 this.updateHeight(isResize);
8372                 this.updateWidth(isResize);
8373                 this.updateNowIndicator();
8374
8375                 if (isResize) {
8376                         this.setScroll(scrollState);
8377                 }
8378         },
8379
8380
8381         // Refreshes the horizontal dimensions of the calendar
8382         updateWidth: function(isResize) {
8383                 // subclasses should implement
8384         },
8385
8386
8387         // Refreshes the vertical dimensions of the calendar
8388         updateHeight: function(isResize) {
8389                 var calendar = this.calendar; // we poll the calendar for height information
8390
8391                 this.setHeight(
8392                         calendar.getSuggestedViewHeight(),
8393                         calendar.isHeightAuto()
8394                 );
8395         },
8396
8397
8398         // Updates the vertical dimensions of the calendar to the specified height.
8399         // if `isAuto` is set to true, height becomes merely a suggestion and the view should use its "natural" height.
8400         setHeight: function(height, isAuto) {
8401                 // subclasses should implement
8402         },
8403
8404
8405         /* Scroller
8406         ------------------------------------------------------------------------------------------------------------------*/
8407
8408
8409         // Computes the initial pre-configured scroll state prior to allowing the user to change it.
8410         // Given the scroll state from the previous rendering. If first time rendering, given null.
8411         computeInitialScroll: function(previousScrollState) {
8412                 return 0;
8413         },
8414
8415
8416         // Retrieves the view's current natural scroll state. Can return an arbitrary format.
8417         queryScroll: function() {
8418                 // subclasses must implement
8419         },
8420
8421
8422         // Sets the view's scroll state. Will accept the same format computeInitialScroll and queryScroll produce.
8423         setScroll: function(scrollState) {
8424                 // subclasses must implement
8425         },
8426
8427
8428         // Sets the scroll state, making sure to overcome any predefined scroll value the browser has in mind
8429         forceScroll: function(scrollState) {
8430                 var _this = this;
8431
8432                 this.setScroll(scrollState);
8433                 setTimeout(function() {
8434                         _this.setScroll(scrollState);
8435                 }, 0);
8436         },
8437
8438
8439         /* Event Elements / Segments
8440         ------------------------------------------------------------------------------------------------------------------*/
8441
8442
8443         // Does everything necessary to display the given events onto the current view
8444         displayEvents: function(events) {
8445                 var scrollState = this.queryScroll();
8446
8447                 this.clearEvents();
8448                 this.renderEvents(events);
8449                 this.isEventsRendered = true;
8450                 this.setScroll(scrollState);
8451                 this.triggerEventRender();
8452         },
8453
8454
8455         // Does everything necessary to clear the view's currently-rendered events
8456         clearEvents: function() {
8457                 var scrollState;
8458
8459                 if (this.isEventsRendered) {
8460
8461                         // TODO: optimize: if we know this is part of a displayEvents call, don't queryScroll/setScroll
8462                         scrollState = this.queryScroll();
8463
8464                         this.triggerEventUnrender();
8465                         if (this.destroyEvents) {
8466                                 this.destroyEvents(); // TODO: deprecate
8467                         }
8468                         this.unrenderEvents();
8469                         this.setScroll(scrollState);
8470                         this.isEventsRendered = false;
8471                 }
8472         },
8473
8474
8475         // Renders the events onto the view.
8476         renderEvents: function(events) {
8477                 // subclasses should implement
8478         },
8479
8480
8481         // Removes event elements from the view.
8482         unrenderEvents: function() {
8483                 // subclasses should implement
8484         },
8485
8486
8487         // Signals that all events have been rendered
8488         triggerEventRender: function() {
8489                 this.renderedEventSegEach(function(seg) {
8490                         this.trigger('eventAfterRender', seg.event, seg.event, seg.el);
8491                 });
8492                 this.trigger('eventAfterAllRender');
8493         },
8494
8495
8496         // Signals that all event elements are about to be removed
8497         triggerEventUnrender: function() {
8498                 this.renderedEventSegEach(function(seg) {
8499                         this.trigger('eventDestroy', seg.event, seg.event, seg.el);
8500                 });
8501         },
8502
8503
8504         // Given an event and the default element used for rendering, returns the element that should actually be used.
8505         // Basically runs events and elements through the eventRender hook.
8506         resolveEventEl: function(event, el) {
8507                 var custom = this.trigger('eventRender', event, event, el);
8508
8509                 if (custom === false) { // means don't render at all
8510                         el = null;
8511                 }
8512                 else if (custom && custom !== true) {
8513                         el = $(custom);
8514                 }
8515
8516                 return el;
8517         },
8518
8519
8520         // Hides all rendered event segments linked to the given event
8521         showEvent: function(event) {
8522                 this.renderedEventSegEach(function(seg) {
8523                         seg.el.css('visibility', '');
8524                 }, event);
8525         },
8526
8527
8528         // Shows all rendered event segments linked to the given event
8529         hideEvent: function(event) {
8530                 this.renderedEventSegEach(function(seg) {
8531                         seg.el.css('visibility', 'hidden');
8532                 }, event);
8533         },
8534
8535
8536         // Iterates through event segments that have been rendered (have an el). Goes through all by default.
8537         // If the optional `event` argument is specified, only iterates through segments linked to that event.
8538         // The `this` value of the callback function will be the view.
8539         renderedEventSegEach: function(func, event) {
8540                 var segs = this.getEventSegs();
8541                 var i;
8542
8543                 for (i = 0; i < segs.length; i++) {
8544                         if (!event || segs[i].event._id === event._id) {
8545                                 if (segs[i].el) {
8546                                         func.call(this, segs[i]);
8547                                 }
8548                         }
8549                 }
8550         },
8551
8552
8553         // Retrieves all the rendered segment objects for the view
8554         getEventSegs: function() {
8555                 // subclasses must implement
8556                 return [];
8557         },
8558
8559
8560         /* Event Drag-n-Drop
8561         ------------------------------------------------------------------------------------------------------------------*/
8562
8563
8564         // Computes if the given event is allowed to be dragged by the user
8565         isEventDraggable: function(event) {
8566                 return this.isEventStartEditable(event);
8567         },
8568
8569
8570         isEventStartEditable: function(event) {
8571                 return firstDefined(
8572                         event.startEditable,
8573                         (event.source || {}).startEditable,
8574                         this.opt('eventStartEditable'),
8575                         this.isEventGenerallyEditable(event)
8576                 );
8577         },
8578
8579
8580         isEventGenerallyEditable: function(event) {
8581                 return firstDefined(
8582                         event.editable,
8583                         (event.source || {}).editable,
8584                         this.opt('editable')
8585                 );
8586         },
8587
8588
8589         // Must be called when an event in the view is dropped onto new location.
8590         // `dropLocation` is an object that contains the new zoned start/end/allDay values for the event.
8591         reportEventDrop: function(event, dropLocation, largeUnit, el, ev) {
8592                 var calendar = this.calendar;
8593                 var mutateResult = calendar.mutateEvent(event, dropLocation, largeUnit);
8594                 var undoFunc = function() {
8595                         mutateResult.undo();
8596                         calendar.reportEventChange();
8597                 };
8598
8599                 this.triggerEventDrop(event, mutateResult.dateDelta, undoFunc, el, ev);
8600                 calendar.reportEventChange(); // will rerender events
8601         },
8602
8603
8604         // Triggers event-drop handlers that have subscribed via the API
8605         triggerEventDrop: function(event, dateDelta, undoFunc, el, ev) {
8606                 this.trigger('eventDrop', el[0], event, dateDelta, undoFunc, ev, {}); // {} = jqui dummy
8607         },
8608
8609
8610         /* External Element Drag-n-Drop
8611         ------------------------------------------------------------------------------------------------------------------*/
8612
8613
8614         // Must be called when an external element, via jQuery UI, has been dropped onto the calendar.
8615         // `meta` is the parsed data that has been embedded into the dragging event.
8616         // `dropLocation` is an object that contains the new zoned start/end/allDay values for the event.
8617         reportExternalDrop: function(meta, dropLocation, el, ev, ui) {
8618                 var eventProps = meta.eventProps;
8619                 var eventInput;
8620                 var event;
8621
8622                 // Try to build an event object and render it. TODO: decouple the two
8623                 if (eventProps) {
8624                         eventInput = $.extend({}, eventProps, dropLocation);
8625                         event = this.calendar.renderEvent(eventInput, meta.stick)[0]; // renderEvent returns an array
8626                 }
8627
8628                 this.triggerExternalDrop(event, dropLocation, el, ev, ui);
8629         },
8630
8631
8632         // Triggers external-drop handlers that have subscribed via the API
8633         triggerExternalDrop: function(event, dropLocation, el, ev, ui) {
8634
8635                 // trigger 'drop' regardless of whether element represents an event
8636                 this.trigger('drop', el[0], dropLocation.start, ev, ui);
8637
8638                 if (event) {
8639                         this.trigger('eventReceive', null, event); // signal an external event landed
8640                 }
8641         },
8642
8643
8644         /* Drag-n-Drop Rendering (for both events and external elements)
8645         ------------------------------------------------------------------------------------------------------------------*/
8646
8647
8648         // Renders a visual indication of a event or external-element drag over the given drop zone.
8649         // If an external-element, seg will be `null`.
8650         // Must return elements used for any mock events.
8651         renderDrag: function(dropLocation, seg) {
8652                 // subclasses must implement
8653         },
8654
8655
8656         // Unrenders a visual indication of an event or external-element being dragged.
8657         unrenderDrag: function() {
8658                 // subclasses must implement
8659         },
8660
8661
8662         /* Event Resizing
8663         ------------------------------------------------------------------------------------------------------------------*/
8664
8665
8666         // Computes if the given event is allowed to be resized from its starting edge
8667         isEventResizableFromStart: function(event) {
8668                 return this.opt('eventResizableFromStart') && this.isEventResizable(event);
8669         },
8670
8671
8672         // Computes if the given event is allowed to be resized from its ending edge
8673         isEventResizableFromEnd: function(event) {
8674                 return this.isEventResizable(event);
8675         },
8676
8677
8678         // Computes if the given event is allowed to be resized by the user at all
8679         isEventResizable: function(event) {
8680                 var source = event.source || {};
8681
8682                 return firstDefined(
8683                         event.durationEditable,
8684                         source.durationEditable,
8685                         this.opt('eventDurationEditable'),
8686                         event.editable,
8687                         source.editable,
8688                         this.opt('editable')
8689                 );
8690         },
8691
8692
8693         // Must be called when an event in the view has been resized to a new length
8694         reportEventResize: function(event, resizeLocation, largeUnit, el, ev) {
8695                 var calendar = this.calendar;
8696                 var mutateResult = calendar.mutateEvent(event, resizeLocation, largeUnit);
8697                 var undoFunc = function() {
8698                         mutateResult.undo();
8699                         calendar.reportEventChange();
8700                 };
8701
8702                 this.triggerEventResize(event, mutateResult.durationDelta, undoFunc, el, ev);
8703                 calendar.reportEventChange(); // will rerender events
8704         },
8705
8706
8707         // Triggers event-resize handlers that have subscribed via the API
8708         triggerEventResize: function(event, durationDelta, undoFunc, el, ev) {
8709                 this.trigger('eventResize', el[0], event, durationDelta, undoFunc, ev, {}); // {} = jqui dummy
8710         },
8711
8712
8713         /* Selection (time range)
8714         ------------------------------------------------------------------------------------------------------------------*/
8715
8716
8717         // Selects a date span on the view. `start` and `end` are both Moments.
8718         // `ev` is the native mouse event that begin the interaction.
8719         select: function(span, ev) {
8720                 this.unselect(ev);
8721                 this.renderSelection(span);
8722                 this.reportSelection(span, ev);
8723         },
8724
8725
8726         // Renders a visual indication of the selection
8727         renderSelection: function(span) {
8728                 // subclasses should implement
8729         },
8730
8731
8732         // Called when a new selection is made. Updates internal state and triggers handlers.
8733         reportSelection: function(span, ev) {
8734                 this.isSelected = true;
8735                 this.triggerSelect(span, ev);
8736         },
8737
8738
8739         // Triggers handlers to 'select'
8740         triggerSelect: function(span, ev) {
8741                 this.trigger(
8742                         'select',
8743                         null,
8744                         this.calendar.applyTimezone(span.start), // convert to calendar's tz for external API
8745                         this.calendar.applyTimezone(span.end), // "
8746                         ev
8747                 );
8748         },
8749
8750
8751         // Undoes a selection. updates in the internal state and triggers handlers.
8752         // `ev` is the native mouse event that began the interaction.
8753         unselect: function(ev) {
8754                 if (this.isSelected) {
8755                         this.isSelected = false;
8756                         if (this.destroySelection) {
8757                                 this.destroySelection(); // TODO: deprecate
8758                         }
8759                         this.unrenderSelection();
8760                         this.trigger('unselect', null, ev);
8761                 }
8762         },
8763
8764
8765         // Unrenders a visual indication of selection
8766         unrenderSelection: function() {
8767                 // subclasses should implement
8768         },
8769
8770
8771         /* Event Selection
8772         ------------------------------------------------------------------------------------------------------------------*/
8773
8774
8775         selectEvent: function(event) {
8776                 if (!this.selectedEvent || this.selectedEvent !== event) {
8777                         this.unselectEvent();
8778                         this.renderedEventSegEach(function(seg) {
8779                                 seg.el.addClass('fc-selected');
8780                         }, event);
8781                         this.selectedEvent = event;
8782                 }
8783         },
8784
8785
8786         unselectEvent: function() {
8787                 if (this.selectedEvent) {
8788                         this.renderedEventSegEach(function(seg) {
8789                                 seg.el.removeClass('fc-selected');
8790                         }, this.selectedEvent);
8791                         this.selectedEvent = null;
8792                 }
8793         },
8794
8795
8796         isEventSelected: function(event) {
8797                 // event references might change on refetchEvents(), while selectedEvent doesn't,
8798                 // so compare IDs
8799                 return this.selectedEvent && this.selectedEvent._id === event._id;
8800         },
8801
8802
8803         /* Mouse / Touch Unselecting (time range & event unselection)
8804         ------------------------------------------------------------------------------------------------------------------*/
8805         // TODO: move consistently to down/start or up/end?
8806         // TODO: don't kill previous selection if touch scrolling
8807
8808
8809         handleDocumentMousedown: function(ev) {
8810                 if (isPrimaryMouseButton(ev)) {
8811                         this.processUnselect(ev);
8812                 }
8813         },
8814
8815
8816         processUnselect: function(ev) {
8817                 this.processRangeUnselect(ev);
8818                 this.processEventUnselect(ev);
8819         },
8820
8821
8822         processRangeUnselect: function(ev) {
8823                 var ignore;
8824
8825                 // is there a time-range selection?
8826                 if (this.isSelected && this.opt('unselectAuto')) {
8827                         // only unselect if the clicked element is not identical to or inside of an 'unselectCancel' element
8828                         ignore = this.opt('unselectCancel');
8829                         if (!ignore || !$(ev.target).closest(ignore).length) {
8830                                 this.unselect(ev);
8831                         }
8832                 }
8833         },
8834
8835
8836         processEventUnselect: function(ev) {
8837                 if (this.selectedEvent) {
8838                         if (!$(ev.target).closest('.fc-selected').length) {
8839                                 this.unselectEvent();
8840                         }
8841                 }
8842         },
8843
8844
8845         /* Day Click
8846         ------------------------------------------------------------------------------------------------------------------*/
8847
8848
8849         // Triggers handlers to 'dayClick'
8850         // Span has start/end of the clicked area. Only the start is useful.
8851         triggerDayClick: function(span, dayEl, ev) {
8852                 this.trigger(
8853                         'dayClick',
8854                         dayEl,
8855                         this.calendar.applyTimezone(span.start), // convert to calendar's timezone for external API
8856                         ev
8857                 );
8858         },
8859
8860
8861         /* Date Utils
8862         ------------------------------------------------------------------------------------------------------------------*/
8863
8864
8865         // Initializes internal variables related to calculating hidden days-of-week
8866         initHiddenDays: function() {
8867                 var hiddenDays = this.opt('hiddenDays') || []; // array of day-of-week indices that are hidden
8868                 var isHiddenDayHash = []; // is the day-of-week hidden? (hash with day-of-week-index -> bool)
8869                 var dayCnt = 0;
8870                 var i;
8871
8872                 if (this.opt('weekends') === false) {
8873                         hiddenDays.push(0, 6); // 0=sunday, 6=saturday
8874                 }
8875
8876                 for (i = 0; i < 7; i++) {
8877                         if (
8878                                 !(isHiddenDayHash[i] = $.inArray(i, hiddenDays) !== -1)
8879                         ) {
8880                                 dayCnt++;
8881                         }
8882                 }
8883
8884                 if (!dayCnt) {
8885                         throw 'invalid hiddenDays'; // all days were hidden? bad.
8886                 }
8887
8888                 this.isHiddenDayHash = isHiddenDayHash;
8889         },
8890
8891
8892         // Is the current day hidden?
8893         // `day` is a day-of-week index (0-6), or a Moment
8894         isHiddenDay: function(day) {
8895                 if (moment.isMoment(day)) {
8896                         day = day.day();
8897                 }
8898                 return this.isHiddenDayHash[day];
8899         },
8900
8901
8902         // Incrementing the current day until it is no longer a hidden day, returning a copy.
8903         // If the initial value of `date` is not a hidden day, don't do anything.
8904         // Pass `isExclusive` as `true` if you are dealing with an end date.
8905         // `inc` defaults to `1` (increment one day forward each time)
8906         skipHiddenDays: function(date, inc, isExclusive) {
8907                 var out = date.clone();
8908                 inc = inc || 1;
8909                 while (
8910                         this.isHiddenDayHash[(out.day() + (isExclusive ? inc : 0) + 7) % 7]
8911                 ) {
8912                         out.add(inc, 'days');
8913                 }
8914                 return out;
8915         },
8916
8917
8918         // Returns the date range of the full days the given range visually appears to occupy.
8919         // Returns a new range object.
8920         computeDayRange: function(range) {
8921                 var startDay = range.start.clone().stripTime(); // the beginning of the day the range starts
8922                 var end = range.end;
8923                 var endDay = null;
8924                 var endTimeMS;
8925
8926                 if (end) {
8927                         endDay = end.clone().stripTime(); // the beginning of the day the range exclusively ends
8928                         endTimeMS = +end.time(); // # of milliseconds into `endDay`
8929
8930                         // If the end time is actually inclusively part of the next day and is equal to or
8931                         // beyond the next day threshold, adjust the end to be the exclusive end of `endDay`.
8932                         // Otherwise, leaving it as inclusive will cause it to exclude `endDay`.
8933                         if (endTimeMS && endTimeMS >= this.nextDayThreshold) {
8934                                 endDay.add(1, 'days');
8935                         }
8936                 }
8937
8938                 // If no end was specified, or if it is within `startDay` but not past nextDayThreshold,
8939                 // assign the default duration of one day.
8940                 if (!end || endDay <= startDay) {
8941                         endDay = startDay.clone().add(1, 'days');
8942                 }
8943
8944                 return { start: startDay, end: endDay };
8945         },
8946
8947
8948         // Does the given event visually appear to occupy more than one day?
8949         isMultiDayEvent: function(event) {
8950                 var range = this.computeDayRange(event); // event is range-ish
8951
8952                 return range.end.diff(range.start, 'days') > 1;
8953         }
8954
8955 });
8956
8957 ;;
8958
8959 /*
8960 Embodies a div that has potential scrollbars
8961 */
8962 var Scroller = FC.Scroller = Class.extend({
8963
8964         el: null, // the guaranteed outer element
8965         scrollEl: null, // the element with the scrollbars
8966         overflowX: null,
8967         overflowY: null,
8968
8969
8970         constructor: function(options) {
8971                 options = options || {};
8972                 this.overflowX = options.overflowX || options.overflow || 'auto';
8973                 this.overflowY = options.overflowY || options.overflow || 'auto';
8974         },
8975
8976
8977         render: function() {
8978                 this.el = this.renderEl();
8979                 this.applyOverflow();
8980         },
8981
8982
8983         renderEl: function() {
8984                 return (this.scrollEl = $('<div class="fc-scroller"></div>'));
8985         },
8986
8987
8988         // sets to natural height, unlocks overflow
8989         clear: function() {
8990                 this.setHeight('auto');
8991                 this.applyOverflow();
8992         },
8993
8994
8995         destroy: function() {
8996                 this.el.remove();
8997         },
8998
8999
9000         // Overflow
9001         // -----------------------------------------------------------------------------------------------------------------
9002
9003
9004         applyOverflow: function() {
9005                 this.scrollEl.css({
9006                         'overflow-x': this.overflowX,
9007                         'overflow-y': this.overflowY
9008                 });
9009         },
9010
9011
9012         // Causes any 'auto' overflow values to resolves to 'scroll' or 'hidden'.
9013         // Useful for preserving scrollbar widths regardless of future resizes.
9014         // Can pass in scrollbarWidths for optimization.
9015         lockOverflow: function(scrollbarWidths) {
9016                 var overflowX = this.overflowX;
9017                 var overflowY = this.overflowY;
9018
9019                 scrollbarWidths = scrollbarWidths || this.getScrollbarWidths();
9020
9021                 if (overflowX === 'auto') {
9022                         overflowX = (
9023                                         scrollbarWidths.top || scrollbarWidths.bottom || // horizontal scrollbars?
9024                                         // OR scrolling pane with massless scrollbars?
9025                                         this.scrollEl[0].scrollWidth - 1 > this.scrollEl[0].clientWidth
9026                                                 // subtract 1 because of IE off-by-one issue
9027                                 ) ? 'scroll' : 'hidden';
9028                 }
9029
9030                 if (overflowY === 'auto') {
9031                         overflowY = (
9032                                         scrollbarWidths.left || scrollbarWidths.right || // vertical scrollbars?
9033                                         // OR scrolling pane with massless scrollbars?
9034                                         this.scrollEl[0].scrollHeight - 1 > this.scrollEl[0].clientHeight
9035                                                 // subtract 1 because of IE off-by-one issue
9036                                 ) ? 'scroll' : 'hidden';
9037                 }
9038
9039                 this.scrollEl.css({ 'overflow-x': overflowX, 'overflow-y': overflowY });
9040         },
9041
9042
9043         // Getters / Setters
9044         // -----------------------------------------------------------------------------------------------------------------
9045
9046
9047         setHeight: function(height) {
9048                 this.scrollEl.height(height);
9049         },
9050
9051
9052         getScrollTop: function() {
9053                 return this.scrollEl.scrollTop();
9054         },
9055
9056
9057         setScrollTop: function(top) {
9058                 this.scrollEl.scrollTop(top);
9059         },
9060
9061
9062         getClientWidth: function() {
9063                 return this.scrollEl[0].clientWidth;
9064         },
9065
9066
9067         getClientHeight: function() {
9068                 return this.scrollEl[0].clientHeight;
9069         },
9070
9071
9072         getScrollbarWidths: function() {
9073                 return getScrollbarWidths(this.scrollEl);
9074         }
9075
9076 });
9077
9078 ;;
9079
9080 var Calendar = FC.Calendar = Class.extend({
9081
9082         dirDefaults: null, // option defaults related to LTR or RTL
9083         localeDefaults: null, // option defaults related to current locale
9084         overrides: null, // option overrides given to the fullCalendar constructor
9085         dynamicOverrides: null, // options set with dynamic setter method. higher precedence than view overrides.
9086         options: null, // all defaults combined with overrides
9087         viewSpecCache: null, // cache of view definitions
9088         view: null, // current View object
9089         header: null,
9090         loadingLevel: 0, // number of simultaneous loading tasks
9091
9092
9093         // a lot of this class' OOP logic is scoped within this constructor function,
9094         // but in the future, write individual methods on the prototype.
9095         constructor: Calendar_constructor,
9096
9097
9098         // Subclasses can override this for initialization logic after the constructor has been called
9099         initialize: function() {
9100         },
9101
9102
9103         // Computes the flattened options hash for the calendar and assigns to `this.options`.
9104         // Assumes this.overrides and this.dynamicOverrides have already been initialized.
9105         populateOptionsHash: function() {
9106                 var locale, localeDefaults;
9107                 var isRTL, dirDefaults;
9108
9109                 locale = firstDefined( // explicit locale option given?
9110                         this.dynamicOverrides.locale,
9111                         this.overrides.locale
9112                 );
9113                 localeDefaults = localeOptionHash[locale];
9114                 if (!localeDefaults) { // explicit locale option not given or invalid?
9115                         locale = Calendar.defaults.locale;
9116                         localeDefaults = localeOptionHash[locale] || {};
9117                 }
9118
9119                 isRTL = firstDefined( // based on options computed so far, is direction RTL?
9120                         this.dynamicOverrides.isRTL,
9121                         this.overrides.isRTL,
9122                         localeDefaults.isRTL,
9123                         Calendar.defaults.isRTL
9124                 );
9125                 dirDefaults = isRTL ? Calendar.rtlDefaults : {};
9126
9127                 this.dirDefaults = dirDefaults;
9128                 this.localeDefaults = localeDefaults;
9129                 this.options = mergeOptions([ // merge defaults and overrides. lowest to highest precedence
9130                         Calendar.defaults, // global defaults
9131                         dirDefaults,
9132                         localeDefaults,
9133                         this.overrides,
9134                         this.dynamicOverrides
9135                 ]);
9136                 populateInstanceComputableOptions(this.options); // fill in gaps with computed options
9137         },
9138
9139
9140         // Gets information about how to create a view. Will use a cache.
9141         getViewSpec: function(viewType) {
9142                 var cache = this.viewSpecCache;
9143
9144                 return cache[viewType] || (cache[viewType] = this.buildViewSpec(viewType));
9145         },
9146
9147
9148         // Given a duration singular unit, like "week" or "day", finds a matching view spec.
9149         // Preference is given to views that have corresponding buttons.
9150         getUnitViewSpec: function(unit) {
9151                 var viewTypes;
9152                 var i;
9153                 var spec;
9154
9155                 if ($.inArray(unit, intervalUnits) != -1) {
9156
9157                         // put views that have buttons first. there will be duplicates, but oh well
9158                         viewTypes = this.header.getViewsWithButtons();
9159                         $.each(FC.views, function(viewType) { // all views
9160                                 viewTypes.push(viewType);
9161                         });
9162
9163                         for (i = 0; i < viewTypes.length; i++) {
9164                                 spec = this.getViewSpec(viewTypes[i]);
9165                                 if (spec) {
9166                                         if (spec.singleUnit == unit) {
9167                                                 return spec;
9168                                         }
9169                                 }
9170                         }
9171                 }
9172         },
9173
9174
9175         // Builds an object with information on how to create a given view
9176         buildViewSpec: function(requestedViewType) {
9177                 var viewOverrides = this.overrides.views || {};
9178                 var specChain = []; // for the view. lowest to highest priority
9179                 var defaultsChain = []; // for the view. lowest to highest priority
9180                 var overridesChain = []; // for the view. lowest to highest priority
9181                 var viewType = requestedViewType;
9182                 var spec; // for the view
9183                 var overrides; // for the view
9184                 var duration;
9185                 var unit;
9186
9187                 // iterate from the specific view definition to a more general one until we hit an actual View class
9188                 while (viewType) {
9189                         spec = fcViews[viewType];
9190                         overrides = viewOverrides[viewType];
9191                         viewType = null; // clear. might repopulate for another iteration
9192
9193                         if (typeof spec === 'function') { // TODO: deprecate
9194                                 spec = { 'class': spec };
9195                         }
9196
9197                         if (spec) {
9198                                 specChain.unshift(spec);
9199                                 defaultsChain.unshift(spec.defaults || {});
9200                                 duration = duration || spec.duration;
9201                                 viewType = viewType || spec.type;
9202                         }
9203
9204                         if (overrides) {
9205                                 overridesChain.unshift(overrides); // view-specific option hashes have options at zero-level
9206                                 duration = duration || overrides.duration;
9207                                 viewType = viewType || overrides.type;
9208                         }
9209                 }
9210
9211                 spec = mergeProps(specChain);
9212                 spec.type = requestedViewType;
9213                 if (!spec['class']) {
9214                         return false;
9215                 }
9216
9217                 if (duration) {
9218                         duration = moment.duration(duration);
9219                         if (duration.valueOf()) { // valid?
9220                                 spec.duration = duration;
9221                                 unit = computeIntervalUnit(duration);
9222
9223                                 // view is a single-unit duration, like "week" or "day"
9224                                 // incorporate options for this. lowest priority
9225                                 if (duration.as(unit) === 1) {
9226                                         spec.singleUnit = unit;
9227                                         overridesChain.unshift(viewOverrides[unit] || {});
9228                                 }
9229                         }
9230                 }
9231
9232                 spec.defaults = mergeOptions(defaultsChain);
9233                 spec.overrides = mergeOptions(overridesChain);
9234
9235                 this.buildViewSpecOptions(spec);
9236                 this.buildViewSpecButtonText(spec, requestedViewType);
9237
9238                 return spec;
9239         },
9240
9241
9242         // Builds and assigns a view spec's options object from its already-assigned defaults and overrides
9243         buildViewSpecOptions: function(spec) {
9244                 spec.options = mergeOptions([ // lowest to highest priority
9245                         Calendar.defaults, // global defaults
9246                         spec.defaults, // view's defaults (from ViewSubclass.defaults)
9247                         this.dirDefaults,
9248                         this.localeDefaults, // locale and dir take precedence over view's defaults!
9249                         this.overrides, // calendar's overrides (options given to constructor)
9250                         spec.overrides, // view's overrides (view-specific options)
9251                         this.dynamicOverrides // dynamically set via setter. highest precedence
9252                 ]);
9253                 populateInstanceComputableOptions(spec.options);
9254         },
9255
9256
9257         // Computes and assigns a view spec's buttonText-related options
9258         buildViewSpecButtonText: function(spec, requestedViewType) {
9259
9260                 // given an options object with a possible `buttonText` hash, lookup the buttonText for the
9261                 // requested view, falling back to a generic unit entry like "week" or "day"
9262                 function queryButtonText(options) {
9263                         var buttonText = options.buttonText || {};
9264                         return buttonText[requestedViewType] ||
9265                                 // view can decide to look up a certain key
9266                                 (spec.buttonTextKey ? buttonText[spec.buttonTextKey] : null) ||
9267                                 // a key like "month"
9268                                 (spec.singleUnit ? buttonText[spec.singleUnit] : null);
9269                 }
9270
9271                 // highest to lowest priority
9272                 spec.buttonTextOverride =
9273                         queryButtonText(this.dynamicOverrides) ||
9274                         queryButtonText(this.overrides) || // constructor-specified buttonText lookup hash takes precedence
9275                         spec.overrides.buttonText; // `buttonText` for view-specific options is a string
9276
9277                 // highest to lowest priority. mirrors buildViewSpecOptions
9278                 spec.buttonTextDefault =
9279                         queryButtonText(this.localeDefaults) ||
9280                         queryButtonText(this.dirDefaults) ||
9281                         spec.defaults.buttonText || // a single string. from ViewSubclass.defaults
9282                         queryButtonText(Calendar.defaults) ||
9283                         (spec.duration ? this.humanizeDuration(spec.duration) : null) || // like "3 days"
9284                         requestedViewType; // fall back to given view name
9285         },
9286
9287
9288         // Given a view name for a custom view or a standard view, creates a ready-to-go View object
9289         instantiateView: function(viewType) {
9290                 var spec = this.getViewSpec(viewType);
9291
9292                 return new spec['class'](this, viewType, spec.options, spec.duration);
9293         },
9294
9295
9296         // Returns a boolean about whether the view is okay to instantiate at some point
9297         isValidViewType: function(viewType) {
9298                 return Boolean(this.getViewSpec(viewType));
9299         },
9300
9301
9302         // Should be called when any type of async data fetching begins
9303         pushLoading: function() {
9304                 if (!(this.loadingLevel++)) {
9305                         this.trigger('loading', null, true, this.view);
9306                 }
9307         },
9308
9309
9310         // Should be called when any type of async data fetching completes
9311         popLoading: function() {
9312                 if (!(--this.loadingLevel)) {
9313                         this.trigger('loading', null, false, this.view);
9314                 }
9315         },
9316
9317
9318         // Given arguments to the select method in the API, returns a span (unzoned start/end and other info)
9319         buildSelectSpan: function(zonedStartInput, zonedEndInput) {
9320                 var start = this.moment(zonedStartInput).stripZone();
9321                 var end;
9322
9323                 if (zonedEndInput) {
9324                         end = this.moment(zonedEndInput).stripZone();
9325                 }
9326                 else if (start.hasTime()) {
9327                         end = start.clone().add(this.defaultTimedEventDuration);
9328                 }
9329                 else {
9330                         end = start.clone().add(this.defaultAllDayEventDuration);
9331                 }
9332
9333                 return { start: start, end: end };
9334         }
9335
9336 });
9337
9338
9339 Calendar.mixin(EmitterMixin);
9340
9341
9342 function Calendar_constructor(element, overrides) {
9343         var t = this;
9344
9345
9346         // Exports
9347         // -----------------------------------------------------------------------------------
9348
9349         t.render = render;
9350         t.destroy = destroy;
9351         t.refetchEvents = refetchEvents;
9352         t.refetchEventSources = refetchEventSources;
9353         t.reportEvents = reportEvents;
9354         t.reportEventChange = reportEventChange;
9355         t.rerenderEvents = renderEvents; // `renderEvents` serves as a rerender. an API method
9356         t.changeView = renderView; // `renderView` will switch to another view
9357         t.select = select;
9358         t.unselect = unselect;
9359         t.prev = prev;
9360         t.next = next;
9361         t.prevYear = prevYear;
9362         t.nextYear = nextYear;
9363         t.today = today;
9364         t.gotoDate = gotoDate;
9365         t.incrementDate = incrementDate;
9366         t.zoomTo = zoomTo;
9367         t.getDate = getDate;
9368         t.getCalendar = getCalendar;
9369         t.getView = getView;
9370         t.option = option; // getter/setter method
9371         t.trigger = trigger;
9372
9373
9374         // Options
9375         // -----------------------------------------------------------------------------------
9376
9377         t.dynamicOverrides = {};
9378         t.viewSpecCache = {};
9379         t.optionHandlers = {}; // for Calendar.options.js
9380         t.overrides = $.extend({}, overrides); // make a copy
9381
9382         t.populateOptionsHash(); // sets this.options
9383
9384
9385
9386         // Locale-data Internals
9387         // -----------------------------------------------------------------------------------
9388         // Apply overrides to the current locale's data
9389
9390         var localeData;
9391
9392         // Called immediately, and when any of the options change.
9393         // Happens before any internal objects rebuild or rerender, because this is very core.
9394         t.bindOptions([
9395                 'locale', 'monthNames', 'monthNamesShort', 'dayNames', 'dayNamesShort', 'firstDay', 'weekNumberCalculation'
9396         ], function(locale, monthNames, monthNamesShort, dayNames, dayNamesShort, firstDay, weekNumberCalculation) {
9397
9398                 // normalize
9399                 if (weekNumberCalculation === 'iso') {
9400                         weekNumberCalculation = 'ISO'; // normalize
9401                 }
9402
9403                 localeData = createObject( // make a cheap copy
9404                         getMomentLocaleData(locale) // will fall back to en
9405                 );
9406
9407                 if (monthNames) {
9408                         localeData._months = monthNames;
9409                 }
9410                 if (monthNamesShort) {
9411                         localeData._monthsShort = monthNamesShort;
9412                 }
9413                 if (dayNames) {
9414                         localeData._weekdays = dayNames;
9415                 }
9416                 if (dayNamesShort) {
9417                         localeData._weekdaysShort = dayNamesShort;
9418                 }
9419
9420                 if (firstDay == null && weekNumberCalculation === 'ISO') {
9421                         firstDay = 1;
9422                 }
9423                 if (firstDay != null) {
9424                         var _week = createObject(localeData._week); // _week: { dow: # }
9425                         _week.dow = firstDay;
9426                         localeData._week = _week;
9427                 }
9428
9429                 if ( // whitelist certain kinds of input
9430                         weekNumberCalculation === 'ISO' ||
9431                         weekNumberCalculation === 'local' ||
9432                         typeof weekNumberCalculation === 'function'
9433                 ) {
9434                         localeData._fullCalendar_weekCalc = weekNumberCalculation; // moment-ext will know what to do with it
9435                 }
9436
9437                 // If the internal current date object already exists, move to new locale.
9438                 // We do NOT need to do this technique for event dates, because this happens when converting to "segments".
9439                 if (date) {
9440                         localizeMoment(date); // sets to localeData
9441                 }
9442         });
9443
9444
9445         // Calendar-specific Date Utilities
9446         // -----------------------------------------------------------------------------------
9447
9448
9449         t.defaultAllDayEventDuration = moment.duration(t.options.defaultAllDayEventDuration);
9450         t.defaultTimedEventDuration = moment.duration(t.options.defaultTimedEventDuration);
9451
9452
9453         // Builds a moment using the settings of the current calendar: timezone and locale.
9454         // Accepts anything the vanilla moment() constructor accepts.
9455         t.moment = function() {
9456                 var mom;
9457
9458                 if (t.options.timezone === 'local') {
9459                         mom = FC.moment.apply(null, arguments);
9460
9461                         // Force the moment to be local, because FC.moment doesn't guarantee it.
9462                         if (mom.hasTime()) { // don't give ambiguously-timed moments a local zone
9463                                 mom.local();
9464                         }
9465                 }
9466                 else if (t.options.timezone === 'UTC') {
9467                         mom = FC.moment.utc.apply(null, arguments); // process as UTC
9468                 }
9469                 else {
9470                         mom = FC.moment.parseZone.apply(null, arguments); // let the input decide the zone
9471                 }
9472
9473                 localizeMoment(mom);
9474
9475                 return mom;
9476         };
9477
9478
9479         // Updates the given moment's locale settings to the current calendar locale settings.
9480         function localizeMoment(mom) {
9481                 mom._locale = localeData;
9482         }
9483         t.localizeMoment = localizeMoment;
9484
9485
9486         // Returns a boolean about whether or not the calendar knows how to calculate
9487         // the timezone offset of arbitrary dates in the current timezone.
9488         t.getIsAmbigTimezone = function() {
9489                 return t.options.timezone !== 'local' && t.options.timezone !== 'UTC';
9490         };
9491
9492
9493         // Returns a copy of the given date in the current timezone. Has no effect on dates without times.
9494         t.applyTimezone = function(date) {
9495                 if (!date.hasTime()) {
9496                         return date.clone();
9497                 }
9498
9499                 var zonedDate = t.moment(date.toArray());
9500                 var timeAdjust = date.time() - zonedDate.time();
9501                 var adjustedZonedDate;
9502
9503                 // Safari sometimes has problems with this coersion when near DST. Adjust if necessary. (bug #2396)
9504                 if (timeAdjust) { // is the time result different than expected?
9505                         adjustedZonedDate = zonedDate.clone().add(timeAdjust); // add milliseconds
9506                         if (date.time() - adjustedZonedDate.time() === 0) { // does it match perfectly now?
9507                                 zonedDate = adjustedZonedDate;
9508                         }
9509                 }
9510
9511                 return zonedDate;
9512         };
9513
9514
9515         // Returns a moment for the current date, as defined by the client's computer or from the `now` option.
9516         // Will return an moment with an ambiguous timezone.
9517         t.getNow = function() {
9518                 var now = t.options.now;
9519                 if (typeof now === 'function') {
9520                         now = now();
9521                 }
9522                 return t.moment(now).stripZone();
9523         };
9524
9525
9526         // Get an event's normalized end date. If not present, calculate it from the defaults.
9527         t.getEventEnd = function(event) {
9528                 if (event.end) {
9529                         return event.end.clone();
9530                 }
9531                 else {
9532                         return t.getDefaultEventEnd(event.allDay, event.start);
9533                 }
9534         };
9535
9536
9537         // Given an event's allDay status and start date, return what its fallback end date should be.
9538         // TODO: rename to computeDefaultEventEnd
9539         t.getDefaultEventEnd = function(allDay, zonedStart) {
9540                 var end = zonedStart.clone();
9541
9542                 if (allDay) {
9543                         end.stripTime().add(t.defaultAllDayEventDuration);
9544                 }
9545                 else {
9546                         end.add(t.defaultTimedEventDuration);
9547                 }
9548
9549                 if (t.getIsAmbigTimezone()) {
9550                         end.stripZone(); // we don't know what the tzo should be
9551                 }
9552
9553                 return end;
9554         };
9555
9556
9557         // Produces a human-readable string for the given duration.
9558         // Side-effect: changes the locale of the given duration.
9559         t.humanizeDuration = function(duration) {
9560                 return duration.locale(t.options.locale).humanize();
9561         };
9562
9563
9564         
9565         // Imports
9566         // -----------------------------------------------------------------------------------
9567
9568
9569         EventManager.call(t);
9570         var isFetchNeeded = t.isFetchNeeded;
9571         var fetchEvents = t.fetchEvents;
9572         var fetchEventSources = t.fetchEventSources;
9573
9574
9575
9576         // Locals
9577         // -----------------------------------------------------------------------------------
9578
9579
9580         var _element = element[0];
9581         var header;
9582         var content;
9583         var tm; // for making theme classes
9584         var currentView; // NOTE: keep this in sync with this.view
9585         var viewsByType = {}; // holds all instantiated view instances, current or not
9586         var suggestedViewHeight;
9587         var windowResizeProxy; // wraps the windowResize function
9588         var ignoreWindowResize = 0;
9589         var events = [];
9590         var date; // unzoned
9591         
9592         
9593         
9594         // Main Rendering
9595         // -----------------------------------------------------------------------------------
9596
9597
9598         // compute the initial ambig-timezone date
9599         if (t.options.defaultDate != null) {
9600                 date = t.moment(t.options.defaultDate).stripZone();
9601         }
9602         else {
9603                 date = t.getNow(); // getNow already returns unzoned
9604         }
9605         
9606         
9607         function render() {
9608                 if (!content) {
9609                         initialRender();
9610                 }
9611                 else if (elementVisible()) {
9612                         // mainly for the public API
9613                         calcSize();
9614                         renderView();
9615                 }
9616         }
9617         
9618         
9619         function initialRender() {
9620                 element.addClass('fc');
9621
9622                 // event delegation for nav links
9623                 element.on('click.fc', 'a[data-goto]', function(ev) {
9624                         var anchorEl = $(this);
9625                         var gotoOptions = anchorEl.data('goto'); // will automatically parse JSON
9626                         var date = t.moment(gotoOptions.date);
9627                         var viewType = gotoOptions.type;
9628
9629                         // property like "navLinkDayClick". might be a string or a function
9630                         var customAction = currentView.opt('navLink' + capitaliseFirstLetter(viewType) + 'Click');
9631
9632                         if (typeof customAction === 'function') {
9633                                 customAction(date, ev);
9634                         }
9635                         else {
9636                                 if (typeof customAction === 'string') {
9637                                         viewType = customAction;
9638                                 }
9639                                 zoomTo(date, viewType);
9640                         }
9641                 });
9642
9643                 // called immediately, and upon option change
9644                 t.bindOption('theme', function(theme) {
9645                         tm = theme ? 'ui' : 'fc'; // affects a larger scope
9646                         element.toggleClass('ui-widget', theme);
9647                         element.toggleClass('fc-unthemed', !theme);
9648                 });
9649
9650                 // called immediately, and upon option change.
9651                 // HACK: locale often affects isRTL, so we explicitly listen to that too.
9652                 t.bindOptions([ 'isRTL', 'locale' ], function(isRTL) {
9653                         element.toggleClass('fc-ltr', !isRTL);
9654                         element.toggleClass('fc-rtl', isRTL);
9655                 });
9656
9657                 content = $("<div class='fc-view-container'/>").prependTo(element);
9658
9659                 header = t.header = new Header(t);
9660                 renderHeader();
9661
9662                 renderView(t.options.defaultView);
9663
9664                 if (t.options.handleWindowResize) {
9665                         windowResizeProxy = debounce(windowResize, t.options.windowResizeDelay); // prevents rapid calls
9666                         $(window).resize(windowResizeProxy);
9667                 }
9668         }
9669
9670
9671         // can be called repeatedly and Header will rerender
9672         function renderHeader() {
9673                 header.render();
9674                 if (header.el) {
9675                         element.prepend(header.el);
9676                 }
9677         }
9678         
9679         
9680         function destroy() {
9681
9682                 if (currentView) {
9683                         currentView.removeElement();
9684
9685                         // NOTE: don't null-out currentView/t.view in case API methods are called after destroy.
9686                         // It is still the "current" view, just not rendered.
9687                 }
9688
9689                 header.removeElement();
9690                 content.remove();
9691                 element.removeClass('fc fc-ltr fc-rtl fc-unthemed ui-widget');
9692
9693                 element.off('.fc'); // unbind nav link handlers
9694
9695                 if (windowResizeProxy) {
9696                         $(window).unbind('resize', windowResizeProxy);
9697                 }
9698         }
9699         
9700         
9701         function elementVisible() {
9702                 return element.is(':visible');
9703         }
9704         
9705         
9706
9707         // View Rendering
9708         // -----------------------------------------------------------------------------------
9709
9710
9711         // Renders a view because of a date change, view-type change, or for the first time.
9712         // If not given a viewType, keep the current view but render different dates.
9713         // Accepts an optional scroll state to restore to.
9714         function renderView(viewType, explicitScrollState) {
9715                 ignoreWindowResize++;
9716
9717                 // if viewType is changing, remove the old view's rendering
9718                 if (currentView && viewType && currentView.type !== viewType) {
9719                         freezeContentHeight(); // prevent a scroll jump when view element is removed
9720                         clearView();
9721                 }
9722
9723                 // if viewType changed, or the view was never created, create a fresh view
9724                 if (!currentView && viewType) {
9725                         currentView = t.view =
9726                                 viewsByType[viewType] ||
9727                                 (viewsByType[viewType] = t.instantiateView(viewType));
9728
9729                         currentView.setElement(
9730                                 $("<div class='fc-view fc-" + viewType + "-view' />").appendTo(content)
9731                         );
9732                         header.activateButton(viewType);
9733                 }
9734
9735                 if (currentView) {
9736
9737                         // in case the view should render a period of time that is completely hidden
9738                         date = currentView.massageCurrentDate(date);
9739
9740                         // render or rerender the view
9741                         if (
9742                                 !currentView.displaying ||
9743                                 !( // NOT within interval range signals an implicit date window change
9744                                         date >= currentView.intervalStart &&
9745                                         date < currentView.intervalEnd
9746                                 )
9747                         ) {
9748                                 if (elementVisible()) {
9749
9750                                         currentView.display(date, explicitScrollState); // will call freezeContentHeight
9751                                         unfreezeContentHeight(); // immediately unfreeze regardless of whether display is async
9752
9753                                         // need to do this after View::render, so dates are calculated
9754                                         updateHeaderTitle();
9755                                         updateTodayButton();
9756
9757                                         getAndRenderEvents();
9758                                 }
9759                         }
9760                 }
9761
9762                 unfreezeContentHeight(); // undo any lone freezeContentHeight calls
9763                 ignoreWindowResize--;
9764         }
9765
9766
9767         // Unrenders the current view and reflects this change in the Header.
9768         // Unregsiters the `currentView`, but does not remove from viewByType hash.
9769         function clearView() {
9770                 header.deactivateButton(currentView.type);
9771                 currentView.removeElement();
9772                 currentView = t.view = null;
9773         }
9774
9775
9776         // Destroys the view, including the view object. Then, re-instantiates it and renders it.
9777         // Maintains the same scroll state.
9778         // TODO: maintain any other user-manipulated state.
9779         function reinitView() {
9780                 ignoreWindowResize++;
9781                 freezeContentHeight();
9782
9783                 var viewType = currentView.type;
9784                 var scrollState = currentView.queryScroll();
9785                 clearView();
9786                 renderView(viewType, scrollState);
9787
9788                 unfreezeContentHeight();
9789                 ignoreWindowResize--;
9790         }
9791
9792         
9793
9794         // Resizing
9795         // -----------------------------------------------------------------------------------
9796
9797
9798         t.getSuggestedViewHeight = function() {
9799                 if (suggestedViewHeight === undefined) {
9800                         calcSize();
9801                 }
9802                 return suggestedViewHeight;
9803         };
9804
9805
9806         t.isHeightAuto = function() {
9807                 return t.options.contentHeight === 'auto' || t.options.height === 'auto';
9808         };
9809         
9810         
9811         function updateSize(shouldRecalc) {
9812                 if (elementVisible()) {
9813
9814                         if (shouldRecalc) {
9815                                 _calcSize();
9816                         }
9817
9818                         ignoreWindowResize++;
9819                         currentView.updateSize(true); // isResize=true. will poll getSuggestedViewHeight() and isHeightAuto()
9820                         ignoreWindowResize--;
9821
9822                         return true; // signal success
9823                 }
9824         }
9825
9826
9827         function calcSize() {
9828                 if (elementVisible()) {
9829                         _calcSize();
9830                 }
9831         }
9832         
9833         
9834         function _calcSize() { // assumes elementVisible
9835                 var contentHeightInput = t.options.contentHeight;
9836                 var heightInput = t.options.height;
9837
9838                 if (typeof contentHeightInput === 'number') { // exists and not 'auto'
9839                         suggestedViewHeight = contentHeightInput;
9840                 }
9841                 else if (typeof contentHeightInput === 'function') { // exists and is a function
9842                         suggestedViewHeight = contentHeightInput();
9843                 }
9844                 else if (typeof heightInput === 'number') { // exists and not 'auto'
9845                         suggestedViewHeight = heightInput - queryHeaderHeight();
9846                 }
9847                 else if (typeof heightInput === 'function') { // exists and is a function
9848                         suggestedViewHeight = heightInput() - queryHeaderHeight();
9849                 }
9850                 else if (heightInput === 'parent') { // set to height of parent element
9851                         suggestedViewHeight = element.parent().height() - queryHeaderHeight();
9852                 }
9853                 else {
9854                         suggestedViewHeight = Math.round(content.width() / Math.max(t.options.aspectRatio, .5));
9855                 }
9856         }
9857
9858
9859         function queryHeaderHeight() {
9860                 return header.el ? header.el.outerHeight(true) : 0; // includes margin
9861         }
9862         
9863         
9864         function windowResize(ev) {
9865                 if (
9866                         !ignoreWindowResize &&
9867                         ev.target === window && // so we don't process jqui "resize" events that have bubbled up
9868                         currentView.start // view has already been rendered
9869                 ) {
9870                         if (updateSize(true)) {
9871                                 currentView.trigger('windowResize', _element);
9872                         }
9873                 }
9874         }
9875         
9876         
9877         
9878         /* Event Fetching/Rendering
9879         -----------------------------------------------------------------------------*/
9880         // TODO: going forward, most of this stuff should be directly handled by the view
9881
9882
9883         function refetchEvents() { // can be called as an API method
9884                 fetchAndRenderEvents();
9885         }
9886
9887
9888         // TODO: move this into EventManager?
9889         function refetchEventSources(matchInputs) {
9890                 fetchEventSources(t.getEventSourcesByMatchArray(matchInputs));
9891         }
9892
9893
9894         function renderEvents() { // destroys old events if previously rendered
9895                 if (elementVisible()) {
9896                         freezeContentHeight();
9897                         currentView.displayEvents(events);
9898                         unfreezeContentHeight();
9899                 }
9900         }
9901         
9902
9903         function getAndRenderEvents() {
9904                 if (!t.options.lazyFetching || isFetchNeeded(currentView.start, currentView.end)) {
9905                         fetchAndRenderEvents();
9906                 }
9907                 else {
9908                         renderEvents();
9909                 }
9910         }
9911
9912
9913         function fetchAndRenderEvents() {
9914                 fetchEvents(currentView.start, currentView.end);
9915                         // ... will call reportEvents
9916                         // ... which will call renderEvents
9917         }
9918
9919         
9920         // called when event data arrives
9921         function reportEvents(_events) {
9922                 events = _events;
9923                 renderEvents();
9924         }
9925
9926
9927         // called when a single event's data has been changed
9928         function reportEventChange() {
9929                 renderEvents();
9930         }
9931
9932
9933
9934         /* Header Updating
9935         -----------------------------------------------------------------------------*/
9936
9937
9938         function updateHeaderTitle() {
9939                 header.updateTitle(currentView.title);
9940         }
9941
9942
9943         function updateTodayButton() {
9944                 var now = t.getNow();
9945
9946                 if (now >= currentView.intervalStart && now < currentView.intervalEnd) {
9947                         header.disableButton('today');
9948                 }
9949                 else {
9950                         header.enableButton('today');
9951                 }
9952         }
9953         
9954
9955
9956         /* Selection
9957         -----------------------------------------------------------------------------*/
9958         
9959
9960         // this public method receives start/end dates in any format, with any timezone
9961         function select(zonedStartInput, zonedEndInput) {
9962                 currentView.select(
9963                         t.buildSelectSpan.apply(t, arguments)
9964                 );
9965         }
9966         
9967
9968         function unselect() { // safe to be called before renderView
9969                 if (currentView) {
9970                         currentView.unselect();
9971                 }
9972         }
9973         
9974         
9975         
9976         /* Date
9977         -----------------------------------------------------------------------------*/
9978         
9979         
9980         function prev() {
9981                 date = currentView.computePrevDate(date);
9982                 renderView();
9983         }
9984         
9985         
9986         function next() {
9987                 date = currentView.computeNextDate(date);
9988                 renderView();
9989         }
9990         
9991         
9992         function prevYear() {
9993                 date.add(-1, 'years');
9994                 renderView();
9995         }
9996         
9997         
9998         function nextYear() {
9999                 date.add(1, 'years');
10000                 renderView();
10001         }
10002         
10003         
10004         function today() {
10005                 date = t.getNow();
10006                 renderView();
10007         }
10008         
10009         
10010         function gotoDate(zonedDateInput) {
10011                 date = t.moment(zonedDateInput).stripZone();
10012                 renderView();
10013         }
10014         
10015         
10016         function incrementDate(delta) {
10017                 date.add(moment.duration(delta));
10018                 renderView();
10019         }
10020
10021
10022         // Forces navigation to a view for the given date.
10023         // `viewType` can be a specific view name or a generic one like "week" or "day".
10024         function zoomTo(newDate, viewType) {
10025                 var spec;
10026
10027                 viewType = viewType || 'day'; // day is default zoom
10028                 spec = t.getViewSpec(viewType) || t.getUnitViewSpec(viewType);
10029
10030                 date = newDate.clone();
10031                 renderView(spec ? spec.type : null);
10032         }
10033         
10034         
10035         // for external API
10036         function getDate() {
10037                 return t.applyTimezone(date); // infuse the calendar's timezone
10038         }
10039
10040
10041
10042         /* Height "Freezing"
10043         -----------------------------------------------------------------------------*/
10044         // TODO: move this into the view
10045
10046         t.freezeContentHeight = freezeContentHeight;
10047         t.unfreezeContentHeight = unfreezeContentHeight;
10048
10049
10050         function freezeContentHeight() {
10051                 content.css({
10052                         width: '100%',
10053                         height: content.height(),
10054                         overflow: 'hidden'
10055                 });
10056         }
10057
10058
10059         function unfreezeContentHeight() {
10060                 content.css({
10061                         width: '',
10062                         height: '',
10063                         overflow: ''
10064                 });
10065         }
10066         
10067         
10068         
10069         /* Misc
10070         -----------------------------------------------------------------------------*/
10071         
10072
10073         function getCalendar() {
10074                 return t;
10075         }
10076
10077         
10078         function getView() {
10079                 return currentView;
10080         }
10081         
10082         
10083         function option(name, value) {
10084                 var newOptionHash;
10085
10086                 if (typeof name === 'string') {
10087                         if (value === undefined) { // getter
10088                                 return t.options[name];
10089                         }
10090                         else { // setter for individual option
10091                                 newOptionHash = {};
10092                                 newOptionHash[name] = value;
10093                                 setOptions(newOptionHash);
10094                         }
10095                 }
10096                 else if (typeof name === 'object') { // compound setter with object input
10097                         setOptions(name);
10098                 }
10099         }
10100
10101
10102         function setOptions(newOptionHash) {
10103                 var optionCnt = 0;
10104                 var optionName;
10105
10106                 for (optionName in newOptionHash) {
10107                         t.dynamicOverrides[optionName] = newOptionHash[optionName];
10108                 }
10109
10110                 t.viewSpecCache = {}; // the dynamic override invalidates the options in this cache, so just clear it
10111                 t.populateOptionsHash(); // this.options needs to be recomputed after the dynamic override
10112
10113                 // trigger handlers after this.options has been updated
10114                 for (optionName in newOptionHash) {
10115                         t.triggerOptionHandlers(optionName); // recall bindOption/bindOptions
10116                         optionCnt++;
10117                 }
10118
10119                 // special-case handling of single option change.
10120                 // if only one option change, `optionName` will be its name.
10121                 if (optionCnt === 1) {
10122                         if (optionName === 'height' || optionName === 'contentHeight' || optionName === 'aspectRatio') {
10123                                 updateSize(true); // true = allow recalculation of height
10124                                 return;
10125                         }
10126                         else if (optionName === 'defaultDate') {
10127                                 return; // can't change date this way. use gotoDate instead
10128                         }
10129                         else if (optionName === 'businessHours') {
10130                                 if (currentView) {
10131                                         currentView.unrenderBusinessHours();
10132                                         currentView.renderBusinessHours();
10133                                 }
10134                                 return;
10135                         }
10136                         else if (optionName === 'timezone') {
10137                                 t.rezoneArrayEventSources();
10138                                 refetchEvents();
10139                                 return;
10140                         }
10141                 }
10142
10143                 // catch-all. rerender the header and rebuild/rerender the current view
10144                 renderHeader();
10145                 viewsByType = {}; // even non-current views will be affected by this option change. do before rerender
10146                 reinitView();
10147         }
10148         
10149         
10150         function trigger(name, thisObj) { // overrides the Emitter's trigger method :(
10151                 var args = Array.prototype.slice.call(arguments, 2);
10152
10153                 thisObj = thisObj || _element;
10154                 this.triggerWith(name, thisObj, args); // Emitter's method
10155
10156                 if (t.options[name]) {
10157                         return t.options[name].apply(thisObj, args);
10158                 }
10159         }
10160
10161         t.initialize();
10162 }
10163
10164 ;;
10165 /*
10166 Options binding/triggering system.
10167 */
10168 Calendar.mixin({
10169
10170         // A map of option names to arrays of handler objects. Initialized to {} in Calendar.
10171         // Format for a handler object:
10172         // {
10173         //   func // callback function to be called upon change
10174         //   names // option names whose values should be given to func
10175         // }
10176         optionHandlers: null, 
10177
10178         // Calls handlerFunc immediately, and when the given option has changed.
10179         // handlerFunc will be given the option value.
10180         bindOption: function(optionName, handlerFunc) {
10181                 this.bindOptions([ optionName ], handlerFunc);
10182         },
10183
10184         // Calls handlerFunc immediately, and when any of the given options change.
10185         // handlerFunc will be given each option value as ordered function arguments.
10186         bindOptions: function(optionNames, handlerFunc) {
10187                 var handlerObj = { func: handlerFunc, names: optionNames };
10188                 var i;
10189
10190                 for (i = 0; i < optionNames.length; i++) {
10191                         this.registerOptionHandlerObj(optionNames[i], handlerObj);
10192                 }
10193
10194                 this.triggerOptionHandlerObj(handlerObj);
10195         },
10196
10197         // Puts the given handler object into the internal hash
10198         registerOptionHandlerObj: function(optionName, handlerObj) {
10199                 (this.optionHandlers[optionName] || (this.optionHandlers[optionName] = []))
10200                         .push(handlerObj);
10201         },
10202
10203         // Reports that the given option has changed, and calls all appropriate handlers.
10204         triggerOptionHandlers: function(optionName) {
10205                 var handlerObjs = this.optionHandlers[optionName] || [];
10206                 var i;
10207
10208                 for (i = 0; i < handlerObjs.length; i++) {
10209                         this.triggerOptionHandlerObj(handlerObjs[i]);
10210                 }
10211         },
10212
10213         // Calls the callback for a specific handler object, passing in the appropriate arguments.
10214         triggerOptionHandlerObj: function(handlerObj) {
10215                 var optionNames = handlerObj.names;
10216                 var optionValues = [];
10217                 var i;
10218
10219                 for (i = 0; i < optionNames.length; i++) {
10220                         optionValues.push(this.options[optionNames[i]]);
10221                 }
10222
10223                 handlerObj.func.apply(this, optionValues); // maintain the Calendar's `this` context
10224         }
10225
10226 });
10227
10228 ;;
10229
10230 Calendar.defaults = {
10231
10232         titleRangeSeparator: ' \u2013 ', // en dash
10233         monthYearFormat: 'MMMM YYYY', // required for en. other locales rely on datepicker computable option
10234
10235         defaultTimedEventDuration: '02:00:00',
10236         defaultAllDayEventDuration: { days: 1 },
10237         forceEventDuration: false,
10238         nextDayThreshold: '09:00:00', // 9am
10239
10240         // display
10241         defaultView: 'month',
10242         aspectRatio: 1.35,
10243         header: {
10244                 left: 'title',
10245                 center: '',
10246                 right: 'today prev,next'
10247         },
10248         weekends: true,
10249         weekNumbers: false,
10250
10251         weekNumberTitle: 'W',
10252         weekNumberCalculation: 'local',
10253         
10254         //editable: false,
10255
10256         //nowIndicator: false,
10257
10258         scrollTime: '06:00:00',
10259         
10260         // event ajax
10261         lazyFetching: true,
10262         startParam: 'start',
10263         endParam: 'end',
10264         timezoneParam: 'timezone',
10265
10266         timezone: false,
10267
10268         //allDayDefault: undefined,
10269
10270         // locale
10271         isRTL: false,
10272         buttonText: {
10273                 prev: "prev",
10274                 next: "next",
10275                 prevYear: "prev year",
10276                 nextYear: "next year",
10277                 year: 'year', // TODO: locale files need to specify this
10278                 today: 'today',
10279                 month: 'month',
10280                 week: 'week',
10281                 day: 'day'
10282         },
10283
10284         buttonIcons: {
10285                 prev: 'left-single-arrow',
10286                 next: 'right-single-arrow',
10287                 prevYear: 'left-double-arrow',
10288                 nextYear: 'right-double-arrow'
10289         },
10290
10291         allDayText: 'all-day',
10292         
10293         // jquery-ui theming
10294         theme: false,
10295         themeButtonIcons: {
10296                 prev: 'circle-triangle-w',
10297                 next: 'circle-triangle-e',
10298                 prevYear: 'seek-prev',
10299                 nextYear: 'seek-next'
10300         },
10301
10302         //eventResizableFromStart: false,
10303         dragOpacity: .75,
10304         dragRevertDuration: 500,
10305         dragScroll: true,
10306         
10307         //selectable: false,
10308         unselectAuto: true,
10309         
10310         dropAccept: '*',
10311
10312         eventOrder: 'title',
10313
10314         eventLimit: false,
10315         eventLimitText: 'more',
10316         eventLimitClick: 'popover',
10317         dayPopoverFormat: 'LL',
10318         
10319         handleWindowResize: true,
10320         windowResizeDelay: 100, // milliseconds before an updateSize happens
10321
10322         longPressDelay: 1000
10323         
10324 };
10325
10326
10327 Calendar.englishDefaults = { // used by locale.js
10328         dayPopoverFormat: 'dddd, MMMM D'
10329 };
10330
10331
10332 Calendar.rtlDefaults = { // right-to-left defaults
10333         header: { // TODO: smarter solution (first/center/last ?)
10334                 left: 'next,prev today',
10335                 center: '',
10336                 right: 'title'
10337         },
10338         buttonIcons: {
10339                 prev: 'right-single-arrow',
10340                 next: 'left-single-arrow',
10341                 prevYear: 'right-double-arrow',
10342                 nextYear: 'left-double-arrow'
10343         },
10344         themeButtonIcons: {
10345                 prev: 'circle-triangle-e',
10346                 next: 'circle-triangle-w',
10347                 nextYear: 'seek-prev',
10348                 prevYear: 'seek-next'
10349         }
10350 };
10351
10352 ;;
10353
10354 var localeOptionHash = FC.locales = {}; // initialize and expose
10355
10356
10357 // TODO: document the structure and ordering of a FullCalendar locale file
10358
10359
10360 // Initialize jQuery UI datepicker translations while using some of the translations
10361 // Will set this as the default locales for datepicker.
10362 FC.datepickerLocale = function(localeCode, dpLocaleCode, dpOptions) {
10363
10364         // get the FullCalendar internal option hash for this locale. create if necessary
10365         var fcOptions = localeOptionHash[localeCode] || (localeOptionHash[localeCode] = {});
10366
10367         // transfer some simple options from datepicker to fc
10368         fcOptions.isRTL = dpOptions.isRTL;
10369         fcOptions.weekNumberTitle = dpOptions.weekHeader;
10370
10371         // compute some more complex options from datepicker
10372         $.each(dpComputableOptions, function(name, func) {
10373                 fcOptions[name] = func(dpOptions);
10374         });
10375
10376         // is jQuery UI Datepicker is on the page?
10377         if ($.datepicker) {
10378
10379                 // Register the locale data.
10380                 // FullCalendar and MomentJS use locale codes like "pt-br" but Datepicker
10381                 // does it like "pt-BR" or if it doesn't have the locale, maybe just "pt".
10382                 // Make an alias so the locale can be referenced either way.
10383                 $.datepicker.regional[dpLocaleCode] =
10384                         $.datepicker.regional[localeCode] = // alias
10385                                 dpOptions;
10386
10387                 // Alias 'en' to the default locale data. Do this every time.
10388                 $.datepicker.regional.en = $.datepicker.regional[''];
10389
10390                 // Set as Datepicker's global defaults.
10391                 $.datepicker.setDefaults(dpOptions);
10392         }
10393 };
10394
10395
10396 // Sets FullCalendar-specific translations. Will set the locales as the global default.
10397 FC.locale = function(localeCode, newFcOptions) {
10398         var fcOptions;
10399         var momOptions;
10400
10401         // get the FullCalendar internal option hash for this locale. create if necessary
10402         fcOptions = localeOptionHash[localeCode] || (localeOptionHash[localeCode] = {});
10403
10404         // provided new options for this locales? merge them in
10405         if (newFcOptions) {
10406                 fcOptions = localeOptionHash[localeCode] = mergeOptions([ fcOptions, newFcOptions ]);
10407         }
10408
10409         // compute locale options that weren't defined.
10410         // always do this. newFcOptions can be undefined when initializing from i18n file,
10411         // so no way to tell if this is an initialization or a default-setting.
10412         momOptions = getMomentLocaleData(localeCode); // will fall back to en
10413         $.each(momComputableOptions, function(name, func) {
10414                 if (fcOptions[name] == null) {
10415                         fcOptions[name] = func(momOptions, fcOptions);
10416                 }
10417         });
10418
10419         // set it as the default locale for FullCalendar
10420         Calendar.defaults.locale = localeCode;
10421 };
10422
10423
10424 // NOTE: can't guarantee any of these computations will run because not every locale has datepicker
10425 // configs, so make sure there are English fallbacks for these in the defaults file.
10426 var dpComputableOptions = {
10427
10428         buttonText: function(dpOptions) {
10429                 return {
10430                         // the translations sometimes wrongly contain HTML entities
10431                         prev: stripHtmlEntities(dpOptions.prevText),
10432                         next: stripHtmlEntities(dpOptions.nextText),
10433                         today: stripHtmlEntities(dpOptions.currentText)
10434                 };
10435         },
10436
10437         // Produces format strings like "MMMM YYYY" -> "September 2014"
10438         monthYearFormat: function(dpOptions) {
10439                 return dpOptions.showMonthAfterYear ?
10440                         'YYYY[' + dpOptions.yearSuffix + '] MMMM' :
10441                         'MMMM YYYY[' + dpOptions.yearSuffix + ']';
10442         }
10443
10444 };
10445
10446 var momComputableOptions = {
10447
10448         // Produces format strings like "ddd M/D" -> "Fri 9/15"
10449         dayOfMonthFormat: function(momOptions, fcOptions) {
10450                 var format = momOptions.longDateFormat('l'); // for the format like "M/D/YYYY"
10451
10452                 // strip the year off the edge, as well as other misc non-whitespace chars
10453                 format = format.replace(/^Y+[^\w\s]*|[^\w\s]*Y+$/g, '');
10454
10455                 if (fcOptions.isRTL) {
10456                         format += ' ddd'; // for RTL, add day-of-week to end
10457                 }
10458                 else {
10459                         format = 'ddd ' + format; // for LTR, add day-of-week to beginning
10460                 }
10461                 return format;
10462         },
10463
10464         // Produces format strings like "h:mma" -> "6:00pm"
10465         mediumTimeFormat: function(momOptions) { // can't be called `timeFormat` because collides with option
10466                 return momOptions.longDateFormat('LT')
10467                         .replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand
10468         },
10469
10470         // Produces format strings like "h(:mm)a" -> "6pm" / "6:30pm"
10471         smallTimeFormat: function(momOptions) {
10472                 return momOptions.longDateFormat('LT')
10473                         .replace(':mm', '(:mm)')
10474                         .replace(/(\Wmm)$/, '($1)') // like above, but for foreign locales
10475                         .replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand
10476         },
10477
10478         // Produces format strings like "h(:mm)t" -> "6p" / "6:30p"
10479         extraSmallTimeFormat: function(momOptions) {
10480                 return momOptions.longDateFormat('LT')
10481                         .replace(':mm', '(:mm)')
10482                         .replace(/(\Wmm)$/, '($1)') // like above, but for foreign locales
10483                         .replace(/\s*a$/i, 't'); // convert to AM/PM/am/pm to lowercase one-letter. remove any spaces beforehand
10484         },
10485
10486         // Produces format strings like "ha" / "H" -> "6pm" / "18"
10487         hourFormat: function(momOptions) {
10488                 return momOptions.longDateFormat('LT')
10489                         .replace(':mm', '')
10490                         .replace(/(\Wmm)$/, '') // like above, but for foreign locales
10491                         .replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand
10492         },
10493
10494         // Produces format strings like "h:mm" -> "6:30" (with no AM/PM)
10495         noMeridiemTimeFormat: function(momOptions) {
10496                 return momOptions.longDateFormat('LT')
10497                         .replace(/\s*a$/i, ''); // remove trailing AM/PM
10498         }
10499
10500 };
10501
10502
10503 // options that should be computed off live calendar options (considers override options)
10504 // TODO: best place for this? related to locale?
10505 // TODO: flipping text based on isRTL is a bad idea because the CSS `direction` might want to handle it
10506 var instanceComputableOptions = {
10507
10508         // Produces format strings for results like "Mo 16"
10509         smallDayDateFormat: function(options) {
10510                 return options.isRTL ?
10511                         'D dd' :
10512                         'dd D';
10513         },
10514
10515         // Produces format strings for results like "Wk 5"
10516         weekFormat: function(options) {
10517                 return options.isRTL ?
10518                         'w[ ' + options.weekNumberTitle + ']' :
10519                         '[' + options.weekNumberTitle + ' ]w';
10520         },
10521
10522         // Produces format strings for results like "Wk5"
10523         smallWeekFormat: function(options) {
10524                 return options.isRTL ?
10525                         'w[' + options.weekNumberTitle + ']' :
10526                         '[' + options.weekNumberTitle + ']w';
10527         }
10528
10529 };
10530
10531 function populateInstanceComputableOptions(options) {
10532         $.each(instanceComputableOptions, function(name, func) {
10533                 if (options[name] == null) {
10534                         options[name] = func(options);
10535                 }
10536         });
10537 }
10538
10539
10540 // Returns moment's internal locale data. If doesn't exist, returns English.
10541 function getMomentLocaleData(localeCode) {
10542         return moment.localeData(localeCode) || moment.localeData('en');
10543 }
10544
10545
10546 // Initialize English by forcing computation of moment-derived options.
10547 // Also, sets it as the default.
10548 FC.locale('en', Calendar.englishDefaults);
10549
10550 ;;
10551
10552 /* Top toolbar area with buttons and title
10553 ----------------------------------------------------------------------------------------------------------------------*/
10554 // TODO: rename all header-related things to "toolbar"
10555
10556 function Header(calendar) {
10557         var t = this;
10558         
10559         // exports
10560         t.render = render;
10561         t.removeElement = removeElement;
10562         t.updateTitle = updateTitle;
10563         t.activateButton = activateButton;
10564         t.deactivateButton = deactivateButton;
10565         t.disableButton = disableButton;
10566         t.enableButton = enableButton;
10567         t.getViewsWithButtons = getViewsWithButtons;
10568         t.el = null; // mirrors local `el`
10569         
10570         // locals
10571         var el;
10572         var viewsWithButtons = [];
10573         var tm;
10574
10575
10576         // can be called repeatedly and will rerender
10577         function render() {
10578                 var options = calendar.options;
10579                 var sections = options.header;
10580
10581                 tm = options.theme ? 'ui' : 'fc';
10582
10583                 if (sections) {
10584                         if (!el) {
10585                                 el = this.el = $("<div class='fc-toolbar'/>");
10586                         }
10587                         else {
10588                                 el.empty();
10589                         }
10590                         el.append(renderSection('left'))
10591                                 .append(renderSection('right'))
10592                                 .append(renderSection('center'))
10593                                 .append('<div class="fc-clear"/>');
10594                 }
10595                 else {
10596                         removeElement();
10597                 }
10598         }
10599         
10600         
10601         function removeElement() {
10602                 if (el) {
10603                         el.remove();
10604                         el = t.el = null;
10605                 }
10606         }
10607         
10608         
10609         function renderSection(position) {
10610                 var sectionEl = $('<div class="fc-' + position + '"/>');
10611                 var options = calendar.options;
10612                 var buttonStr = options.header[position];
10613
10614                 if (buttonStr) {
10615                         $.each(buttonStr.split(' '), function(i) {
10616                                 var groupChildren = $();
10617                                 var isOnlyButtons = true;
10618                                 var groupEl;
10619
10620                                 $.each(this.split(','), function(j, buttonName) {
10621                                         var customButtonProps;
10622                                         var viewSpec;
10623                                         var buttonClick;
10624                                         var overrideText; // text explicitly set by calendar's constructor options. overcomes icons
10625                                         var defaultText;
10626                                         var themeIcon;
10627                                         var normalIcon;
10628                                         var innerHtml;
10629                                         var classes;
10630                                         var button; // the element
10631
10632                                         if (buttonName == 'title') {
10633                                                 groupChildren = groupChildren.add($('<h2>&nbsp;</h2>')); // we always want it to take up height
10634                                                 isOnlyButtons = false;
10635                                         }
10636                                         else {
10637                                                 if ((customButtonProps = (options.customButtons || {})[buttonName])) {
10638                                                         buttonClick = function(ev) {
10639                                                                 if (customButtonProps.click) {
10640                                                                         customButtonProps.click.call(button[0], ev);
10641                                                                 }
10642                                                         };
10643                                                         overrideText = ''; // icons will override text
10644                                                         defaultText = customButtonProps.text;
10645                                                 }
10646                                                 else if ((viewSpec = calendar.getViewSpec(buttonName))) {
10647                                                         buttonClick = function() {
10648                                                                 calendar.changeView(buttonName);
10649                                                         };
10650                                                         viewsWithButtons.push(buttonName);
10651                                                         overrideText = viewSpec.buttonTextOverride;
10652                                                         defaultText = viewSpec.buttonTextDefault;
10653                                                 }
10654                                                 else if (calendar[buttonName]) { // a calendar method
10655                                                         buttonClick = function() {
10656                                                                 calendar[buttonName]();
10657                                                         };
10658                                                         overrideText = (calendar.overrides.buttonText || {})[buttonName];
10659                                                         defaultText = options.buttonText[buttonName]; // everything else is considered default
10660                                                 }
10661
10662                                                 if (buttonClick) {
10663
10664                                                         themeIcon =
10665                                                                 customButtonProps ?
10666                                                                         customButtonProps.themeIcon :
10667                                                                         options.themeButtonIcons[buttonName];
10668
10669                                                         normalIcon =
10670                                                                 customButtonProps ?
10671                                                                         customButtonProps.icon :
10672                                                                         options.buttonIcons[buttonName];
10673
10674                                                         if (overrideText) {
10675                                                                 innerHtml = htmlEscape(overrideText);
10676                                                         }
10677                                                         else if (themeIcon && options.theme) {
10678                                                                 innerHtml = "<span class='ui-icon ui-icon-" + themeIcon + "'></span>";
10679                                                         }
10680                                                         else if (normalIcon && !options.theme) {
10681                                                                 innerHtml = "<span class='fc-icon fc-icon-" + normalIcon + "'></span>";
10682                                                         }
10683                                                         else {
10684                                                                 innerHtml = htmlEscape(defaultText);
10685                                                         }
10686
10687                                                         classes = [
10688                                                                 'fc-' + buttonName + '-button',
10689                                                                 tm + '-button',
10690                                                                 tm + '-state-default'
10691                                                         ];
10692
10693                                                         button = $( // type="button" so that it doesn't submit a form
10694                                                                 '<button type="button" class="' + classes.join(' ') + '">' +
10695                                                                         innerHtml +
10696                                                                 '</button>'
10697                                                                 )
10698                                                                 .click(function(ev) {
10699                                                                         // don't process clicks for disabled buttons
10700                                                                         if (!button.hasClass(tm + '-state-disabled')) {
10701
10702                                                                                 buttonClick(ev);
10703
10704                                                                                 // after the click action, if the button becomes the "active" tab, or disabled,
10705                                                                                 // it should never have a hover class, so remove it now.
10706                                                                                 if (
10707                                                                                         button.hasClass(tm + '-state-active') ||
10708                                                                                         button.hasClass(tm + '-state-disabled')
10709                                                                                 ) {
10710                                                                                         button.removeClass(tm + '-state-hover');
10711                                                                                 }
10712                                                                         }
10713                                                                 })
10714                                                                 .mousedown(function() {
10715                                                                         // the *down* effect (mouse pressed in).
10716                                                                         // only on buttons that are not the "active" tab, or disabled
10717                                                                         button
10718                                                                                 .not('.' + tm + '-state-active')
10719                                                                                 .not('.' + tm + '-state-disabled')
10720                                                                                 .addClass(tm + '-state-down');
10721                                                                 })
10722                                                                 .mouseup(function() {
10723                                                                         // undo the *down* effect
10724                                                                         button.removeClass(tm + '-state-down');
10725                                                                 })
10726                                                                 .hover(
10727                                                                         function() {
10728                                                                                 // the *hover* effect.
10729                                                                                 // only on buttons that are not the "active" tab, or disabled
10730                                                                                 button
10731                                                                                         .not('.' + tm + '-state-active')
10732                                                                                         .not('.' + tm + '-state-disabled')
10733                                                                                         .addClass(tm + '-state-hover');
10734                                                                         },
10735                                                                         function() {
10736                                                                                 // undo the *hover* effect
10737                                                                                 button
10738                                                                                         .removeClass(tm + '-state-hover')
10739                                                                                         .removeClass(tm + '-state-down'); // if mouseleave happens before mouseup
10740                                                                         }
10741                                                                 );
10742
10743                                                         groupChildren = groupChildren.add(button);
10744                                                 }
10745                                         }
10746                                 });
10747
10748                                 if (isOnlyButtons) {
10749                                         groupChildren
10750                                                 .first().addClass(tm + '-corner-left').end()
10751                                                 .last().addClass(tm + '-corner-right').end();
10752                                 }
10753
10754                                 if (groupChildren.length > 1) {
10755                                         groupEl = $('<div/>');
10756                                         if (isOnlyButtons) {
10757                                                 groupEl.addClass('fc-button-group');
10758                                         }
10759                                         groupEl.append(groupChildren);
10760                                         sectionEl.append(groupEl);
10761                                 }
10762                                 else {
10763                                         sectionEl.append(groupChildren); // 1 or 0 children
10764                                 }
10765                         });
10766                 }
10767
10768                 return sectionEl;
10769         }
10770         
10771         
10772         function updateTitle(text) {
10773                 if (el) {
10774                         el.find('h2').text(text);
10775                 }
10776         }
10777         
10778         
10779         function activateButton(buttonName) {
10780                 if (el) {
10781                         el.find('.fc-' + buttonName + '-button')
10782                                 .addClass(tm + '-state-active');
10783                 }
10784         }
10785         
10786         
10787         function deactivateButton(buttonName) {
10788                 if (el) {
10789                         el.find('.fc-' + buttonName + '-button')
10790                                 .removeClass(tm + '-state-active');
10791                 }
10792         }
10793         
10794         
10795         function disableButton(buttonName) {
10796                 if (el) {
10797                         el.find('.fc-' + buttonName + '-button')
10798                                 .prop('disabled', true)
10799                                 .addClass(tm + '-state-disabled');
10800                 }
10801         }
10802         
10803         
10804         function enableButton(buttonName) {
10805                 if (el) {
10806                         el.find('.fc-' + buttonName + '-button')
10807                                 .prop('disabled', false)
10808                                 .removeClass(tm + '-state-disabled');
10809                 }
10810         }
10811
10812
10813         function getViewsWithButtons() {
10814                 return viewsWithButtons;
10815         }
10816
10817 }
10818
10819 ;;
10820
10821 FC.sourceNormalizers = [];
10822 FC.sourceFetchers = [];
10823
10824 var ajaxDefaults = {
10825         dataType: 'json',
10826         cache: false
10827 };
10828
10829 var eventGUID = 1;
10830
10831
10832 function EventManager() { // assumed to be a calendar
10833         var t = this;
10834         
10835         
10836         // exports
10837         t.isFetchNeeded = isFetchNeeded;
10838         t.fetchEvents = fetchEvents;
10839         t.fetchEventSources = fetchEventSources;
10840         t.getEventSources = getEventSources;
10841         t.getEventSourceById = getEventSourceById;
10842         t.getEventSourcesByMatchArray = getEventSourcesByMatchArray;
10843         t.getEventSourcesByMatch = getEventSourcesByMatch;
10844         t.addEventSource = addEventSource;
10845         t.removeEventSource = removeEventSource;
10846         t.removeEventSources = removeEventSources;
10847         t.updateEvent = updateEvent;
10848         t.renderEvent = renderEvent;
10849         t.removeEvents = removeEvents;
10850         t.clientEvents = clientEvents;
10851         t.mutateEvent = mutateEvent;
10852         t.normalizeEventDates = normalizeEventDates;
10853         t.normalizeEventTimes = normalizeEventTimes;
10854         
10855         
10856         // imports
10857         var reportEvents = t.reportEvents;
10858         
10859         
10860         // locals
10861         var stickySource = { events: [] };
10862         var sources = [ stickySource ];
10863         var rangeStart, rangeEnd;
10864         var pendingSourceCnt = 0; // outstanding fetch requests, max one per source
10865         var cache = []; // holds events that have already been expanded
10866
10867
10868         $.each(
10869                 (t.options.events ? [ t.options.events ] : []).concat(t.options.eventSources || []),
10870                 function(i, sourceInput) {
10871                         var source = buildEventSource(sourceInput);
10872                         if (source) {
10873                                 sources.push(source);
10874                         }
10875                 }
10876         );
10877         
10878         
10879         
10880         /* Fetching
10881         -----------------------------------------------------------------------------*/
10882
10883
10884         // start and end are assumed to be unzoned
10885         function isFetchNeeded(start, end) {
10886                 return !rangeStart || // nothing has been fetched yet?
10887                         start < rangeStart || end > rangeEnd; // is part of the new range outside of the old range?
10888         }
10889         
10890         
10891         function fetchEvents(start, end) {
10892                 rangeStart = start;
10893                 rangeEnd = end;
10894                 fetchEventSources(sources, 'reset');
10895         }
10896
10897
10898         // expects an array of event source objects (the originals, not copies)
10899         // `specialFetchType` is an optimization parameter that affects purging of the event cache.
10900         function fetchEventSources(specificSources, specialFetchType) {
10901                 var i, source;
10902
10903                 if (specialFetchType === 'reset') {
10904                         cache = [];
10905                 }
10906                 else if (specialFetchType !== 'add') {
10907                         cache = excludeEventsBySources(cache, specificSources);
10908                 }
10909
10910                 for (i = 0; i < specificSources.length; i++) {
10911                         source = specificSources[i];
10912
10913                         // already-pending sources have already been accounted for in pendingSourceCnt
10914                         if (source._status !== 'pending') {
10915                                 pendingSourceCnt++;
10916                         }
10917
10918                         source._fetchId = (source._fetchId || 0) + 1;
10919                         source._status = 'pending';
10920                 }
10921
10922                 for (i = 0; i < specificSources.length; i++) {
10923                         source = specificSources[i];
10924
10925                         tryFetchEventSource(source, source._fetchId);
10926                 }
10927         }
10928
10929
10930         // fetches an event source and processes its result ONLY if it is still the current fetch.
10931         // caller is responsible for incrementing pendingSourceCnt first.
10932         function tryFetchEventSource(source, fetchId) {
10933                 _fetchEventSource(source, function(eventInputs) {
10934                         var isArraySource = $.isArray(source.events);
10935                         var i, eventInput;
10936                         var abstractEvent;
10937
10938                         if (
10939                                 // is this the source's most recent fetch?
10940                                 // if not, rely on an upcoming fetch of this source to decrement pendingSourceCnt
10941                                 fetchId === source._fetchId &&
10942                                 // event source no longer valid?
10943                                 source._status !== 'rejected'
10944                         ) {
10945                                 source._status = 'resolved';
10946
10947                                 if (eventInputs) {
10948                                         for (i = 0; i < eventInputs.length; i++) {
10949                                                 eventInput = eventInputs[i];
10950
10951                                                 if (isArraySource) { // array sources have already been convert to Event Objects
10952                                                         abstractEvent = eventInput;
10953                                                 }
10954                                                 else {
10955                                                         abstractEvent = buildEventFromInput(eventInput, source);
10956                                                 }
10957
10958                                                 if (abstractEvent) { // not false (an invalid event)
10959                                                         cache.push.apply(
10960                                                                 cache,
10961                                                                 expandEvent(abstractEvent) // add individual expanded events to the cache
10962                                                         );
10963                                                 }
10964                                         }
10965                                 }
10966
10967                                 decrementPendingSourceCnt();
10968                         }
10969                 });
10970         }
10971
10972
10973         function rejectEventSource(source) {
10974                 var wasPending = source._status === 'pending';
10975
10976                 source._status = 'rejected';
10977
10978                 if (wasPending) {
10979                         decrementPendingSourceCnt();
10980                 }
10981         }
10982
10983
10984         function decrementPendingSourceCnt() {
10985                 pendingSourceCnt--;
10986                 if (!pendingSourceCnt) {
10987                         reportEvents(cache);
10988                 }
10989         }
10990         
10991         
10992         function _fetchEventSource(source, callback) {
10993                 var i;
10994                 var fetchers = FC.sourceFetchers;
10995                 var res;
10996
10997                 for (i=0; i<fetchers.length; i++) {
10998                         res = fetchers[i].call(
10999                                 t, // this, the Calendar object
11000                                 source,
11001                                 rangeStart.clone(),
11002                                 rangeEnd.clone(),
11003                                 t.options.timezone,
11004                                 callback
11005                         );
11006
11007                         if (res === true) {
11008                                 // the fetcher is in charge. made its own async request
11009                                 return;
11010                         }
11011                         else if (typeof res == 'object') {
11012                                 // the fetcher returned a new source. process it
11013                                 _fetchEventSource(res, callback);
11014                                 return;
11015                         }
11016                 }
11017
11018                 var events = source.events;
11019                 if (events) {
11020                         if ($.isFunction(events)) {
11021                                 t.pushLoading();
11022                                 events.call(
11023                                         t, // this, the Calendar object
11024                                         rangeStart.clone(),
11025                                         rangeEnd.clone(),
11026                                         t.options.timezone,
11027                                         function(events) {
11028                                                 callback(events);
11029                                                 t.popLoading();
11030                                         }
11031                                 );
11032                         }
11033                         else if ($.isArray(events)) {
11034                                 callback(events);
11035                         }
11036                         else {
11037                                 callback();
11038                         }
11039                 }else{
11040                         var url = source.url;
11041                         if (url) {
11042                                 var success = source.success;
11043                                 var error = source.error;
11044                                 var complete = source.complete;
11045
11046                                 // retrieve any outbound GET/POST $.ajax data from the options
11047                                 var customData;
11048                                 if ($.isFunction(source.data)) {
11049                                         // supplied as a function that returns a key/value object
11050                                         customData = source.data();
11051                                 }
11052                                 else {
11053                                         // supplied as a straight key/value object
11054                                         customData = source.data;
11055                                 }
11056
11057                                 // use a copy of the custom data so we can modify the parameters
11058                                 // and not affect the passed-in object.
11059                                 var data = $.extend({}, customData || {});
11060
11061                                 var startParam = firstDefined(source.startParam, t.options.startParam);
11062                                 var endParam = firstDefined(source.endParam, t.options.endParam);
11063                                 var timezoneParam = firstDefined(source.timezoneParam, t.options.timezoneParam);
11064
11065                                 if (startParam) {
11066                                         data[startParam] = rangeStart.format();
11067                                 }
11068                                 if (endParam) {
11069                                         data[endParam] = rangeEnd.format();
11070                                 }
11071                                 if (t.options.timezone && t.options.timezone != 'local') {
11072                                         data[timezoneParam] = t.options.timezone;
11073                                 }
11074
11075                                 t.pushLoading();
11076                                 $.ajax($.extend({}, ajaxDefaults, source, {
11077                                         data: data,
11078                                         success: function(events) {
11079                                                 events = events || [];
11080                                                 var res = applyAll(success, this, arguments);
11081                                                 if ($.isArray(res)) {
11082                                                         events = res;
11083                                                 }
11084                                                 callback(events);
11085                                         },
11086                                         error: function() {
11087                                                 applyAll(error, this, arguments);
11088                                                 callback();
11089                                         },
11090                                         complete: function() {
11091                                                 applyAll(complete, this, arguments);
11092                                                 t.popLoading();
11093                                         }
11094                                 }));
11095                         }else{
11096                                 callback();
11097                         }
11098                 }
11099         }
11100         
11101         
11102         
11103         /* Sources
11104         -----------------------------------------------------------------------------*/
11105
11106
11107         function addEventSource(sourceInput) {
11108                 var source = buildEventSource(sourceInput);
11109                 if (source) {
11110                         sources.push(source);
11111                         fetchEventSources([ source ], 'add'); // will eventually call reportEvents
11112                 }
11113         }
11114
11115
11116         function buildEventSource(sourceInput) { // will return undefined if invalid source
11117                 var normalizers = FC.sourceNormalizers;
11118                 var source;
11119                 var i;
11120
11121                 if ($.isFunction(sourceInput) || $.isArray(sourceInput)) {
11122                         source = { events: sourceInput };
11123                 }
11124                 else if (typeof sourceInput === 'string') {
11125                         source = { url: sourceInput };
11126                 }
11127                 else if (typeof sourceInput === 'object') {
11128                         source = $.extend({}, sourceInput); // shallow copy
11129                 }
11130
11131                 if (source) {
11132
11133                         // TODO: repeat code, same code for event classNames
11134                         if (source.className) {
11135                                 if (typeof source.className === 'string') {
11136                                         source.className = source.className.split(/\s+/);
11137                                 }
11138                                 // otherwise, assumed to be an array
11139                         }
11140                         else {
11141                                 source.className = [];
11142                         }
11143
11144                         // for array sources, we convert to standard Event Objects up front
11145                         if ($.isArray(source.events)) {
11146                                 source.origArray = source.events; // for removeEventSource
11147                                 source.events = $.map(source.events, function(eventInput) {
11148                                         return buildEventFromInput(eventInput, source);
11149                                 });
11150                         }
11151
11152                         for (i=0; i<normalizers.length; i++) {
11153                                 normalizers[i].call(t, source);
11154                         }
11155
11156                         return source;
11157                 }
11158         }
11159
11160
11161         function removeEventSource(matchInput) {
11162                 removeSpecificEventSources(
11163                         getEventSourcesByMatch(matchInput)
11164                 );
11165         }
11166
11167
11168         // if called with no arguments, removes all.
11169         function removeEventSources(matchInputs) {
11170                 if (matchInputs == null) {
11171                         removeSpecificEventSources(sources, true); // isAll=true
11172                 }
11173                 else {
11174                         removeSpecificEventSources(
11175                                 getEventSourcesByMatchArray(matchInputs)
11176                         );
11177                 }
11178         }
11179
11180
11181         function removeSpecificEventSources(targetSources, isAll) {
11182                 var i;
11183
11184                 // cancel pending requests
11185                 for (i = 0; i < targetSources.length; i++) {
11186                         rejectEventSource(targetSources[i]);
11187                 }
11188
11189                 if (isAll) { // an optimization
11190                         sources = [];
11191                         cache = [];
11192                 }
11193                 else {
11194                         // remove from persisted source list
11195                         sources = $.grep(sources, function(source) {
11196                                 for (i = 0; i < targetSources.length; i++) {
11197                                         if (source === targetSources[i]) {
11198                                                 return false; // exclude
11199                                         }
11200                                 }
11201                                 return true; // include
11202                         });
11203
11204                         cache = excludeEventsBySources(cache, targetSources);
11205                 }
11206
11207                 reportEvents(cache);
11208         }
11209
11210
11211         function getEventSources() {
11212                 return sources.slice(1); // returns a shallow copy of sources with stickySource removed
11213         }
11214
11215
11216         function getEventSourceById(id) {
11217                 return $.grep(sources, function(source) {
11218                         return source.id && source.id === id;
11219                 })[0];
11220         }
11221
11222
11223         // like getEventSourcesByMatch, but accepts multple match criteria (like multiple IDs)
11224         function getEventSourcesByMatchArray(matchInputs) {
11225
11226                 // coerce into an array
11227                 if (!matchInputs) {
11228                         matchInputs = [];
11229                 }
11230                 else if (!$.isArray(matchInputs)) {
11231                         matchInputs = [ matchInputs ];
11232                 }
11233
11234                 var matchingSources = [];
11235                 var i;
11236
11237                 // resolve raw inputs to real event source objects
11238                 for (i = 0; i < matchInputs.length; i++) {
11239                         matchingSources.push.apply( // append
11240                                 matchingSources,
11241                                 getEventSourcesByMatch(matchInputs[i])
11242                         );
11243                 }
11244
11245                 return matchingSources;
11246         }
11247
11248
11249         // matchInput can either by a real event source object, an ID, or the function/URL for the source.
11250         // returns an array of matching source objects.
11251         function getEventSourcesByMatch(matchInput) {
11252                 var i, source;
11253
11254                 // given an proper event source object
11255                 for (i = 0; i < sources.length; i++) {
11256                         source = sources[i];
11257                         if (source === matchInput) {
11258                                 return [ source ];
11259                         }
11260                 }
11261
11262                 // an ID match
11263                 source = getEventSourceById(matchInput);
11264                 if (source) {
11265                         return [ source ];
11266                 }
11267
11268                 return $.grep(sources, function(source) {
11269                         return isSourcesEquivalent(matchInput, source);
11270                 });
11271         }
11272
11273
11274         function isSourcesEquivalent(source1, source2) {
11275                 return source1 && source2 && getSourcePrimitive(source1) == getSourcePrimitive(source2);
11276         }
11277
11278
11279         function getSourcePrimitive(source) {
11280                 return (
11281                         (typeof source === 'object') ? // a normalized event source?
11282                                 (source.origArray || source.googleCalendarId || source.url || source.events) : // get the primitive
11283                                 null
11284                 ) ||
11285                 source; // the given argument *is* the primitive
11286         }
11287
11288
11289         // util
11290         // returns a filtered array without events that are part of any of the given sources
11291         function excludeEventsBySources(specificEvents, specificSources) {
11292                 return $.grep(specificEvents, function(event) {
11293                         for (var i = 0; i < specificSources.length; i++) {
11294                                 if (event.source === specificSources[i]) {
11295                                         return false; // exclude
11296                                 }
11297                         }
11298                         return true; // keep
11299                 });
11300         }
11301         
11302         
11303         
11304         /* Manipulation
11305         -----------------------------------------------------------------------------*/
11306
11307
11308         // Only ever called from the externally-facing API
11309         function updateEvent(event) {
11310
11311                 // massage start/end values, even if date string values
11312                 event.start = t.moment(event.start);
11313                 if (event.end) {
11314                         event.end = t.moment(event.end);
11315                 }
11316                 else {
11317                         event.end = null;
11318                 }
11319
11320                 mutateEvent(event, getMiscEventProps(event)); // will handle start/end/allDay normalization
11321                 reportEvents(cache); // reports event modifications (so we can redraw)
11322         }
11323
11324
11325         // Returns a hash of misc event properties that should be copied over to related events.
11326         function getMiscEventProps(event) {
11327                 var props = {};
11328
11329                 $.each(event, function(name, val) {
11330                         if (isMiscEventPropName(name)) {
11331                                 if (val !== undefined && isAtomic(val)) { // a defined non-object
11332                                         props[name] = val;
11333                                 }
11334                         }
11335                 });
11336
11337                 return props;
11338         }
11339
11340         // non-date-related, non-id-related, non-secret
11341         function isMiscEventPropName(name) {
11342                 return !/^_|^(id|allDay|start|end)$/.test(name);
11343         }
11344
11345         
11346         // returns the expanded events that were created
11347         function renderEvent(eventInput, stick) {
11348                 var abstractEvent = buildEventFromInput(eventInput);
11349                 var events;
11350                 var i, event;
11351
11352                 if (abstractEvent) { // not false (a valid input)
11353                         events = expandEvent(abstractEvent);
11354
11355                         for (i = 0; i < events.length; i++) {
11356                                 event = events[i];
11357
11358                                 if (!event.source) {
11359                                         if (stick) {
11360                                                 stickySource.events.push(event);
11361                                                 event.source = stickySource;
11362                                         }
11363                                         cache.push(event);
11364                                 }
11365                         }
11366
11367                         reportEvents(cache);
11368
11369                         return events;
11370                 }
11371
11372                 return [];
11373         }
11374         
11375         
11376         function removeEvents(filter) {
11377                 var eventID;
11378                 var i;
11379
11380                 if (filter == null) { // null or undefined. remove all events
11381                         filter = function() { return true; }; // will always match
11382                 }
11383                 else if (!$.isFunction(filter)) { // an event ID
11384                         eventID = filter + '';
11385                         filter = function(event) {
11386                                 return event._id == eventID;
11387                         };
11388                 }
11389
11390                 // Purge event(s) from our local cache
11391                 cache = $.grep(cache, filter, true); // inverse=true
11392
11393                 // Remove events from array sources.
11394                 // This works because they have been converted to official Event Objects up front.
11395                 // (and as a result, event._id has been calculated).
11396                 for (i=0; i<sources.length; i++) {
11397                         if ($.isArray(sources[i].events)) {
11398                                 sources[i].events = $.grep(sources[i].events, filter, true);
11399                         }
11400                 }
11401
11402                 reportEvents(cache);
11403         }
11404
11405         
11406         function clientEvents(filter) {
11407                 if ($.isFunction(filter)) {
11408                         return $.grep(cache, filter);
11409                 }
11410                 else if (filter != null) { // not null, not undefined. an event ID
11411                         filter += '';
11412                         return $.grep(cache, function(e) {
11413                                 return e._id == filter;
11414                         });
11415                 }
11416                 return cache; // else, return all
11417         }
11418
11419
11420         // Makes sure all array event sources have their internal event objects
11421         // converted over to the Calendar's current timezone.
11422         t.rezoneArrayEventSources = function() {
11423                 var i;
11424                 var events;
11425                 var j;
11426
11427                 for (i = 0; i < sources.length; i++) {
11428                         events = sources[i].events;
11429                         if ($.isArray(events)) {
11430
11431                                 for (j = 0; j < events.length; j++) {
11432                                         rezoneEventDates(events[j]);
11433                                 }
11434                         }
11435                 }
11436         };
11437
11438         function rezoneEventDates(event) {
11439                 event.start = t.moment(event.start);
11440                 if (event.end) {
11441                         event.end = t.moment(event.end);
11442                 }
11443                 backupEventDates(event);
11444         }
11445         
11446         
11447         /* Event Normalization
11448         -----------------------------------------------------------------------------*/
11449
11450
11451         // Given a raw object with key/value properties, returns an "abstract" Event object.
11452         // An "abstract" event is an event that, if recurring, will not have been expanded yet.
11453         // Will return `false` when input is invalid.
11454         // `source` is optional
11455         function buildEventFromInput(input, source) {
11456                 var out = {};
11457                 var start, end;
11458                 var allDay;
11459
11460                 if (t.options.eventDataTransform) {
11461                         input = t.options.eventDataTransform(input);
11462                 }
11463                 if (source && source.eventDataTransform) {
11464                         input = source.eventDataTransform(input);
11465                 }
11466
11467                 // Copy all properties over to the resulting object.
11468                 // The special-case properties will be copied over afterwards.
11469                 $.extend(out, input);
11470
11471                 if (source) {
11472                         out.source = source;
11473                 }
11474
11475                 out._id = input._id || (input.id === undefined ? '_fc' + eventGUID++ : input.id + '');
11476
11477                 if (input.className) {
11478                         if (typeof input.className == 'string') {
11479                                 out.className = input.className.split(/\s+/);
11480                         }
11481                         else { // assumed to be an array
11482                                 out.className = input.className;
11483                         }
11484                 }
11485                 else {
11486                         out.className = [];
11487                 }
11488
11489                 start = input.start || input.date; // "date" is an alias for "start"
11490                 end = input.end;
11491
11492                 // parse as a time (Duration) if applicable
11493                 if (isTimeString(start)) {
11494                         start = moment.duration(start);
11495                 }
11496                 if (isTimeString(end)) {
11497                         end = moment.duration(end);
11498                 }
11499
11500                 if (input.dow || moment.isDuration(start) || moment.isDuration(end)) {
11501
11502                         // the event is "abstract" (recurring) so don't calculate exact start/end dates just yet
11503                         out.start = start ? moment.duration(start) : null; // will be a Duration or null
11504                         out.end = end ? moment.duration(end) : null; // will be a Duration or null
11505                         out._recurring = true; // our internal marker
11506                 }
11507                 else {
11508
11509                         if (start) {
11510                                 start = t.moment(start);
11511                                 if (!start.isValid()) {
11512                                         return false;
11513                                 }
11514                         }
11515
11516                         if (end) {
11517                                 end = t.moment(end);
11518                                 if (!end.isValid()) {
11519                                         end = null; // let defaults take over
11520                                 }
11521                         }
11522
11523                         allDay = input.allDay;
11524                         if (allDay === undefined) { // still undefined? fallback to default
11525                                 allDay = firstDefined(
11526                                         source ? source.allDayDefault : undefined,
11527                                         t.options.allDayDefault
11528                                 );
11529                                 // still undefined? normalizeEventDates will calculate it
11530                         }
11531
11532                         assignDatesToEvent(start, end, allDay, out);
11533                 }
11534
11535                 t.normalizeEvent(out); // hook for external use. a prototype method
11536
11537                 return out;
11538         }
11539         t.buildEventFromInput = buildEventFromInput;
11540
11541
11542         // Normalizes and assigns the given dates to the given partially-formed event object.
11543         // NOTE: mutates the given start/end moments. does not make a copy.
11544         function assignDatesToEvent(start, end, allDay, event) {
11545                 event.start = start;
11546                 event.end = end;
11547                 event.allDay = allDay;
11548                 normalizeEventDates(event);
11549                 backupEventDates(event);
11550         }
11551
11552
11553         // Ensures proper values for allDay/start/end. Accepts an Event object, or a plain object with event-ish properties.
11554         // NOTE: Will modify the given object.
11555         function normalizeEventDates(eventProps) {
11556
11557                 normalizeEventTimes(eventProps);
11558
11559                 if (eventProps.end && !eventProps.end.isAfter(eventProps.start)) {
11560                         eventProps.end = null;
11561                 }
11562
11563                 if (!eventProps.end) {
11564                         if (t.options.forceEventDuration) {
11565                                 eventProps.end = t.getDefaultEventEnd(eventProps.allDay, eventProps.start);
11566                         }
11567                         else {
11568                                 eventProps.end = null;
11569                         }
11570                 }
11571         }
11572
11573
11574         // Ensures the allDay property exists and the timeliness of the start/end dates are consistent
11575         function normalizeEventTimes(eventProps) {
11576                 if (eventProps.allDay == null) {
11577                         eventProps.allDay = !(eventProps.start.hasTime() || (eventProps.end && eventProps.end.hasTime()));
11578                 }
11579
11580                 if (eventProps.allDay) {
11581                         eventProps.start.stripTime();
11582                         if (eventProps.end) {
11583                                 // TODO: consider nextDayThreshold here? If so, will require a lot of testing and adjustment
11584                                 eventProps.end.stripTime();
11585                         }
11586                 }
11587                 else {
11588                         if (!eventProps.start.hasTime()) {
11589                                 eventProps.start = t.applyTimezone(eventProps.start.time(0)); // will assign a 00:00 time
11590                         }
11591                         if (eventProps.end && !eventProps.end.hasTime()) {
11592                                 eventProps.end = t.applyTimezone(eventProps.end.time(0)); // will assign a 00:00 time
11593                         }
11594                 }
11595         }
11596
11597
11598         // If the given event is a recurring event, break it down into an array of individual instances.
11599         // If not a recurring event, return an array with the single original event.
11600         // If given a falsy input (probably because of a failed buildEventFromInput call), returns an empty array.
11601         // HACK: can override the recurring window by providing custom rangeStart/rangeEnd (for businessHours).
11602         function expandEvent(abstractEvent, _rangeStart, _rangeEnd) {
11603                 var events = [];
11604                 var dowHash;
11605                 var dow;
11606                 var i;
11607                 var date;
11608                 var startTime, endTime;
11609                 var start, end;
11610                 var event;
11611
11612                 _rangeStart = _rangeStart || rangeStart;
11613                 _rangeEnd = _rangeEnd || rangeEnd;
11614
11615                 if (abstractEvent) {
11616                         if (abstractEvent._recurring) {
11617
11618                                 // make a boolean hash as to whether the event occurs on each day-of-week
11619                                 if ((dow = abstractEvent.dow)) {
11620                                         dowHash = {};
11621                                         for (i = 0; i < dow.length; i++) {
11622                                                 dowHash[dow[i]] = true;
11623                                         }
11624                                 }
11625
11626                                 // iterate through every day in the current range
11627                                 date = _rangeStart.clone().stripTime(); // holds the date of the current day
11628                                 while (date.isBefore(_rangeEnd)) {
11629
11630                                         if (!dowHash || dowHash[date.day()]) { // if everyday, or this particular day-of-week
11631
11632                                                 startTime = abstractEvent.start; // the stored start and end properties are times (Durations)
11633                                                 endTime = abstractEvent.end; // "
11634                                                 start = date.clone();
11635                                                 end = null;
11636
11637                                                 if (startTime) {
11638                                                         start = start.time(startTime);
11639                                                 }
11640                                                 if (endTime) {
11641                                                         end = date.clone().time(endTime);
11642                                                 }
11643
11644                                                 event = $.extend({}, abstractEvent); // make a copy of the original
11645                                                 assignDatesToEvent(
11646                                                         start, end,
11647                                                         !startTime && !endTime, // allDay?
11648                                                         event
11649                                                 );
11650                                                 events.push(event);
11651                                         }
11652
11653                                         date.add(1, 'days');
11654                                 }
11655                         }
11656                         else {
11657                                 events.push(abstractEvent); // return the original event. will be a one-item array
11658                         }
11659                 }
11660
11661                 return events;
11662         }
11663         t.expandEvent = expandEvent;
11664
11665
11666
11667         /* Event Modification Math
11668         -----------------------------------------------------------------------------------------*/
11669
11670
11671         // Modifies an event and all related events by applying the given properties.
11672         // Special date-diffing logic is used for manipulation of dates.
11673         // If `props` does not contain start/end dates, the updated values are assumed to be the event's current start/end.
11674         // All date comparisons are done against the event's pristine _start and _end dates.
11675         // Returns an object with delta information and a function to undo all operations.
11676         // For making computations in a granularity greater than day/time, specify largeUnit.
11677         // NOTE: The given `newProps` might be mutated for normalization purposes.
11678         function mutateEvent(event, newProps, largeUnit) {
11679                 var miscProps = {};
11680                 var oldProps;
11681                 var clearEnd;
11682                 var startDelta;
11683                 var endDelta;
11684                 var durationDelta;
11685                 var undoFunc;
11686
11687                 // diffs the dates in the appropriate way, returning a duration
11688                 function diffDates(date1, date0) { // date1 - date0
11689                         if (largeUnit) {
11690                                 return diffByUnit(date1, date0, largeUnit);
11691                         }
11692                         else if (newProps.allDay) {
11693                                 return diffDay(date1, date0);
11694                         }
11695                         else {
11696                                 return diffDayTime(date1, date0);
11697                         }
11698                 }
11699
11700                 newProps = newProps || {};
11701
11702                 // normalize new date-related properties
11703                 if (!newProps.start) {
11704                         newProps.start = event.start.clone();
11705                 }
11706                 if (newProps.end === undefined) {
11707                         newProps.end = event.end ? event.end.clone() : null;
11708                 }
11709                 if (newProps.allDay == null) { // is null or undefined?
11710                         newProps.allDay = event.allDay;
11711                 }
11712                 normalizeEventDates(newProps);
11713
11714                 // create normalized versions of the original props to compare against
11715                 // need a real end value, for diffing
11716                 oldProps = {
11717                         start: event._start.clone(),
11718                         end: event._end ? event._end.clone() : t.getDefaultEventEnd(event._allDay, event._start),
11719                         allDay: newProps.allDay // normalize the dates in the same regard as the new properties
11720                 };
11721                 normalizeEventDates(oldProps);
11722
11723                 // need to clear the end date if explicitly changed to null
11724                 clearEnd = event._end !== null && newProps.end === null;
11725
11726                 // compute the delta for moving the start date
11727                 startDelta = diffDates(newProps.start, oldProps.start);
11728
11729                 // compute the delta for moving the end date
11730                 if (newProps.end) {
11731                         endDelta = diffDates(newProps.end, oldProps.end);
11732                         durationDelta = endDelta.subtract(startDelta);
11733                 }
11734                 else {
11735                         durationDelta = null;
11736                 }
11737
11738                 // gather all non-date-related properties
11739                 $.each(newProps, function(name, val) {
11740                         if (isMiscEventPropName(name)) {
11741                                 if (val !== undefined) {
11742                                         miscProps[name] = val;
11743                                 }
11744                         }
11745                 });
11746
11747                 // apply the operations to the event and all related events
11748                 undoFunc = mutateEvents(
11749                         clientEvents(event._id), // get events with this ID
11750                         clearEnd,
11751                         newProps.allDay,
11752                         startDelta,
11753                         durationDelta,
11754                         miscProps
11755                 );
11756
11757                 return {
11758                         dateDelta: startDelta,
11759                         durationDelta: durationDelta,
11760                         undo: undoFunc
11761                 };
11762         }
11763
11764
11765         // Modifies an array of events in the following ways (operations are in order):
11766         // - clear the event's `end`
11767         // - convert the event to allDay
11768         // - add `dateDelta` to the start and end
11769         // - add `durationDelta` to the event's duration
11770         // - assign `miscProps` to the event
11771         //
11772         // Returns a function that can be called to undo all the operations.
11773         //
11774         // TODO: don't use so many closures. possible memory issues when lots of events with same ID.
11775         //
11776         function mutateEvents(events, clearEnd, allDay, dateDelta, durationDelta, miscProps) {
11777                 var isAmbigTimezone = t.getIsAmbigTimezone();
11778                 var undoFunctions = [];
11779
11780                 // normalize zero-length deltas to be null
11781                 if (dateDelta && !dateDelta.valueOf()) { dateDelta = null; }
11782                 if (durationDelta && !durationDelta.valueOf()) { durationDelta = null; }
11783
11784                 $.each(events, function(i, event) {
11785                         var oldProps;
11786                         var newProps;
11787
11788                         // build an object holding all the old values, both date-related and misc.
11789                         // for the undo function.
11790                         oldProps = {
11791                                 start: event.start.clone(),
11792                                 end: event.end ? event.end.clone() : null,
11793                                 allDay: event.allDay
11794                         };
11795                         $.each(miscProps, function(name) {
11796                                 oldProps[name] = event[name];
11797                         });
11798
11799                         // new date-related properties. work off the original date snapshot.
11800                         // ok to use references because they will be thrown away when backupEventDates is called.
11801                         newProps = {
11802                                 start: event._start,
11803                                 end: event._end,
11804                                 allDay: allDay // normalize the dates in the same regard as the new properties
11805                         };
11806                         normalizeEventDates(newProps); // massages start/end/allDay
11807
11808                         // strip or ensure the end date
11809                         if (clearEnd) {
11810                                 newProps.end = null;
11811                         }
11812                         else if (durationDelta && !newProps.end) { // the duration translation requires an end date
11813                                 newProps.end = t.getDefaultEventEnd(newProps.allDay, newProps.start);
11814                         }
11815
11816                         if (dateDelta) {
11817                                 newProps.start.add(dateDelta);
11818                                 if (newProps.end) {
11819                                         newProps.end.add(dateDelta);
11820                                 }
11821                         }
11822
11823                         if (durationDelta) {
11824                                 newProps.end.add(durationDelta); // end already ensured above
11825                         }
11826
11827                         // if the dates have changed, and we know it is impossible to recompute the
11828                         // timezone offsets, strip the zone.
11829                         if (
11830                                 isAmbigTimezone &&
11831                                 !newProps.allDay &&
11832                                 (dateDelta || durationDelta)
11833                         ) {
11834                                 newProps.start.stripZone();
11835                                 if (newProps.end) {
11836                                         newProps.end.stripZone();
11837                                 }
11838                         }
11839
11840                         $.extend(event, miscProps, newProps); // copy over misc props, then date-related props
11841                         backupEventDates(event); // regenerate internal _start/_end/_allDay
11842
11843                         undoFunctions.push(function() {
11844                                 $.extend(event, oldProps);
11845                                 backupEventDates(event); // regenerate internal _start/_end/_allDay
11846                         });
11847                 });
11848
11849                 return function() {
11850                         for (var i = 0; i < undoFunctions.length; i++) {
11851                                 undoFunctions[i]();
11852                         }
11853                 };
11854         }
11855
11856
11857         t.getEventCache = function() {
11858                 return cache;
11859         };
11860
11861 }
11862
11863
11864 // hook for external libs to manipulate event properties upon creation.
11865 // should manipulate the event in-place.
11866 Calendar.prototype.normalizeEvent = function(event) {
11867 };
11868
11869
11870 // Does the given span (start, end, and other location information)
11871 // fully contain the other?
11872 Calendar.prototype.spanContainsSpan = function(outerSpan, innerSpan) {
11873         var eventStart = outerSpan.start.clone().stripZone();
11874         var eventEnd = this.getEventEnd(outerSpan).stripZone();
11875
11876         return innerSpan.start >= eventStart && innerSpan.end <= eventEnd;
11877 };
11878
11879
11880 // Returns a list of events that the given event should be compared against when being considered for a move to
11881 // the specified span. Attached to the Calendar's prototype because EventManager is a mixin for a Calendar.
11882 Calendar.prototype.getPeerEvents = function(span, event) {
11883         var cache = this.getEventCache();
11884         var peerEvents = [];
11885         var i, otherEvent;
11886
11887         for (i = 0; i < cache.length; i++) {
11888                 otherEvent = cache[i];
11889                 if (
11890                         !event ||
11891                         event._id !== otherEvent._id // don't compare the event to itself or other related [repeating] events
11892                 ) {
11893                         peerEvents.push(otherEvent);
11894                 }
11895         }
11896
11897         return peerEvents;
11898 };
11899
11900
11901 // updates the "backup" properties, which are preserved in order to compute diffs later on.
11902 function backupEventDates(event) {
11903         event._allDay = event.allDay;
11904         event._start = event.start.clone();
11905         event._end = event.end ? event.end.clone() : null;
11906 }
11907
11908
11909 /* Overlapping / Constraining
11910 -----------------------------------------------------------------------------------------*/
11911
11912
11913 // Determines if the given event can be relocated to the given span (unzoned start/end with other misc data)
11914 Calendar.prototype.isEventSpanAllowed = function(span, event) {
11915         var source = event.source || {};
11916
11917         var constraint = firstDefined(
11918                 event.constraint,
11919                 source.constraint,
11920                 this.options.eventConstraint
11921         );
11922
11923         var overlap = firstDefined(
11924                 event.overlap,
11925                 source.overlap,
11926                 this.options.eventOverlap
11927         );
11928
11929         return this.isSpanAllowed(span, constraint, overlap, event) &&
11930                 (!this.options.eventAllow || this.options.eventAllow(span, event) !== false);
11931 };
11932
11933
11934 // Determines if an external event can be relocated to the given span (unzoned start/end with other misc data)
11935 Calendar.prototype.isExternalSpanAllowed = function(eventSpan, eventLocation, eventProps) {
11936         var eventInput;
11937         var event;
11938
11939         // note: very similar logic is in View's reportExternalDrop
11940         if (eventProps) {
11941                 eventInput = $.extend({}, eventProps, eventLocation);
11942                 event = this.expandEvent(
11943                         this.buildEventFromInput(eventInput)
11944                 )[0];
11945         }
11946
11947         if (event) {
11948                 return this.isEventSpanAllowed(eventSpan, event);
11949         }
11950         else { // treat it as a selection
11951
11952                 return this.isSelectionSpanAllowed(eventSpan);
11953         }
11954 };
11955
11956
11957 // Determines the given span (unzoned start/end with other misc data) can be selected.
11958 Calendar.prototype.isSelectionSpanAllowed = function(span) {
11959         return this.isSpanAllowed(span, this.options.selectConstraint, this.options.selectOverlap) &&
11960                 (!this.options.selectAllow || this.options.selectAllow(span) !== false);
11961 };
11962
11963
11964 // Returns true if the given span (caused by an event drop/resize or a selection) is allowed to exist
11965 // according to the constraint/overlap settings.
11966 // `event` is not required if checking a selection.
11967 Calendar.prototype.isSpanAllowed = function(span, constraint, overlap, event) {
11968         var constraintEvents;
11969         var anyContainment;
11970         var peerEvents;
11971         var i, peerEvent;
11972         var peerOverlap;
11973
11974         // the range must be fully contained by at least one of produced constraint events
11975         if (constraint != null) {
11976
11977                 // not treated as an event! intermediate data structure
11978                 // TODO: use ranges in the future
11979                 constraintEvents = this.constraintToEvents(constraint);
11980                 if (constraintEvents) { // not invalid
11981
11982                         anyContainment = false;
11983                         for (i = 0; i < constraintEvents.length; i++) {
11984                                 if (this.spanContainsSpan(constraintEvents[i], span)) {
11985                                         anyContainment = true;
11986                                         break;
11987                                 }
11988                         }
11989
11990                         if (!anyContainment) {
11991                                 return false;
11992                         }
11993                 }
11994         }
11995
11996         peerEvents = this.getPeerEvents(span, event);
11997
11998         for (i = 0; i < peerEvents.length; i++)  {
11999                 peerEvent = peerEvents[i];
12000
12001                 // there needs to be an actual intersection before disallowing anything
12002                 if (this.eventIntersectsRange(peerEvent, span)) {
12003
12004                         // evaluate overlap for the given range and short-circuit if necessary
12005                         if (overlap === false) {
12006                                 return false;
12007                         }
12008                         // if the event's overlap is a test function, pass the peer event in question as the first param
12009                         else if (typeof overlap === 'function' && !overlap(peerEvent, event)) {
12010                                 return false;
12011                         }
12012
12013                         // if we are computing if the given range is allowable for an event, consider the other event's
12014                         // EventObject-specific or Source-specific `overlap` property
12015                         if (event) {
12016                                 peerOverlap = firstDefined(
12017                                         peerEvent.overlap,
12018                                         (peerEvent.source || {}).overlap
12019                                         // we already considered the global `eventOverlap`
12020                                 );
12021                                 if (peerOverlap === false) {
12022                                         return false;
12023                                 }
12024                                 // if the peer event's overlap is a test function, pass the subject event as the first param
12025                                 if (typeof peerOverlap === 'function' && !peerOverlap(event, peerEvent)) {
12026                                         return false;
12027                                 }
12028                         }
12029                 }
12030         }
12031
12032         return true;
12033 };
12034
12035
12036 // Given an event input from the API, produces an array of event objects. Possible event inputs:
12037 // 'businessHours'
12038 // An event ID (number or string)
12039 // An object with specific start/end dates or a recurring event (like what businessHours accepts)
12040 Calendar.prototype.constraintToEvents = function(constraintInput) {
12041
12042         if (constraintInput === 'businessHours') {
12043                 return this.getCurrentBusinessHourEvents();
12044         }
12045
12046         if (typeof constraintInput === 'object') {
12047                 if (constraintInput.start != null) { // needs to be event-like input
12048                         return this.expandEvent(this.buildEventFromInput(constraintInput));
12049                 }
12050                 else {
12051                         return null; // invalid
12052                 }
12053         }
12054
12055         return this.clientEvents(constraintInput); // probably an ID
12056 };
12057
12058
12059 // Does the event's date range intersect with the given range?
12060 // start/end already assumed to have stripped zones :(
12061 Calendar.prototype.eventIntersectsRange = function(event, range) {
12062         var eventStart = event.start.clone().stripZone();
12063         var eventEnd = this.getEventEnd(event).stripZone();
12064
12065         return range.start < eventEnd && range.end > eventStart;
12066 };
12067
12068
12069 /* Business Hours
12070 -----------------------------------------------------------------------------------------*/
12071
12072 var BUSINESS_HOUR_EVENT_DEFAULTS = {
12073         id: '_fcBusinessHours', // will relate events from different calls to expandEvent
12074         start: '09:00',
12075         end: '17:00',
12076         dow: [ 1, 2, 3, 4, 5 ], // monday - friday
12077         rendering: 'inverse-background'
12078         // classNames are defined in businessHoursSegClasses
12079 };
12080
12081 // Return events objects for business hours within the current view.
12082 // Abuse of our event system :(
12083 Calendar.prototype.getCurrentBusinessHourEvents = function(wholeDay) {
12084         return this.computeBusinessHourEvents(wholeDay, this.options.businessHours);
12085 };
12086
12087 // Given a raw input value from options, return events objects for business hours within the current view.
12088 Calendar.prototype.computeBusinessHourEvents = function(wholeDay, input) {
12089         if (input === true) {
12090                 return this.expandBusinessHourEvents(wholeDay, [ {} ]);
12091         }
12092         else if ($.isPlainObject(input)) {
12093                 return this.expandBusinessHourEvents(wholeDay, [ input ]);
12094         }
12095         else if ($.isArray(input)) {
12096                 return this.expandBusinessHourEvents(wholeDay, input, true);
12097         }
12098         else {
12099                 return [];
12100         }
12101 };
12102
12103 // inputs expected to be an array of objects.
12104 // if ignoreNoDow is true, will ignore entries that don't specify a day-of-week (dow) key.
12105 Calendar.prototype.expandBusinessHourEvents = function(wholeDay, inputs, ignoreNoDow) {
12106         var view = this.getView();
12107         var events = [];
12108         var i, input;
12109
12110         for (i = 0; i < inputs.length; i++) {
12111                 input = inputs[i];
12112
12113                 if (ignoreNoDow && !input.dow) {
12114                         continue;
12115                 }
12116
12117                 // give defaults. will make a copy
12118                 input = $.extend({}, BUSINESS_HOUR_EVENT_DEFAULTS, input);
12119
12120                 // if a whole-day series is requested, clear the start/end times
12121                 if (wholeDay) {
12122                         input.start = null;
12123                         input.end = null;
12124                 }
12125
12126                 events.push.apply(events, // append
12127                         this.expandEvent(
12128                                 this.buildEventFromInput(input),
12129                                 view.start,
12130                                 view.end
12131                         )
12132                 );
12133         }
12134
12135         return events;
12136 };
12137
12138 ;;
12139
12140 /* An abstract class for the "basic" views, as well as month view. Renders one or more rows of day cells.
12141 ----------------------------------------------------------------------------------------------------------------------*/
12142 // It is a manager for a DayGrid subcomponent, which does most of the heavy lifting.
12143 // It is responsible for managing width/height.
12144
12145 var BasicView = FC.BasicView = View.extend({
12146
12147         scroller: null,
12148
12149         dayGridClass: DayGrid, // class the dayGrid will be instantiated from (overridable by subclasses)
12150         dayGrid: null, // the main subcomponent that does most of the heavy lifting
12151
12152         dayNumbersVisible: false, // display day numbers on each day cell?
12153         colWeekNumbersVisible: false, // display week numbers along the side?
12154         cellWeekNumbersVisible: false, // display week numbers in day cell?
12155
12156         weekNumberWidth: null, // width of all the week-number cells running down the side
12157
12158         headContainerEl: null, // div that hold's the dayGrid's rendered date header
12159         headRowEl: null, // the fake row element of the day-of-week header
12160
12161
12162         initialize: function() {
12163                 this.dayGrid = this.instantiateDayGrid();
12164
12165                 this.scroller = new Scroller({
12166                         overflowX: 'hidden',
12167                         overflowY: 'auto'
12168                 });
12169         },
12170
12171
12172         // Generates the DayGrid object this view needs. Draws from this.dayGridClass
12173         instantiateDayGrid: function() {
12174                 // generate a subclass on the fly with BasicView-specific behavior
12175                 // TODO: cache this subclass
12176                 var subclass = this.dayGridClass.extend(basicDayGridMethods);
12177
12178                 return new subclass(this);
12179         },
12180
12181
12182         // Sets the display range and computes all necessary dates
12183         setRange: function(range) {
12184                 View.prototype.setRange.call(this, range); // call the super-method
12185
12186                 this.dayGrid.breakOnWeeks = /year|month|week/.test(this.intervalUnit); // do before setRange
12187                 this.dayGrid.setRange(range);
12188         },
12189
12190
12191         // Compute the value to feed into setRange. Overrides superclass.
12192         computeRange: function(date) {
12193                 var range = View.prototype.computeRange.call(this, date); // get value from the super-method
12194
12195                 // year and month views should be aligned with weeks. this is already done for week
12196                 if (/year|month/.test(range.intervalUnit)) {
12197                         range.start.startOf('week');
12198                         range.start = this.skipHiddenDays(range.start);
12199
12200                         // make end-of-week if not already
12201                         if (range.end.weekday()) {
12202                                 range.end.add(1, 'week').startOf('week');
12203                                 range.end = this.skipHiddenDays(range.end, -1, true); // exclusively move backwards
12204                         }
12205                 }
12206
12207                 return range;
12208         },
12209
12210
12211         // Renders the view into `this.el`, which should already be assigned
12212         renderDates: function() {
12213
12214                 this.dayNumbersVisible = this.dayGrid.rowCnt > 1; // TODO: make grid responsible
12215                 if (this.opt('weekNumbers')) {
12216                         if (this.opt('weekNumbersWithinDays')) {
12217                                 this.cellWeekNumbersVisible = true;
12218                                 this.colWeekNumbersVisible = false;
12219                         }
12220                         else {
12221                                 this.cellWeekNumbersVisible = false;
12222                                 this.colWeekNumbersVisible = true;
12223                         };
12224                 }
12225                 this.dayGrid.numbersVisible = this.dayNumbersVisible ||
12226                         this.cellWeekNumbersVisible || this.colWeekNumbersVisible;
12227
12228                 this.el.addClass('fc-basic-view').html(this.renderSkeletonHtml());
12229                 this.renderHead();
12230
12231                 this.scroller.render();
12232                 var dayGridContainerEl = this.scroller.el.addClass('fc-day-grid-container');
12233                 var dayGridEl = $('<div class="fc-day-grid" />').appendTo(dayGridContainerEl);
12234                 this.el.find('.fc-body > tr > td').append(dayGridContainerEl);
12235
12236                 this.dayGrid.setElement(dayGridEl);
12237                 this.dayGrid.renderDates(this.hasRigidRows());
12238         },
12239
12240
12241         // render the day-of-week headers
12242         renderHead: function() {
12243                 this.headContainerEl =
12244                         this.el.find('.fc-head-container')
12245                                 .html(this.dayGrid.renderHeadHtml());
12246                 this.headRowEl = this.headContainerEl.find('.fc-row');
12247         },
12248
12249
12250         // Unrenders the content of the view. Since we haven't separated skeleton rendering from date rendering,
12251         // always completely kill the dayGrid's rendering.
12252         unrenderDates: function() {
12253                 this.dayGrid.unrenderDates();
12254                 this.dayGrid.removeElement();
12255                 this.scroller.destroy();
12256         },
12257
12258
12259         renderBusinessHours: function() {
12260                 this.dayGrid.renderBusinessHours();
12261         },
12262
12263
12264         unrenderBusinessHours: function() {
12265                 this.dayGrid.unrenderBusinessHours();
12266         },
12267
12268
12269         // Builds the HTML skeleton for the view.
12270         // The day-grid component will render inside of a container defined by this HTML.
12271         renderSkeletonHtml: function() {
12272                 return '' +
12273                         '<table>' +
12274                                 '<thead class="fc-head">' +
12275                                         '<tr>' +
12276                                                 '<td class="fc-head-container ' + this.widgetHeaderClass + '"></td>' +
12277                                         '</tr>' +
12278                                 '</thead>' +
12279                                 '<tbody class="fc-body">' +
12280                                         '<tr>' +
12281                                                 '<td class="' + this.widgetContentClass + '"></td>' +
12282                                         '</tr>' +
12283                                 '</tbody>' +
12284                         '</table>';
12285         },
12286
12287
12288         // Generates an HTML attribute string for setting the width of the week number column, if it is known
12289         weekNumberStyleAttr: function() {
12290                 if (this.weekNumberWidth !== null) {
12291                         return 'style="width:' + this.weekNumberWidth + 'px"';
12292                 }
12293                 return '';
12294         },
12295
12296
12297         // Determines whether each row should have a constant height
12298         hasRigidRows: function() {
12299                 var eventLimit = this.opt('eventLimit');
12300                 return eventLimit && typeof eventLimit !== 'number';
12301         },
12302
12303
12304         /* Dimensions
12305         ------------------------------------------------------------------------------------------------------------------*/
12306
12307
12308         // Refreshes the horizontal dimensions of the view
12309         updateWidth: function() {
12310                 if (this.colWeekNumbersVisible) {
12311                         // Make sure all week number cells running down the side have the same width.
12312                         // Record the width for cells created later.
12313                         this.weekNumberWidth = matchCellWidths(
12314                                 this.el.find('.fc-week-number')
12315                         );
12316                 }
12317         },
12318
12319
12320         // Adjusts the vertical dimensions of the view to the specified values
12321         setHeight: function(totalHeight, isAuto) {
12322                 var eventLimit = this.opt('eventLimit');
12323                 var scrollerHeight;
12324                 var scrollbarWidths;
12325
12326                 // reset all heights to be natural
12327                 this.scroller.clear();
12328                 uncompensateScroll(this.headRowEl);
12329
12330                 this.dayGrid.removeSegPopover(); // kill the "more" popover if displayed
12331
12332                 // is the event limit a constant level number?
12333                 if (eventLimit && typeof eventLimit === 'number') {
12334                         this.dayGrid.limitRows(eventLimit); // limit the levels first so the height can redistribute after
12335                 }
12336
12337                 // distribute the height to the rows
12338                 // (totalHeight is a "recommended" value if isAuto)
12339                 scrollerHeight = this.computeScrollerHeight(totalHeight);
12340                 this.setGridHeight(scrollerHeight, isAuto);
12341
12342                 // is the event limit dynamically calculated?
12343                 if (eventLimit && typeof eventLimit !== 'number') {
12344                         this.dayGrid.limitRows(eventLimit); // limit the levels after the grid's row heights have been set
12345                 }
12346
12347                 if (!isAuto) { // should we force dimensions of the scroll container?
12348
12349                         this.scroller.setHeight(scrollerHeight);
12350                         scrollbarWidths = this.scroller.getScrollbarWidths();
12351
12352                         if (scrollbarWidths.left || scrollbarWidths.right) { // using scrollbars?
12353
12354                                 compensateScroll(this.headRowEl, scrollbarWidths);
12355
12356                                 // doing the scrollbar compensation might have created text overflow which created more height. redo
12357                                 scrollerHeight = this.computeScrollerHeight(totalHeight);
12358                                 this.scroller.setHeight(scrollerHeight);
12359                         }
12360
12361                         // guarantees the same scrollbar widths
12362                         this.scroller.lockOverflow(scrollbarWidths);
12363                 }
12364         },
12365
12366
12367         // given a desired total height of the view, returns what the height of the scroller should be
12368         computeScrollerHeight: function(totalHeight) {
12369                 return totalHeight -
12370                         subtractInnerElHeight(this.el, this.scroller.el); // everything that's NOT the scroller
12371         },
12372
12373
12374         // Sets the height of just the DayGrid component in this view
12375         setGridHeight: function(height, isAuto) {
12376                 if (isAuto) {
12377                         undistributeHeight(this.dayGrid.rowEls); // let the rows be their natural height with no expanding
12378                 }
12379                 else {
12380                         distributeHeight(this.dayGrid.rowEls, height, true); // true = compensate for height-hogging rows
12381                 }
12382         },
12383
12384
12385         /* Scroll
12386         ------------------------------------------------------------------------------------------------------------------*/
12387
12388
12389         queryScroll: function() {
12390                 return this.scroller.getScrollTop();
12391         },
12392
12393
12394         setScroll: function(top) {
12395                 this.scroller.setScrollTop(top);
12396         },
12397
12398
12399         /* Hit Areas
12400         ------------------------------------------------------------------------------------------------------------------*/
12401         // forward all hit-related method calls to dayGrid
12402
12403
12404         prepareHits: function() {
12405                 this.dayGrid.prepareHits();
12406         },
12407
12408
12409         releaseHits: function() {
12410                 this.dayGrid.releaseHits();
12411         },
12412
12413
12414         queryHit: function(left, top) {
12415                 return this.dayGrid.queryHit(left, top);
12416         },
12417
12418
12419         getHitSpan: function(hit) {
12420                 return this.dayGrid.getHitSpan(hit);
12421         },
12422
12423
12424         getHitEl: function(hit) {
12425                 return this.dayGrid.getHitEl(hit);
12426         },
12427
12428
12429         /* Events
12430         ------------------------------------------------------------------------------------------------------------------*/
12431
12432
12433         // Renders the given events onto the view and populates the segments array
12434         renderEvents: function(events) {
12435                 this.dayGrid.renderEvents(events);
12436
12437                 this.updateHeight(); // must compensate for events that overflow the row
12438         },
12439
12440
12441         // Retrieves all segment objects that are rendered in the view
12442         getEventSegs: function() {
12443                 return this.dayGrid.getEventSegs();
12444         },
12445
12446
12447         // Unrenders all event elements and clears internal segment data
12448         unrenderEvents: function() {
12449                 this.dayGrid.unrenderEvents();
12450
12451                 // we DON'T need to call updateHeight() because
12452                 // a renderEvents() call always happens after this, which will eventually call updateHeight()
12453         },
12454
12455
12456         /* Dragging (for both events and external elements)
12457         ------------------------------------------------------------------------------------------------------------------*/
12458
12459
12460         // A returned value of `true` signals that a mock "helper" event has been rendered.
12461         renderDrag: function(dropLocation, seg) {
12462                 return this.dayGrid.renderDrag(dropLocation, seg);
12463         },
12464
12465
12466         unrenderDrag: function() {
12467                 this.dayGrid.unrenderDrag();
12468         },
12469
12470
12471         /* Selection
12472         ------------------------------------------------------------------------------------------------------------------*/
12473
12474
12475         // Renders a visual indication of a selection
12476         renderSelection: function(span) {
12477                 this.dayGrid.renderSelection(span);
12478         },
12479
12480
12481         // Unrenders a visual indications of a selection
12482         unrenderSelection: function() {
12483                 this.dayGrid.unrenderSelection();
12484         }
12485
12486 });
12487
12488
12489 // Methods that will customize the rendering behavior of the BasicView's dayGrid
12490 var basicDayGridMethods = {
12491
12492
12493         // Generates the HTML that will go before the day-of week header cells
12494         renderHeadIntroHtml: function() {
12495                 var view = this.view;
12496
12497                 if (view.colWeekNumbersVisible) {
12498                         return '' +
12499                                 '<th class="fc-week-number ' + view.widgetHeaderClass + '" ' + view.weekNumberStyleAttr() + '>' +
12500                                         '<span>' + // needed for matchCellWidths
12501                                                 htmlEscape(view.opt('weekNumberTitle')) +
12502                                         '</span>' +
12503                                 '</th>';
12504                 }
12505
12506                 return '';
12507         },
12508
12509
12510         // Generates the HTML that will go before content-skeleton cells that display the day/week numbers
12511         renderNumberIntroHtml: function(row) {
12512                 var view = this.view;
12513                 var weekStart = this.getCellDate(row, 0);
12514
12515                 if (view.colWeekNumbersVisible) {
12516                         return '' +
12517                                 '<td class="fc-week-number" ' + view.weekNumberStyleAttr() + '>' +
12518                                         view.buildGotoAnchorHtml( // aside from link, important for matchCellWidths
12519                                                 { date: weekStart, type: 'week', forceOff: this.colCnt === 1 },
12520                                                 weekStart.format('w') // inner HTML
12521                                         ) +
12522                                 '</td>';
12523                 }
12524
12525                 return '';
12526         },
12527
12528
12529         // Generates the HTML that goes before the day bg cells for each day-row
12530         renderBgIntroHtml: function() {
12531                 var view = this.view;
12532
12533                 if (view.colWeekNumbersVisible) {
12534                         return '<td class="fc-week-number ' + view.widgetContentClass + '" ' +
12535                                 view.weekNumberStyleAttr() + '></td>';
12536                 }
12537
12538                 return '';
12539         },
12540
12541
12542         // Generates the HTML that goes before every other type of row generated by DayGrid.
12543         // Affects helper-skeleton and highlight-skeleton rows.
12544         renderIntroHtml: function() {
12545                 var view = this.view;
12546
12547                 if (view.colWeekNumbersVisible) {
12548                         return '<td class="fc-week-number" ' + view.weekNumberStyleAttr() + '></td>';
12549                 }
12550
12551                 return '';
12552         }
12553
12554 };
12555
12556 ;;
12557
12558 /* A month view with day cells running in rows (one-per-week) and columns
12559 ----------------------------------------------------------------------------------------------------------------------*/
12560
12561 var MonthView = FC.MonthView = BasicView.extend({
12562
12563         // Produces information about what range to display
12564         computeRange: function(date) {
12565                 var range = BasicView.prototype.computeRange.call(this, date); // get value from super-method
12566                 var rowCnt;
12567
12568                 // ensure 6 weeks
12569                 if (this.isFixedWeeks()) {
12570                         rowCnt = Math.ceil(range.end.diff(range.start, 'weeks', true)); // could be partial weeks due to hiddenDays
12571                         range.end.add(6 - rowCnt, 'weeks');
12572                 }
12573
12574                 return range;
12575         },
12576
12577
12578         // Overrides the default BasicView behavior to have special multi-week auto-height logic
12579         setGridHeight: function(height, isAuto) {
12580
12581                 // if auto, make the height of each row the height that it would be if there were 6 weeks
12582                 if (isAuto) {
12583                         height *= this.rowCnt / 6;
12584                 }
12585
12586                 distributeHeight(this.dayGrid.rowEls, height, !isAuto); // if auto, don't compensate for height-hogging rows
12587         },
12588
12589
12590         isFixedWeeks: function() {
12591                 return this.opt('fixedWeekCount');
12592         }
12593
12594 });
12595
12596 ;;
12597
12598 fcViews.basic = {
12599         'class': BasicView
12600 };
12601
12602 fcViews.basicDay = {
12603         type: 'basic',
12604         duration: { days: 1 }
12605 };
12606
12607 fcViews.basicWeek = {
12608         type: 'basic',
12609         duration: { weeks: 1 }
12610 };
12611
12612 fcViews.month = {
12613         'class': MonthView,
12614         duration: { months: 1 }, // important for prev/next
12615         defaults: {
12616                 fixedWeekCount: true
12617         }
12618 };
12619 ;;
12620
12621 /* An abstract class for all agenda-related views. Displays one more columns with time slots running vertically.
12622 ----------------------------------------------------------------------------------------------------------------------*/
12623 // Is a manager for the TimeGrid subcomponent and possibly the DayGrid subcomponent (if allDaySlot is on).
12624 // Responsible for managing width/height.
12625
12626 var AgendaView = FC.AgendaView = View.extend({
12627
12628         scroller: null,
12629
12630         timeGridClass: TimeGrid, // class used to instantiate the timeGrid. subclasses can override
12631         timeGrid: null, // the main time-grid subcomponent of this view
12632
12633         dayGridClass: DayGrid, // class used to instantiate the dayGrid. subclasses can override
12634         dayGrid: null, // the "all-day" subcomponent. if all-day is turned off, this will be null
12635
12636         axisWidth: null, // the width of the time axis running down the side
12637
12638         headContainerEl: null, // div that hold's the timeGrid's rendered date header
12639         noScrollRowEls: null, // set of fake row elements that must compensate when scroller has scrollbars
12640
12641         // when the time-grid isn't tall enough to occupy the given height, we render an <hr> underneath
12642         bottomRuleEl: null,
12643
12644
12645         initialize: function() {
12646                 this.timeGrid = this.instantiateTimeGrid();
12647
12648                 if (this.opt('allDaySlot')) { // should we display the "all-day" area?
12649                         this.dayGrid = this.instantiateDayGrid(); // the all-day subcomponent of this view
12650                 }
12651
12652                 this.scroller = new Scroller({
12653                         overflowX: 'hidden',
12654                         overflowY: 'auto'
12655                 });
12656         },
12657
12658
12659         // Instantiates the TimeGrid object this view needs. Draws from this.timeGridClass
12660         instantiateTimeGrid: function() {
12661                 var subclass = this.timeGridClass.extend(agendaTimeGridMethods);
12662
12663                 return new subclass(this);
12664         },
12665
12666
12667         // Instantiates the DayGrid object this view might need. Draws from this.dayGridClass
12668         instantiateDayGrid: function() {
12669                 var subclass = this.dayGridClass.extend(agendaDayGridMethods);
12670
12671                 return new subclass(this);
12672         },
12673
12674
12675         /* Rendering
12676         ------------------------------------------------------------------------------------------------------------------*/
12677
12678
12679         // Sets the display range and computes all necessary dates
12680         setRange: function(range) {
12681                 View.prototype.setRange.call(this, range); // call the super-method
12682
12683                 this.timeGrid.setRange(range);
12684                 if (this.dayGrid) {
12685                         this.dayGrid.setRange(range);
12686                 }
12687         },
12688
12689
12690         // Renders the view into `this.el`, which has already been assigned
12691         renderDates: function() {
12692
12693                 this.el.addClass('fc-agenda-view').html(this.renderSkeletonHtml());
12694                 this.renderHead();
12695
12696                 this.scroller.render();
12697                 var timeGridWrapEl = this.scroller.el.addClass('fc-time-grid-container');
12698                 var timeGridEl = $('<div class="fc-time-grid" />').appendTo(timeGridWrapEl);
12699                 this.el.find('.fc-body > tr > td').append(timeGridWrapEl);
12700
12701                 this.timeGrid.setElement(timeGridEl);
12702                 this.timeGrid.renderDates();
12703
12704                 // the <hr> that sometimes displays under the time-grid
12705                 this.bottomRuleEl = $('<hr class="fc-divider ' + this.widgetHeaderClass + '"/>')
12706                         .appendTo(this.timeGrid.el); // inject it into the time-grid
12707
12708                 if (this.dayGrid) {
12709                         this.dayGrid.setElement(this.el.find('.fc-day-grid'));
12710                         this.dayGrid.renderDates();
12711
12712                         // have the day-grid extend it's coordinate area over the <hr> dividing the two grids
12713                         this.dayGrid.bottomCoordPadding = this.dayGrid.el.next('hr').outerHeight();
12714                 }
12715
12716                 this.noScrollRowEls = this.el.find('.fc-row:not(.fc-scroller *)'); // fake rows not within the scroller
12717         },
12718
12719
12720         // render the day-of-week headers
12721         renderHead: function() {
12722                 this.headContainerEl =
12723                         this.el.find('.fc-head-container')
12724                                 .html(this.timeGrid.renderHeadHtml());
12725         },
12726
12727
12728         // Unrenders the content of the view. Since we haven't separated skeleton rendering from date rendering,
12729         // always completely kill each grid's rendering.
12730         unrenderDates: function() {
12731                 this.timeGrid.unrenderDates();
12732                 this.timeGrid.removeElement();
12733
12734                 if (this.dayGrid) {
12735                         this.dayGrid.unrenderDates();
12736                         this.dayGrid.removeElement();
12737                 }
12738
12739                 this.scroller.destroy();
12740         },
12741
12742
12743         // Builds the HTML skeleton for the view.
12744         // The day-grid and time-grid components will render inside containers defined by this HTML.
12745         renderSkeletonHtml: function() {
12746                 return '' +
12747                         '<table>' +
12748                                 '<thead class="fc-head">' +
12749                                         '<tr>' +
12750                                                 '<td class="fc-head-container ' + this.widgetHeaderClass + '"></td>' +
12751                                         '</tr>' +
12752                                 '</thead>' +
12753                                 '<tbody class="fc-body">' +
12754                                         '<tr>' +
12755                                                 '<td class="' + this.widgetContentClass + '">' +
12756                                                         (this.dayGrid ?
12757                                                                 '<div class="fc-day-grid"/>' +
12758                                                                 '<hr class="fc-divider ' + this.widgetHeaderClass + '"/>' :
12759                                                                 ''
12760                                                                 ) +
12761                                                 '</td>' +
12762                                         '</tr>' +
12763                                 '</tbody>' +
12764                         '</table>';
12765         },
12766
12767
12768         // Generates an HTML attribute string for setting the width of the axis, if it is known
12769         axisStyleAttr: function() {
12770                 if (this.axisWidth !== null) {
12771                          return 'style="width:' + this.axisWidth + 'px"';
12772                 }
12773                 return '';
12774         },
12775
12776
12777         /* Business Hours
12778         ------------------------------------------------------------------------------------------------------------------*/
12779
12780
12781         renderBusinessHours: function() {
12782                 this.timeGrid.renderBusinessHours();
12783
12784                 if (this.dayGrid) {
12785                         this.dayGrid.renderBusinessHours();
12786                 }
12787         },
12788
12789
12790         unrenderBusinessHours: function() {
12791                 this.timeGrid.unrenderBusinessHours();
12792
12793                 if (this.dayGrid) {
12794                         this.dayGrid.unrenderBusinessHours();
12795                 }
12796         },
12797
12798
12799         /* Now Indicator
12800         ------------------------------------------------------------------------------------------------------------------*/
12801
12802
12803         getNowIndicatorUnit: function() {
12804                 return this.timeGrid.getNowIndicatorUnit();
12805         },
12806
12807
12808         renderNowIndicator: function(date) {
12809                 this.timeGrid.renderNowIndicator(date);
12810         },
12811
12812
12813         unrenderNowIndicator: function() {
12814                 this.timeGrid.unrenderNowIndicator();
12815         },
12816
12817
12818         /* Dimensions
12819         ------------------------------------------------------------------------------------------------------------------*/
12820
12821
12822         updateSize: function(isResize) {
12823                 this.timeGrid.updateSize(isResize);
12824
12825                 View.prototype.updateSize.call(this, isResize); // call the super-method
12826         },
12827
12828
12829         // Refreshes the horizontal dimensions of the view
12830         updateWidth: function() {
12831                 // make all axis cells line up, and record the width so newly created axis cells will have it
12832                 this.axisWidth = matchCellWidths(this.el.find('.fc-axis'));
12833         },
12834
12835
12836         // Adjusts the vertical dimensions of the view to the specified values
12837         setHeight: function(totalHeight, isAuto) {
12838                 var eventLimit;
12839                 var scrollerHeight;
12840                 var scrollbarWidths;
12841
12842                 // reset all dimensions back to the original state
12843                 this.bottomRuleEl.hide(); // .show() will be called later if this <hr> is necessary
12844                 this.scroller.clear(); // sets height to 'auto' and clears overflow
12845                 uncompensateScroll(this.noScrollRowEls);
12846
12847                 // limit number of events in the all-day area
12848                 if (this.dayGrid) {
12849                         this.dayGrid.removeSegPopover(); // kill the "more" popover if displayed
12850
12851                         eventLimit = this.opt('eventLimit');
12852                         if (eventLimit && typeof eventLimit !== 'number') {
12853                                 eventLimit = AGENDA_ALL_DAY_EVENT_LIMIT; // make sure "auto" goes to a real number
12854                         }
12855                         if (eventLimit) {
12856                                 this.dayGrid.limitRows(eventLimit);
12857                         }
12858                 }
12859
12860                 if (!isAuto) { // should we force dimensions of the scroll container?
12861
12862                         scrollerHeight = this.computeScrollerHeight(totalHeight);
12863                         this.scroller.setHeight(scrollerHeight);
12864                         scrollbarWidths = this.scroller.getScrollbarWidths();
12865
12866                         if (scrollbarWidths.left || scrollbarWidths.right) { // using scrollbars?
12867
12868                                 // make the all-day and header rows lines up
12869                                 compensateScroll(this.noScrollRowEls, scrollbarWidths);
12870
12871                                 // the scrollbar compensation might have changed text flow, which might affect height, so recalculate
12872                                 // and reapply the desired height to the scroller.
12873                                 scrollerHeight = this.computeScrollerHeight(totalHeight);
12874                                 this.scroller.setHeight(scrollerHeight);
12875                         }
12876
12877                         // guarantees the same scrollbar widths
12878                         this.scroller.lockOverflow(scrollbarWidths);
12879
12880                         // if there's any space below the slats, show the horizontal rule.
12881                         // this won't cause any new overflow, because lockOverflow already called.
12882                         if (this.timeGrid.getTotalSlatHeight() < scrollerHeight) {
12883                                 this.bottomRuleEl.show();
12884                         }
12885                 }
12886         },
12887
12888
12889         // given a desired total height of the view, returns what the height of the scroller should be
12890         computeScrollerHeight: function(totalHeight) {
12891                 return totalHeight -
12892                         subtractInnerElHeight(this.el, this.scroller.el); // everything that's NOT the scroller
12893         },
12894
12895
12896         /* Scroll
12897         ------------------------------------------------------------------------------------------------------------------*/
12898
12899
12900         // Computes the initial pre-configured scroll state prior to allowing the user to change it
12901         computeInitialScroll: function() {
12902                 var scrollTime = moment.duration(this.opt('scrollTime'));
12903                 var top = this.timeGrid.computeTimeTop(scrollTime);
12904
12905                 // zoom can give weird floating-point values. rather scroll a little bit further
12906                 top = Math.ceil(top);
12907
12908                 if (top) {
12909                         top++; // to overcome top border that slots beyond the first have. looks better
12910                 }
12911
12912                 return top;
12913         },
12914
12915
12916         queryScroll: function() {
12917                 return this.scroller.getScrollTop();
12918         },
12919
12920
12921         setScroll: function(top) {
12922                 this.scroller.setScrollTop(top);
12923         },
12924
12925
12926         /* Hit Areas
12927         ------------------------------------------------------------------------------------------------------------------*/
12928         // forward all hit-related method calls to the grids (dayGrid might not be defined)
12929
12930
12931         prepareHits: function() {
12932                 this.timeGrid.prepareHits();
12933                 if (this.dayGrid) {
12934                         this.dayGrid.prepareHits();
12935                 }
12936         },
12937
12938
12939         releaseHits: function() {
12940                 this.timeGrid.releaseHits();
12941                 if (this.dayGrid) {
12942                         this.dayGrid.releaseHits();
12943                 }
12944         },
12945
12946
12947         queryHit: function(left, top) {
12948                 var hit = this.timeGrid.queryHit(left, top);
12949
12950                 if (!hit && this.dayGrid) {
12951                         hit = this.dayGrid.queryHit(left, top);
12952                 }
12953
12954                 return hit;
12955         },
12956
12957
12958         getHitSpan: function(hit) {
12959                 // TODO: hit.component is set as a hack to identify where the hit came from
12960                 return hit.component.getHitSpan(hit);
12961         },
12962
12963
12964         getHitEl: function(hit) {
12965                 // TODO: hit.component is set as a hack to identify where the hit came from
12966                 return hit.component.getHitEl(hit);
12967         },
12968
12969
12970         /* Events
12971         ------------------------------------------------------------------------------------------------------------------*/
12972
12973
12974         // Renders events onto the view and populates the View's segment array
12975         renderEvents: function(events) {
12976                 var dayEvents = [];
12977                 var timedEvents = [];
12978                 var daySegs = [];
12979                 var timedSegs;
12980                 var i;
12981
12982                 // separate the events into all-day and timed
12983                 for (i = 0; i < events.length; i++) {
12984                         if (events[i].allDay) {
12985                                 dayEvents.push(events[i]);
12986                         }
12987                         else {
12988                                 timedEvents.push(events[i]);
12989                         }
12990                 }
12991
12992                 // render the events in the subcomponents
12993                 timedSegs = this.timeGrid.renderEvents(timedEvents);
12994                 if (this.dayGrid) {
12995                         daySegs = this.dayGrid.renderEvents(dayEvents);
12996                 }
12997
12998                 // the all-day area is flexible and might have a lot of events, so shift the height
12999                 this.updateHeight();
13000         },
13001
13002
13003         // Retrieves all segment objects that are rendered in the view
13004         getEventSegs: function() {
13005                 return this.timeGrid.getEventSegs().concat(
13006                         this.dayGrid ? this.dayGrid.getEventSegs() : []
13007                 );
13008         },
13009
13010
13011         // Unrenders all event elements and clears internal segment data
13012         unrenderEvents: function() {
13013
13014                 // unrender the events in the subcomponents
13015                 this.timeGrid.unrenderEvents();
13016                 if (this.dayGrid) {
13017                         this.dayGrid.unrenderEvents();
13018                 }
13019
13020                 // we DON'T need to call updateHeight() because
13021                 // a renderEvents() call always happens after this, which will eventually call updateHeight()
13022         },
13023
13024
13025         /* Dragging (for events and external elements)
13026         ------------------------------------------------------------------------------------------------------------------*/
13027
13028
13029         // A returned value of `true` signals that a mock "helper" event has been rendered.
13030         renderDrag: function(dropLocation, seg) {
13031                 if (dropLocation.start.hasTime()) {
13032                         return this.timeGrid.renderDrag(dropLocation, seg);
13033                 }
13034                 else if (this.dayGrid) {
13035                         return this.dayGrid.renderDrag(dropLocation, seg);
13036                 }
13037         },
13038
13039
13040         unrenderDrag: function() {
13041                 this.timeGrid.unrenderDrag();
13042                 if (this.dayGrid) {
13043                         this.dayGrid.unrenderDrag();
13044                 }
13045         },
13046
13047
13048         /* Selection
13049         ------------------------------------------------------------------------------------------------------------------*/
13050
13051
13052         // Renders a visual indication of a selection
13053         renderSelection: function(span) {
13054                 if (span.start.hasTime() || span.end.hasTime()) {
13055                         this.timeGrid.renderSelection(span);
13056                 }
13057                 else if (this.dayGrid) {
13058                         this.dayGrid.renderSelection(span);
13059                 }
13060         },
13061
13062
13063         // Unrenders a visual indications of a selection
13064         unrenderSelection: function() {
13065                 this.timeGrid.unrenderSelection();
13066                 if (this.dayGrid) {
13067                         this.dayGrid.unrenderSelection();
13068                 }
13069         }
13070
13071 });
13072
13073
13074 // Methods that will customize the rendering behavior of the AgendaView's timeGrid
13075 // TODO: move into TimeGrid
13076 var agendaTimeGridMethods = {
13077
13078
13079         // Generates the HTML that will go before the day-of week header cells
13080         renderHeadIntroHtml: function() {
13081                 var view = this.view;
13082                 var weekText;
13083
13084                 if (view.opt('weekNumbers')) {
13085                         weekText = this.start.format(view.opt('smallWeekFormat'));
13086
13087                         return '' +
13088                                 '<th class="fc-axis fc-week-number ' + view.widgetHeaderClass + '" ' + view.axisStyleAttr() + '>' +
13089                                         view.buildGotoAnchorHtml( // aside from link, important for matchCellWidths
13090                                                 { date: this.start, type: 'week', forceOff: this.colCnt > 1 },
13091                                                 htmlEscape(weekText) // inner HTML
13092                                         ) +
13093                                 '</th>';
13094                 }
13095                 else {
13096                         return '<th class="fc-axis ' + view.widgetHeaderClass + '" ' + view.axisStyleAttr() + '></th>';
13097                 }
13098         },
13099
13100
13101         // Generates the HTML that goes before the bg of the TimeGrid slot area. Long vertical column.
13102         renderBgIntroHtml: function() {
13103                 var view = this.view;
13104
13105                 return '<td class="fc-axis ' + view.widgetContentClass + '" ' + view.axisStyleAttr() + '></td>';
13106         },
13107
13108
13109         // Generates the HTML that goes before all other types of cells.
13110         // Affects content-skeleton, helper-skeleton, highlight-skeleton for both the time-grid and day-grid.
13111         renderIntroHtml: function() {
13112                 var view = this.view;
13113
13114                 return '<td class="fc-axis" ' + view.axisStyleAttr() + '></td>';
13115         }
13116
13117 };
13118
13119
13120 // Methods that will customize the rendering behavior of the AgendaView's dayGrid
13121 var agendaDayGridMethods = {
13122
13123
13124         // Generates the HTML that goes before the all-day cells
13125         renderBgIntroHtml: function() {
13126                 var view = this.view;
13127
13128                 return '' +
13129                         '<td class="fc-axis ' + view.widgetContentClass + '" ' + view.axisStyleAttr() + '>' +
13130                                 '<span>' + // needed for matchCellWidths
13131                                         view.getAllDayHtml() +
13132                                 '</span>' +
13133                         '</td>';
13134         },
13135
13136
13137         // Generates the HTML that goes before all other types of cells.
13138         // Affects content-skeleton, helper-skeleton, highlight-skeleton for both the time-grid and day-grid.
13139         renderIntroHtml: function() {
13140                 var view = this.view;
13141
13142                 return '<td class="fc-axis" ' + view.axisStyleAttr() + '></td>';
13143         }
13144
13145 };
13146
13147 ;;
13148
13149 var AGENDA_ALL_DAY_EVENT_LIMIT = 5;
13150
13151 // potential nice values for the slot-duration and interval-duration
13152 // from largest to smallest
13153 var AGENDA_STOCK_SUB_DURATIONS = [
13154         { hours: 1 },
13155         { minutes: 30 },
13156         { minutes: 15 },
13157         { seconds: 30 },
13158         { seconds: 15 }
13159 ];
13160
13161 fcViews.agenda = {
13162         'class': AgendaView,
13163         defaults: {
13164                 allDaySlot: true,
13165                 slotDuration: '00:30:00',
13166                 minTime: '00:00:00',
13167                 maxTime: '24:00:00',
13168                 slotEventOverlap: true // a bad name. confused with overlap/constraint system
13169         }
13170 };
13171
13172 fcViews.agendaDay = {
13173         type: 'agenda',
13174         duration: { days: 1 }
13175 };
13176
13177 fcViews.agendaWeek = {
13178         type: 'agenda',
13179         duration: { weeks: 1 }
13180 };
13181 ;;
13182
13183 /*
13184 Responsible for the scroller, and forwarding event-related actions into the "grid"
13185 */
13186 var ListView = View.extend({
13187
13188         grid: null,
13189         scroller: null,
13190
13191         initialize: function() {
13192                 this.grid = new ListViewGrid(this);
13193                 this.scroller = new Scroller({
13194                         overflowX: 'hidden',
13195                         overflowY: 'auto'
13196                 });
13197         },
13198
13199         setRange: function(range) {
13200                 View.prototype.setRange.call(this, range); // super
13201
13202                 this.grid.setRange(range); // needs to process range-related options
13203         },
13204
13205         renderSkeleton: function() {
13206                 this.el.addClass(
13207                         'fc-list-view ' +
13208                         this.widgetContentClass
13209                 );
13210
13211                 this.scroller.render();
13212                 this.scroller.el.appendTo(this.el);
13213
13214                 this.grid.setElement(this.scroller.scrollEl);
13215         },
13216
13217         unrenderSkeleton: function() {
13218                 this.scroller.destroy(); // will remove the Grid too
13219         },
13220
13221         setHeight: function(totalHeight, isAuto) {
13222                 this.scroller.setHeight(this.computeScrollerHeight(totalHeight));
13223         },
13224
13225         computeScrollerHeight: function(totalHeight) {
13226                 return totalHeight -
13227                         subtractInnerElHeight(this.el, this.scroller.el); // everything that's NOT the scroller
13228         },
13229
13230         renderEvents: function(events) {
13231                 this.grid.renderEvents(events);
13232         },
13233
13234         unrenderEvents: function() {
13235                 this.grid.unrenderEvents();
13236         },
13237
13238         isEventResizable: function(event) {
13239                 return false;
13240         },
13241
13242         isEventDraggable: function(event) {
13243                 return false;
13244         }
13245
13246 });
13247
13248 /*
13249 Responsible for event rendering and user-interaction.
13250 Its "el" is the inner-content of the above view's scroller.
13251 */
13252 var ListViewGrid = Grid.extend({
13253
13254         segSelector: '.fc-list-item', // which elements accept event actions
13255         hasDayInteractions: false, // no day selection or day clicking
13256
13257         // slices by day
13258         spanToSegs: function(span) {
13259                 var view = this.view;
13260                 var dayStart = view.start.clone().time(0); // timed, so segs get times!
13261                 var dayIndex = 0;
13262                 var seg;
13263                 var segs = [];
13264
13265                 while (dayStart < view.end) {
13266
13267                         seg = intersectRanges(span, {
13268                                 start: dayStart,
13269                                 end: dayStart.clone().add(1, 'day')
13270                         });
13271
13272                         if (seg) {
13273                                 seg.dayIndex = dayIndex;
13274                                 segs.push(seg);
13275                         }
13276
13277                         dayStart.add(1, 'day');
13278                         dayIndex++;
13279
13280                         // detect when span won't go fully into the next day,
13281                         // and mutate the latest seg to the be the end.
13282                         if (
13283                                 seg && !seg.isEnd && span.end.hasTime() &&
13284                                 span.end < dayStart.clone().add(this.view.nextDayThreshold)
13285                         ) {
13286                                 seg.end = span.end.clone();
13287                                 seg.isEnd = true;
13288                                 break;
13289                         }
13290                 }
13291
13292                 return segs;
13293         },
13294
13295         // like "4:00am"
13296         computeEventTimeFormat: function() {
13297                 return this.view.opt('mediumTimeFormat');
13298         },
13299
13300         // for events with a url, the whole <tr> should be clickable,
13301         // but it's impossible to wrap with an <a> tag. simulate this.
13302         handleSegClick: function(seg, ev) {
13303                 var url;
13304
13305                 Grid.prototype.handleSegClick.apply(this, arguments); // super. might prevent the default action
13306
13307                 // not clicking on or within an <a> with an href
13308                 if (!$(ev.target).closest('a[href]').length) {
13309                         url = seg.event.url;
13310                         if (url && !ev.isDefaultPrevented()) { // jsEvent not cancelled in handler
13311                                 window.location.href = url; // simulate link click
13312                         }
13313                 }
13314         },
13315
13316         // returns list of foreground segs that were actually rendered
13317         renderFgSegs: function(segs) {
13318                 segs = this.renderFgSegEls(segs); // might filter away hidden events
13319
13320                 if (!segs.length) {
13321                         this.renderEmptyMessage();
13322                 }
13323                 else {
13324                         this.renderSegList(segs);
13325                 }
13326
13327                 return segs;
13328         },
13329
13330         renderEmptyMessage: function() {
13331                 this.el.html(
13332                         '<div class="fc-list-empty-wrap2">' + // TODO: try less wraps
13333                         '<div class="fc-list-empty-wrap1">' +
13334                         '<div class="fc-list-empty">' +
13335                                 htmlEscape(this.view.opt('noEventsMessage')) +
13336                         '</div>' +
13337                         '</div>' +
13338                         '</div>'
13339                 );
13340         },
13341
13342         // render the event segments in the view
13343         renderSegList: function(allSegs) {
13344                 var segsByDay = this.groupSegsByDay(allSegs); // sparse array
13345                 var dayIndex;
13346                 var daySegs;
13347                 var i;
13348                 var tableEl = $('<table class="fc-list-table"><tbody/></table>');
13349                 var tbodyEl = tableEl.find('tbody');
13350
13351                 for (dayIndex = 0; dayIndex < segsByDay.length; dayIndex++) {
13352                         daySegs = segsByDay[dayIndex];
13353                         if (daySegs) { // sparse array, so might be undefined
13354
13355                                 // append a day header
13356                                 tbodyEl.append(this.dayHeaderHtml(
13357                                         this.view.start.clone().add(dayIndex, 'days')
13358                                 ));
13359
13360                                 this.sortEventSegs(daySegs);
13361
13362                                 for (i = 0; i < daySegs.length; i++) {
13363                                         tbodyEl.append(daySegs[i].el); // append event row
13364                                 }
13365                         }
13366                 }
13367
13368                 this.el.empty().append(tableEl);
13369         },
13370
13371         // Returns a sparse array of arrays, segs grouped by their dayIndex
13372         groupSegsByDay: function(segs) {
13373                 var segsByDay = []; // sparse array
13374                 var i, seg;
13375
13376                 for (i = 0; i < segs.length; i++) {
13377                         seg = segs[i];
13378                         (segsByDay[seg.dayIndex] || (segsByDay[seg.dayIndex] = []))
13379                                 .push(seg);
13380                 }
13381
13382                 return segsByDay;
13383         },
13384
13385         // generates the HTML for the day headers that live amongst the event rows
13386         dayHeaderHtml: function(dayDate) {
13387                 var view = this.view;
13388                 var mainFormat = view.opt('listDayFormat');
13389                 var altFormat = view.opt('listDayAltFormat');
13390
13391                 return '<tr class="fc-list-heading" data-date="' + dayDate.format('YYYY-MM-DD') + '">' +
13392                         '<td class="' + view.widgetHeaderClass + '" colspan="3">' +
13393                                 (mainFormat ?
13394                                         view.buildGotoAnchorHtml(
13395                                                 dayDate,
13396                                                 { 'class': 'fc-list-heading-main' },
13397                                                 htmlEscape(dayDate.format(mainFormat)) // inner HTML
13398                                         ) :
13399                                         '') +
13400                                 (altFormat ?
13401                                         view.buildGotoAnchorHtml(
13402                                                 dayDate,
13403                                                 { 'class': 'fc-list-heading-alt' },
13404                                                 htmlEscape(dayDate.format(altFormat)) // inner HTML
13405                                         ) :
13406                                         '') +
13407                         '</td>' +
13408                 '</tr>';
13409         },
13410
13411         // generates the HTML for a single event row
13412         fgSegHtml: function(seg) {
13413                 var view = this.view;
13414                 var classes = [ 'fc-list-item' ].concat(this.getSegCustomClasses(seg));
13415                 var bgColor = this.getSegBackgroundColor(seg);
13416                 var event = seg.event;
13417                 var url = event.url;
13418                 var timeHtml;
13419
13420                 if (event.allDay) {
13421                         timeHtml = view.getAllDayHtml();
13422                 }
13423                 else if (view.isMultiDayEvent(event)) { // if the event appears to span more than one day
13424                         if (seg.isStart || seg.isEnd) { // outer segment that probably lasts part of the day
13425                                 timeHtml = htmlEscape(this.getEventTimeText(seg));
13426                         }
13427                         else { // inner segment that lasts the whole day
13428                                 timeHtml = view.getAllDayHtml();
13429                         }
13430                 }
13431                 else {
13432                         // Display the normal time text for the *event's* times
13433                         timeHtml = htmlEscape(this.getEventTimeText(event));
13434                 }
13435
13436                 if (url) {
13437                         classes.push('fc-has-url');
13438                 }
13439
13440                 return '<tr class="' + classes.join(' ') + '">' +
13441                         (this.displayEventTime ?
13442                                 '<td class="fc-list-item-time ' + view.widgetContentClass + '">' +
13443                                         (timeHtml || '') +
13444                                 '</td>' :
13445                                 '') +
13446                         '<td class="fc-list-item-marker ' + view.widgetContentClass + '">' +
13447                                 '<span class="fc-event-dot"' +
13448                                 (bgColor ?
13449                                         ' style="background-color:' + bgColor + '"' :
13450                                         '') +
13451                                 '></span>' +
13452                         '</td>' +
13453                         '<td class="fc-list-item-title ' + view.widgetContentClass + '">' +
13454                                 '<a' + (url ? ' href="' + htmlEscape(url) + '"' : '') + '>' +
13455                                         htmlEscape(seg.event.title || '') +
13456                                 '</a>' +
13457                         '</td>' +
13458                 '</tr>';
13459         }
13460
13461 });
13462
13463 ;;
13464
13465 fcViews.list = {
13466         'class': ListView,
13467         buttonTextKey: 'list', // what to lookup in locale files
13468         defaults: {
13469                 buttonText: 'list', // text to display for English
13470                 listDayFormat: 'LL', // like "January 1, 2016"
13471                 noEventsMessage: 'No events to display'
13472         }
13473 };
13474
13475 fcViews.listDay = {
13476         type: 'list',
13477         duration: { days: 1 },
13478         defaults: {
13479                 listDayFormat: 'dddd' // day-of-week is all we need. full date is probably in header
13480         }
13481 };
13482
13483 fcViews.listWeek = {
13484         type: 'list',
13485         duration: { weeks: 1 },
13486         defaults: {
13487                 listDayFormat: 'dddd', // day-of-week is more important
13488                 listDayAltFormat: 'LL'
13489         }
13490 };
13491
13492 fcViews.listMonth = {
13493         type: 'list',
13494         duration: { month: 1 },
13495         defaults: {
13496                 listDayAltFormat: 'dddd' // day-of-week is nice-to-have
13497         }
13498 };
13499
13500 fcViews.listYear = {
13501         type: 'list',
13502         duration: { year: 1 },
13503         defaults: {
13504                 listDayAltFormat: 'dddd' // day-of-week is nice-to-have
13505         }
13506 };
13507
13508 ;;
13509 \r
13510 return FC; // export for Node/CommonJS\r
13511 });