c90a57272c7f2920a788038e91fa99ce63b28fe2
[lhc/web/www.git] / www / plugins-dist / organiseur / lib / fullcalendar / fullcalendar.js
1 /*!
2 * FullCalendar v3.5.1
3 * Docs & License: https://fullcalendar.io/
4 * (c) 2017 Adam Shaw
5 */
6
7 (function(factory) {
8 if (typeof define === 'function' && define.amd) {
9 define([ 'jquery', 'moment' ], factory);
10 }
11 else if (typeof exports === 'object') { // Node/CommonJS
12 module.exports = factory(require('jquery'), require('moment'));
13 }
14 else {
15 factory(jQuery, moment);
16 }
17 })(function($, moment) {
18
19 ;;
20
21 var FC = $.fullCalendar = {
22 version: "3.5.1",
23 // When introducing internal API incompatibilities (where fullcalendar plugins would break),
24 // the minor version of the calendar should be upped (ex: 2.7.2 -> 2.8.0)
25 // and the below integer should be incremented.
26 internalApiVersion: 10
27 };
28 var fcViews = FC.views = {};
29
30
31 $.fn.fullCalendar = function(options) {
32 var args = Array.prototype.slice.call(arguments, 1); // for a possible method call
33 var res = this; // what this function will return (this jQuery object by default)
34
35 this.each(function(i, _element) { // loop each DOM element involved
36 var element = $(_element);
37 var calendar = element.data('fullCalendar'); // get the existing calendar object (if any)
38 var singleRes; // the returned value of this single method call
39
40 // a method call
41 if (typeof options === 'string') {
42
43 if (options === 'getCalendar') {
44 if (!i) { // first element only
45 res = calendar;
46 }
47 }
48 else if (options === 'destroy') { // don't warn if no calendar object
49 if (calendar) {
50 calendar.destroy();
51 element.removeData('fullCalendar');
52 }
53 }
54 else if (!calendar) {
55 FC.warn("Attempting to call a FullCalendar method on an element with no calendar.");
56 }
57 else if ($.isFunction(calendar[options])) {
58 singleRes = calendar[options].apply(calendar, args);
59
60 if (!i) {
61 res = singleRes; // record the first method call result
62 }
63 if (options === 'destroy') { // for the destroy method, must remove Calendar object data
64 element.removeData('fullCalendar');
65 }
66 }
67 else {
68 FC.warn("'" + options + "' is an unknown FullCalendar method.");
69 }
70 }
71 // a new calendar initialization
72 else if (!calendar) { // don't initialize twice
73 calendar = new Calendar(element, options);
74 element.data('fullCalendar', calendar);
75 calendar.render();
76 }
77 });
78
79 return res;
80 };
81
82
83 var complexOptions = [ // names of options that are objects whose properties should be combined
84 'header',
85 'footer',
86 'buttonText',
87 'buttonIcons',
88 'themeButtonIcons'
89 ];
90
91
92 // Merges an array of option objects into a single object
93 function mergeOptions(optionObjs) {
94 return mergeProps(optionObjs, complexOptions);
95 }
96
97 ;;
98
99 // exports
100 FC.applyAll = applyAll;
101 FC.debounce = debounce;
102 FC.isInt = isInt;
103 FC.htmlEscape = htmlEscape;
104 FC.cssToStr = cssToStr;
105 FC.proxy = proxy;
106 FC.capitaliseFirstLetter = capitaliseFirstLetter;
107
108
109 /* FullCalendar-specific DOM Utilities
110 ----------------------------------------------------------------------------------------------------------------------*/
111
112
113 // Given the scrollbar widths of some other container, create borders/margins on rowEls in order to match the left
114 // and right space that was offset by the scrollbars. A 1-pixel border first, then margin beyond that.
115 function compensateScroll(rowEls, scrollbarWidths) {
116 if (scrollbarWidths.left) {
117 rowEls.css({
118 'border-left-width': 1,
119 'margin-left': scrollbarWidths.left - 1
120 });
121 }
122 if (scrollbarWidths.right) {
123 rowEls.css({
124 'border-right-width': 1,
125 'margin-right': scrollbarWidths.right - 1
126 });
127 }
128 }
129
130
131 // Undoes compensateScroll and restores all borders/margins
132 function uncompensateScroll(rowEls) {
133 rowEls.css({
134 'margin-left': '',
135 'margin-right': '',
136 'border-left-width': '',
137 'border-right-width': ''
138 });
139 }
140
141
142 // Make the mouse cursor express that an event is not allowed in the current area
143 function disableCursor() {
144 $('body').addClass('fc-not-allowed');
145 }
146
147
148 // Returns the mouse cursor to its original look
149 function enableCursor() {
150 $('body').removeClass('fc-not-allowed');
151 }
152
153
154 // Given a total available height to fill, have `els` (essentially child rows) expand to accomodate.
155 // By default, all elements that are shorter than the recommended height are expanded uniformly, not considering
156 // any other els that are already too tall. if `shouldRedistribute` is on, it considers these tall rows and
157 // reduces the available height.
158 function distributeHeight(els, availableHeight, shouldRedistribute) {
159
160 // *FLOORING NOTE*: we floor in certain places because zoom can give inaccurate floating-point dimensions,
161 // and it is better to be shorter than taller, to avoid creating unnecessary scrollbars.
162
163 var minOffset1 = Math.floor(availableHeight / els.length); // for non-last element
164 var minOffset2 = Math.floor(availableHeight - minOffset1 * (els.length - 1)); // for last element *FLOORING NOTE*
165 var flexEls = []; // elements that are allowed to expand. array of DOM nodes
166 var flexOffsets = []; // amount of vertical space it takes up
167 var flexHeights = []; // actual css height
168 var usedHeight = 0;
169
170 undistributeHeight(els); // give all elements their natural height
171
172 // find elements that are below the recommended height (expandable).
173 // important to query for heights in a single first pass (to avoid reflow oscillation).
174 els.each(function(i, el) {
175 var minOffset = i === els.length - 1 ? minOffset2 : minOffset1;
176 var naturalOffset = $(el).outerHeight(true);
177
178 if (naturalOffset < minOffset) {
179 flexEls.push(el);
180 flexOffsets.push(naturalOffset);
181 flexHeights.push($(el).height());
182 }
183 else {
184 // this element stretches past recommended height (non-expandable). mark the space as occupied.
185 usedHeight += naturalOffset;
186 }
187 });
188
189 // readjust the recommended height to only consider the height available to non-maxed-out rows.
190 if (shouldRedistribute) {
191 availableHeight -= usedHeight;
192 minOffset1 = Math.floor(availableHeight / flexEls.length);
193 minOffset2 = Math.floor(availableHeight - minOffset1 * (flexEls.length - 1)); // *FLOORING NOTE*
194 }
195
196 // assign heights to all expandable elements
197 $(flexEls).each(function(i, el) {
198 var minOffset = i === flexEls.length - 1 ? minOffset2 : minOffset1;
199 var naturalOffset = flexOffsets[i];
200 var naturalHeight = flexHeights[i];
201 var newHeight = minOffset - (naturalOffset - naturalHeight); // subtract the margin/padding
202
203 if (naturalOffset < minOffset) { // we check this again because redistribution might have changed things
204 $(el).height(newHeight);
205 }
206 });
207 }
208
209
210 // Undoes distrubuteHeight, restoring all els to their natural height
211 function undistributeHeight(els) {
212 els.height('');
213 }
214
215
216 // Given `els`, a jQuery set of <td> cells, find the cell with the largest natural width and set the widths of all the
217 // cells to be that width.
218 // PREREQUISITE: if you want a cell to take up width, it needs to have a single inner element w/ display:inline
219 function matchCellWidths(els) {
220 var maxInnerWidth = 0;
221
222 els.find('> *').each(function(i, innerEl) {
223 var innerWidth = $(innerEl).outerWidth();
224 if (innerWidth > maxInnerWidth) {
225 maxInnerWidth = innerWidth;
226 }
227 });
228
229 maxInnerWidth++; // sometimes not accurate of width the text needs to stay on one line. insurance
230
231 els.width(maxInnerWidth);
232
233 return maxInnerWidth;
234 }
235
236
237 // Given one element that resides inside another,
238 // Subtracts the height of the inner element from the outer element.
239 function subtractInnerElHeight(outerEl, innerEl) {
240 var both = outerEl.add(innerEl);
241 var diff;
242
243 // effin' IE8/9/10/11 sometimes returns 0 for dimensions. this weird hack was the only thing that worked
244 both.css({
245 position: 'relative', // cause a reflow, which will force fresh dimension recalculation
246 left: -1 // ensure reflow in case the el was already relative. negative is less likely to cause new scroll
247 });
248 diff = outerEl.outerHeight() - innerEl.outerHeight(); // grab the dimensions
249 both.css({ position: '', left: '' }); // undo hack
250
251 return diff;
252 }
253
254
255 /* Element Geom Utilities
256 ----------------------------------------------------------------------------------------------------------------------*/
257
258 FC.getOuterRect = getOuterRect;
259 FC.getClientRect = getClientRect;
260 FC.getContentRect = getContentRect;
261 FC.getScrollbarWidths = getScrollbarWidths;
262
263
264 // borrowed from https://github.com/jquery/jquery-ui/blob/1.11.0/ui/core.js#L51
265 function getScrollParent(el) {
266 var position = el.css('position'),
267 scrollParent = el.parents().filter(function() {
268 var parent = $(this);
269 return (/(auto|scroll)/).test(
270 parent.css('overflow') + parent.css('overflow-y') + parent.css('overflow-x')
271 );
272 }).eq(0);
273
274 return position === 'fixed' || !scrollParent.length ? $(el[0].ownerDocument || document) : scrollParent;
275 }
276
277
278 // Queries the outer bounding area of a jQuery element.
279 // Returns a rectangle with absolute coordinates: left, right (exclusive), top, bottom (exclusive).
280 // Origin is optional.
281 function getOuterRect(el, origin) {
282 var offset = el.offset();
283 var left = offset.left - (origin ? origin.left : 0);
284 var top = offset.top - (origin ? origin.top : 0);
285
286 return {
287 left: left,
288 right: left + el.outerWidth(),
289 top: top,
290 bottom: top + el.outerHeight()
291 };
292 }
293
294
295 // Queries the area within the margin/border/scrollbars of a jQuery element. Does not go within the padding.
296 // Returns a rectangle with absolute coordinates: left, right (exclusive), top, bottom (exclusive).
297 // Origin is optional.
298 // WARNING: given element can't have borders
299 // NOTE: should use clientLeft/clientTop, but very unreliable cross-browser.
300 function getClientRect(el, origin) {
301 var offset = el.offset();
302 var scrollbarWidths = getScrollbarWidths(el);
303 var left = offset.left + getCssFloat(el, 'border-left-width') + scrollbarWidths.left - (origin ? origin.left : 0);
304 var top = offset.top + getCssFloat(el, 'border-top-width') + scrollbarWidths.top - (origin ? origin.top : 0);
305
306 return {
307 left: left,
308 right: left + el[0].clientWidth, // clientWidth includes padding but NOT scrollbars
309 top: top,
310 bottom: top + el[0].clientHeight // clientHeight includes padding but NOT scrollbars
311 };
312 }
313
314
315 // Queries the area within the margin/border/padding of a jQuery element. Assumed not to have scrollbars.
316 // Returns a rectangle with absolute coordinates: left, right (exclusive), top, bottom (exclusive).
317 // Origin is optional.
318 function getContentRect(el, origin) {
319 var offset = el.offset(); // just outside of border, margin not included
320 var left = offset.left + getCssFloat(el, 'border-left-width') + getCssFloat(el, 'padding-left') -
321 (origin ? origin.left : 0);
322 var top = offset.top + getCssFloat(el, 'border-top-width') + getCssFloat(el, 'padding-top') -
323 (origin ? origin.top : 0);
324
325 return {
326 left: left,
327 right: left + el.width(),
328 top: top,
329 bottom: top + el.height()
330 };
331 }
332
333
334 // Returns the computed left/right/top/bottom scrollbar widths for the given jQuery element.
335 // WARNING: given element can't have borders (which will cause offsetWidth/offsetHeight to be larger).
336 // NOTE: should use clientLeft/clientTop, but very unreliable cross-browser.
337 function getScrollbarWidths(el) {
338 var leftRightWidth = el[0].offsetWidth - el[0].clientWidth;
339 var bottomWidth = el[0].offsetHeight - el[0].clientHeight;
340 var widths;
341
342 leftRightWidth = sanitizeScrollbarWidth(leftRightWidth);
343 bottomWidth = sanitizeScrollbarWidth(bottomWidth);
344
345 widths = { left: 0, right: 0, top: 0, bottom: bottomWidth };
346
347 if (getIsLeftRtlScrollbars() && el.css('direction') == 'rtl') { // is the scrollbar on the left side?
348 widths.left = leftRightWidth;
349 }
350 else {
351 widths.right = leftRightWidth;
352 }
353
354 return widths;
355 }
356
357
358 // The scrollbar width computations in getScrollbarWidths are sometimes flawed when it comes to
359 // retina displays, rounding, and IE11. Massage them into a usable value.
360 function sanitizeScrollbarWidth(width) {
361 width = Math.max(0, width); // no negatives
362 width = Math.round(width);
363 return width;
364 }
365
366
367 // Logic for determining if, when the element is right-to-left, the scrollbar appears on the left side
368
369 var _isLeftRtlScrollbars = null;
370
371 function getIsLeftRtlScrollbars() { // responsible for caching the computation
372 if (_isLeftRtlScrollbars === null) {
373 _isLeftRtlScrollbars = computeIsLeftRtlScrollbars();
374 }
375 return _isLeftRtlScrollbars;
376 }
377
378 function computeIsLeftRtlScrollbars() { // creates an offscreen test element, then removes it
379 var el = $('<div><div/></div>')
380 .css({
381 position: 'absolute',
382 top: -1000,
383 left: 0,
384 border: 0,
385 padding: 0,
386 overflow: 'scroll',
387 direction: 'rtl'
388 })
389 .appendTo('body');
390 var innerEl = el.children();
391 var res = innerEl.offset().left > el.offset().left; // is the inner div shifted to accommodate a left scrollbar?
392 el.remove();
393 return res;
394 }
395
396
397 // Retrieves a jQuery element's computed CSS value as a floating-point number.
398 // If the queried value is non-numeric (ex: IE can return "medium" for border width), will just return zero.
399 function getCssFloat(el, prop) {
400 return parseFloat(el.css(prop)) || 0;
401 }
402
403
404 /* Mouse / Touch Utilities
405 ----------------------------------------------------------------------------------------------------------------------*/
406
407 FC.preventDefault = preventDefault;
408
409
410 // Returns a boolean whether this was a left mouse click and no ctrl key (which means right click on Mac)
411 function isPrimaryMouseButton(ev) {
412 return ev.which == 1 && !ev.ctrlKey;
413 }
414
415
416 function getEvX(ev) {
417 var touches = ev.originalEvent.touches;
418
419 // on mobile FF, pageX for touch events is present, but incorrect,
420 // so, look at touch coordinates first.
421 if (touches && touches.length) {
422 return touches[0].pageX;
423 }
424
425 return ev.pageX;
426 }
427
428
429 function getEvY(ev) {
430 var touches = ev.originalEvent.touches;
431
432 // on mobile FF, pageX for touch events is present, but incorrect,
433 // so, look at touch coordinates first.
434 if (touches && touches.length) {
435 return touches[0].pageY;
436 }
437
438 return ev.pageY;
439 }
440
441
442 function getEvIsTouch(ev) {
443 return /^touch/.test(ev.type);
444 }
445
446
447 function preventSelection(el) {
448 el.addClass('fc-unselectable')
449 .on('selectstart', preventDefault);
450 }
451
452
453 function allowSelection(el) {
454 el.removeClass('fc-unselectable')
455 .off('selectstart', preventDefault);
456 }
457
458
459 // Stops a mouse/touch event from doing it's native browser action
460 function preventDefault(ev) {
461 ev.preventDefault();
462 }
463
464
465 /* General Geometry Utils
466 ----------------------------------------------------------------------------------------------------------------------*/
467
468 FC.intersectRects = intersectRects;
469
470 // Returns a new rectangle that is the intersection of the two rectangles. If they don't intersect, returns false
471 function intersectRects(rect1, rect2) {
472 var res = {
473 left: Math.max(rect1.left, rect2.left),
474 right: Math.min(rect1.right, rect2.right),
475 top: Math.max(rect1.top, rect2.top),
476 bottom: Math.min(rect1.bottom, rect2.bottom)
477 };
478
479 if (res.left < res.right && res.top < res.bottom) {
480 return res;
481 }
482 return false;
483 }
484
485
486 // Returns a new point that will have been moved to reside within the given rectangle
487 function constrainPoint(point, rect) {
488 return {
489 left: Math.min(Math.max(point.left, rect.left), rect.right),
490 top: Math.min(Math.max(point.top, rect.top), rect.bottom)
491 };
492 }
493
494
495 // Returns a point that is the center of the given rectangle
496 function getRectCenter(rect) {
497 return {
498 left: (rect.left + rect.right) / 2,
499 top: (rect.top + rect.bottom) / 2
500 };
501 }
502
503
504 // Subtracts point2's coordinates from point1's coordinates, returning a delta
505 function diffPoints(point1, point2) {
506 return {
507 left: point1.left - point2.left,
508 top: point1.top - point2.top
509 };
510 }
511
512
513 /* Object Ordering by Field
514 ----------------------------------------------------------------------------------------------------------------------*/
515
516 FC.parseFieldSpecs = parseFieldSpecs;
517 FC.compareByFieldSpecs = compareByFieldSpecs;
518 FC.compareByFieldSpec = compareByFieldSpec;
519 FC.flexibleCompare = flexibleCompare;
520
521
522 function parseFieldSpecs(input) {
523 var specs = [];
524 var tokens = [];
525 var i, token;
526
527 if (typeof input === 'string') {
528 tokens = input.split(/\s*,\s*/);
529 }
530 else if (typeof input === 'function') {
531 tokens = [ input ];
532 }
533 else if ($.isArray(input)) {
534 tokens = input;
535 }
536
537 for (i = 0; i < tokens.length; i++) {
538 token = tokens[i];
539
540 if (typeof token === 'string') {
541 specs.push(
542 token.charAt(0) == '-' ?
543 { field: token.substring(1), order: -1 } :
544 { field: token, order: 1 }
545 );
546 }
547 else if (typeof token === 'function') {
548 specs.push({ func: token });
549 }
550 }
551
552 return specs;
553 }
554
555
556 function compareByFieldSpecs(obj1, obj2, fieldSpecs) {
557 var i;
558 var cmp;
559
560 for (i = 0; i < fieldSpecs.length; i++) {
561 cmp = compareByFieldSpec(obj1, obj2, fieldSpecs[i]);
562 if (cmp) {
563 return cmp;
564 }
565 }
566
567 return 0;
568 }
569
570
571 function compareByFieldSpec(obj1, obj2, fieldSpec) {
572 if (fieldSpec.func) {
573 return fieldSpec.func(obj1, obj2);
574 }
575 return flexibleCompare(obj1[fieldSpec.field], obj2[fieldSpec.field]) *
576 (fieldSpec.order || 1);
577 }
578
579
580 function flexibleCompare(a, b) {
581 if (!a && !b) {
582 return 0;
583 }
584 if (b == null) {
585 return -1;
586 }
587 if (a == null) {
588 return 1;
589 }
590 if ($.type(a) === 'string' || $.type(b) === 'string') {
591 return String(a).localeCompare(String(b));
592 }
593 return a - b;
594 }
595
596
597 /* Date Utilities
598 ----------------------------------------------------------------------------------------------------------------------*/
599
600 FC.computeGreatestUnit = computeGreatestUnit;
601 FC.divideRangeByDuration = divideRangeByDuration;
602 FC.divideDurationByDuration = divideDurationByDuration;
603 FC.multiplyDuration = multiplyDuration;
604 FC.durationHasTime = durationHasTime;
605
606 var dayIDs = [ 'sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat' ];
607 var unitsDesc = [ 'year', 'month', 'week', 'day', 'hour', 'minute', 'second', 'millisecond' ]; // descending
608
609
610 // Diffs the two moments into a Duration where full-days are recorded first, then the remaining time.
611 // Moments will have their timezones normalized.
612 function diffDayTime(a, b) {
613 return moment.duration({
614 days: a.clone().stripTime().diff(b.clone().stripTime(), 'days'),
615 ms: a.time() - b.time() // time-of-day from day start. disregards timezone
616 });
617 }
618
619
620 // Diffs the two moments via their start-of-day (regardless of timezone). Produces whole-day durations.
621 function diffDay(a, b) {
622 return moment.duration({
623 days: a.clone().stripTime().diff(b.clone().stripTime(), 'days')
624 });
625 }
626
627
628 // Diffs two moments, producing a duration, made of a whole-unit-increment of the given unit. Uses rounding.
629 function diffByUnit(a, b, unit) {
630 return moment.duration(
631 Math.round(a.diff(b, unit, true)), // returnFloat=true
632 unit
633 );
634 }
635
636
637 // Computes the unit name of the largest whole-unit period of time.
638 // For example, 48 hours will be "days" whereas 49 hours will be "hours".
639 // Accepts start/end, a range object, or an original duration object.
640 function computeGreatestUnit(start, end) {
641 var i, unit;
642 var val;
643
644 for (i = 0; i < unitsDesc.length; i++) {
645 unit = unitsDesc[i];
646 val = computeRangeAs(unit, start, end);
647
648 if (val >= 1 && isInt(val)) {
649 break;
650 }
651 }
652
653 return unit; // will be "milliseconds" if nothing else matches
654 }
655
656
657 // like computeGreatestUnit, but has special abilities to interpret the source input for clues
658 function computeDurationGreatestUnit(duration, durationInput) {
659 var unit = computeGreatestUnit(duration);
660
661 // prevent days:7 from being interpreted as a week
662 if (unit === 'week' && typeof durationInput === 'object' && durationInput.days) {
663 unit = 'day';
664 }
665
666 return unit;
667 }
668
669
670 // Computes the number of units (like "hours") in the given range.
671 // Range can be a {start,end} object, separate start/end args, or a Duration.
672 // Results are based on Moment's .as() and .diff() methods, so results can depend on internal handling
673 // of month-diffing logic (which tends to vary from version to version).
674 function computeRangeAs(unit, start, end) {
675
676 if (end != null) { // given start, end
677 return end.diff(start, unit, true);
678 }
679 else if (moment.isDuration(start)) { // given duration
680 return start.as(unit);
681 }
682 else { // given { start, end } range object
683 return start.end.diff(start.start, unit, true);
684 }
685 }
686
687
688 // Intelligently divides a range (specified by a start/end params) by a duration
689 function divideRangeByDuration(start, end, dur) {
690 var months;
691
692 if (durationHasTime(dur)) {
693 return (end - start) / dur;
694 }
695 months = dur.asMonths();
696 if (Math.abs(months) >= 1 && isInt(months)) {
697 return end.diff(start, 'months', true) / months;
698 }
699 return end.diff(start, 'days', true) / dur.asDays();
700 }
701
702
703 // Intelligently divides one duration by another
704 function divideDurationByDuration(dur1, dur2) {
705 var months1, months2;
706
707 if (durationHasTime(dur1) || durationHasTime(dur2)) {
708 return dur1 / dur2;
709 }
710 months1 = dur1.asMonths();
711 months2 = dur2.asMonths();
712 if (
713 Math.abs(months1) >= 1 && isInt(months1) &&
714 Math.abs(months2) >= 1 && isInt(months2)
715 ) {
716 return months1 / months2;
717 }
718 return dur1.asDays() / dur2.asDays();
719 }
720
721
722 // Intelligently multiplies a duration by a number
723 function multiplyDuration(dur, n) {
724 var months;
725
726 if (durationHasTime(dur)) {
727 return moment.duration(dur * n);
728 }
729 months = dur.asMonths();
730 if (Math.abs(months) >= 1 && isInt(months)) {
731 return moment.duration({ months: months * n });
732 }
733 return moment.duration({ days: dur.asDays() * n });
734 }
735
736
737 // Returns a boolean about whether the given duration has any time parts (hours/minutes/seconds/ms)
738 function durationHasTime(dur) {
739 return Boolean(dur.hours() || dur.minutes() || dur.seconds() || dur.milliseconds());
740 }
741
742
743 function isNativeDate(input) {
744 return Object.prototype.toString.call(input) === '[object Date]' || input instanceof Date;
745 }
746
747
748 // Returns a boolean about whether the given input is a time string, like "06:40:00" or "06:00"
749 function isTimeString(str) {
750 return typeof str === 'string' &&
751 /^\d+\:\d+(?:\:\d+\.?(?:\d{3})?)?$/.test(str);
752 }
753
754
755 /* Logging and Debug
756 ----------------------------------------------------------------------------------------------------------------------*/
757
758 FC.log = function() {
759 var console = window.console;
760
761 if (console && console.log) {
762 return console.log.apply(console, arguments);
763 }
764 };
765
766 FC.warn = function() {
767 var console = window.console;
768
769 if (console && console.warn) {
770 return console.warn.apply(console, arguments);
771 }
772 else {
773 return FC.log.apply(FC, arguments);
774 }
775 };
776
777
778 /* General Utilities
779 ----------------------------------------------------------------------------------------------------------------------*/
780
781 var hasOwnPropMethod = {}.hasOwnProperty;
782
783
784 // Merges an array of objects into a single object.
785 // The second argument allows for an array of property names who's object values will be merged together.
786 function mergeProps(propObjs, complexProps) {
787 var dest = {};
788 var i, name;
789 var complexObjs;
790 var j, val;
791 var props;
792
793 if (complexProps) {
794 for (i = 0; i < complexProps.length; i++) {
795 name = complexProps[i];
796 complexObjs = [];
797
798 // collect the trailing object values, stopping when a non-object is discovered
799 for (j = propObjs.length - 1; j >= 0; j--) {
800 val = propObjs[j][name];
801
802 if (typeof val === 'object') {
803 complexObjs.unshift(val);
804 }
805 else if (val !== undefined) {
806 dest[name] = val; // if there were no objects, this value will be used
807 break;
808 }
809 }
810
811 // if the trailing values were objects, use the merged value
812 if (complexObjs.length) {
813 dest[name] = mergeProps(complexObjs);
814 }
815 }
816 }
817
818 // copy values into the destination, going from last to first
819 for (i = propObjs.length - 1; i >= 0; i--) {
820 props = propObjs[i];
821
822 for (name in props) {
823 if (!(name in dest)) { // if already assigned by previous props or complex props, don't reassign
824 dest[name] = props[name];
825 }
826 }
827 }
828
829 return dest;
830 }
831
832
833 function copyOwnProps(src, dest) {
834 for (var name in src) {
835 if (hasOwnProp(src, name)) {
836 dest[name] = src[name];
837 }
838 }
839 }
840
841
842 function hasOwnProp(obj, name) {
843 return hasOwnPropMethod.call(obj, name);
844 }
845
846
847 function applyAll(functions, thisObj, args) {
848 if ($.isFunction(functions)) {
849 functions = [ functions ];
850 }
851 if (functions) {
852 var i;
853 var ret;
854 for (i=0; i<functions.length; i++) {
855 ret = functions[i].apply(thisObj, args) || ret;
856 }
857 return ret;
858 }
859 }
860
861
862 function removeMatching(array, testFunc) {
863 var removeCnt = 0;
864 var i = 0;
865
866 while (i < array.length) {
867 if (testFunc(array[i])) { // truthy value means *remove*
868 array.splice(i, 1);
869 removeCnt++;
870 }
871 else {
872 i++;
873 }
874 }
875
876 return removeCnt;
877 }
878
879
880 function removeExact(array, exactVal) {
881 var removeCnt = 0;
882 var i = 0;
883
884 while (i < array.length) {
885 if (array[i] === exactVal) {
886 array.splice(i, 1);
887 removeCnt++;
888 }
889 else {
890 i++;
891 }
892 }
893
894 return removeCnt;
895 }
896 FC.removeExact = removeExact;
897
898
899
900 function firstDefined() {
901 for (var i=0; i<arguments.length; i++) {
902 if (arguments[i] !== undefined) {
903 return arguments[i];
904 }
905 }
906 }
907
908
909 function htmlEscape(s) {
910 return (s + '').replace(/&/g, '&amp;')
911 .replace(/</g, '&lt;')
912 .replace(/>/g, '&gt;')
913 .replace(/'/g, '&#039;')
914 .replace(/"/g, '&quot;')
915 .replace(/\n/g, '<br />');
916 }
917
918
919 function stripHtmlEntities(text) {
920 return text.replace(/&.*?;/g, '');
921 }
922
923
924 // Given a hash of CSS properties, returns a string of CSS.
925 // Uses property names as-is (no camel-case conversion). Will not make statements for null/undefined values.
926 function cssToStr(cssProps) {
927 var statements = [];
928
929 $.each(cssProps, function(name, val) {
930 if (val != null) {
931 statements.push(name + ':' + val);
932 }
933 });
934
935 return statements.join(';');
936 }
937
938
939 // Given an object hash of HTML attribute names to values,
940 // generates a string that can be injected between < > in HTML
941 function attrsToStr(attrs) {
942 var parts = [];
943
944 $.each(attrs, function(name, val) {
945 if (val != null) {
946 parts.push(name + '="' + htmlEscape(val) + '"');
947 }
948 });
949
950 return parts.join(' ');
951 }
952
953
954 function capitaliseFirstLetter(str) {
955 return str.charAt(0).toUpperCase() + str.slice(1);
956 }
957
958
959 function compareNumbers(a, b) { // for .sort()
960 return a - b;
961 }
962
963
964 function isInt(n) {
965 return n % 1 === 0;
966 }
967
968
969 // Returns a method bound to the given object context.
970 // Just like one of the jQuery.proxy signatures, but without the undesired behavior of treating the same method with
971 // different contexts as identical when binding/unbinding events.
972 function proxy(obj, methodName) {
973 var method = obj[methodName];
974
975 return function() {
976 return method.apply(obj, arguments);
977 };
978 }
979
980
981 // Returns a function, that, as long as it continues to be invoked, will not
982 // be triggered. The function will be called after it stops being called for
983 // N milliseconds. If `immediate` is passed, trigger the function on the
984 // leading edge, instead of the trailing.
985 // https://github.com/jashkenas/underscore/blob/1.6.0/underscore.js#L714
986 function debounce(func, wait, immediate) {
987 var timeout, args, context, timestamp, result;
988
989 var later = function() {
990 var last = +new Date() - timestamp;
991 if (last < wait) {
992 timeout = setTimeout(later, wait - last);
993 }
994 else {
995 timeout = null;
996 if (!immediate) {
997 result = func.apply(context, args);
998 context = args = null;
999 }
1000 }
1001 };
1002
1003 return function() {
1004 context = this;
1005 args = arguments;
1006 timestamp = +new Date();
1007 var callNow = immediate && !timeout;
1008 if (!timeout) {
1009 timeout = setTimeout(later, wait);
1010 }
1011 if (callNow) {
1012 result = func.apply(context, args);
1013 context = args = null;
1014 }
1015 return result;
1016 };
1017 }
1018
1019 ;;
1020
1021 /*
1022 GENERAL NOTE on moments throughout the *entire rest* of the codebase:
1023 All moments are assumed to be ambiguously-zoned unless otherwise noted,
1024 with the NOTABLE EXCEOPTION of start/end dates that live on *Event Objects*.
1025 Ambiguously-TIMED moments are assumed to be ambiguously-zoned by nature.
1026 */
1027
1028 var ambigDateOfMonthRegex = /^\s*\d{4}-\d\d$/;
1029 var ambigTimeOrZoneRegex =
1030 /^\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+)?)?)?)?)?$/;
1031 var newMomentProto = moment.fn; // where we will attach our new methods
1032 var oldMomentProto = $.extend({}, newMomentProto); // copy of original moment methods
1033
1034 // tell momentjs to transfer these properties upon clone
1035 var momentProperties = moment.momentProperties;
1036 momentProperties.push('_fullCalendar');
1037 momentProperties.push('_ambigTime');
1038 momentProperties.push('_ambigZone');
1039
1040
1041 // Creating
1042 // -------------------------------------------------------------------------------------------------
1043
1044 // Creates a new moment, similar to the vanilla moment(...) constructor, but with
1045 // extra features (ambiguous time, enhanced formatting). When given an existing moment,
1046 // it will function as a clone (and retain the zone of the moment). Anything else will
1047 // result in a moment in the local zone.
1048 FC.moment = function() {
1049 return makeMoment(arguments);
1050 };
1051
1052 // Sames as FC.moment, but forces the resulting moment to be in the UTC timezone.
1053 FC.moment.utc = function() {
1054 var mom = makeMoment(arguments, true);
1055
1056 // Force it into UTC because makeMoment doesn't guarantee it
1057 // (if given a pre-existing moment for example)
1058 if (mom.hasTime()) { // don't give ambiguously-timed moments a UTC zone
1059 mom.utc();
1060 }
1061
1062 return mom;
1063 };
1064
1065 // Same as FC.moment, but when given an ISO8601 string, the timezone offset is preserved.
1066 // ISO8601 strings with no timezone offset will become ambiguously zoned.
1067 FC.moment.parseZone = function() {
1068 return makeMoment(arguments, true, true);
1069 };
1070
1071 // Builds an enhanced moment from args. When given an existing moment, it clones. When given a
1072 // native Date, or called with no arguments (the current time), the resulting moment will be local.
1073 // Anything else needs to be "parsed" (a string or an array), and will be affected by:
1074 // parseAsUTC - if there is no zone information, should we parse the input in UTC?
1075 // parseZone - if there is zone information, should we force the zone of the moment?
1076 function makeMoment(args, parseAsUTC, parseZone) {
1077 var input = args[0];
1078 var isSingleString = args.length == 1 && typeof input === 'string';
1079 var isAmbigTime;
1080 var isAmbigZone;
1081 var ambigMatch;
1082 var mom;
1083
1084 if (moment.isMoment(input) || isNativeDate(input) || input === undefined) {
1085 mom = moment.apply(null, args);
1086 }
1087 else { // "parsing" is required
1088 isAmbigTime = false;
1089 isAmbigZone = false;
1090
1091 if (isSingleString) {
1092 if (ambigDateOfMonthRegex.test(input)) {
1093 // accept strings like '2014-05', but convert to the first of the month
1094 input += '-01';
1095 args = [ input ]; // for when we pass it on to moment's constructor
1096 isAmbigTime = true;
1097 isAmbigZone = true;
1098 }
1099 else if ((ambigMatch = ambigTimeOrZoneRegex.exec(input))) {
1100 isAmbigTime = !ambigMatch[5]; // no time part?
1101 isAmbigZone = true;
1102 }
1103 }
1104 else if ($.isArray(input)) {
1105 // arrays have no timezone information, so assume ambiguous zone
1106 isAmbigZone = true;
1107 }
1108 // otherwise, probably a string with a format
1109
1110 if (parseAsUTC || isAmbigTime) {
1111 mom = moment.utc.apply(moment, args);
1112 }
1113 else {
1114 mom = moment.apply(null, args);
1115 }
1116
1117 if (isAmbigTime) {
1118 mom._ambigTime = true;
1119 mom._ambigZone = true; // ambiguous time always means ambiguous zone
1120 }
1121 else if (parseZone) { // let's record the inputted zone somehow
1122 if (isAmbigZone) {
1123 mom._ambigZone = true;
1124 }
1125 else if (isSingleString) {
1126 mom.utcOffset(input); // if not a valid zone, will assign UTC
1127 }
1128 }
1129 }
1130
1131 mom._fullCalendar = true; // flag for extended functionality
1132
1133 return mom;
1134 }
1135
1136
1137 // Week Number
1138 // -------------------------------------------------------------------------------------------------
1139
1140
1141 // Returns the week number, considering the locale's custom week number calcuation
1142 // `weeks` is an alias for `week`
1143 newMomentProto.week = newMomentProto.weeks = function(input) {
1144 var weekCalc = this._locale._fullCalendar_weekCalc;
1145
1146 if (input == null && typeof weekCalc === 'function') { // custom function only works for getter
1147 return weekCalc(this);
1148 }
1149 else if (weekCalc === 'ISO') {
1150 return oldMomentProto.isoWeek.apply(this, arguments); // ISO getter/setter
1151 }
1152
1153 return oldMomentProto.week.apply(this, arguments); // local getter/setter
1154 };
1155
1156
1157 // Time-of-day
1158 // -------------------------------------------------------------------------------------------------
1159
1160 // GETTER
1161 // Returns a Duration with the hours/minutes/seconds/ms values of the moment.
1162 // If the moment has an ambiguous time, a duration of 00:00 will be returned.
1163 //
1164 // SETTER
1165 // You can supply a Duration, a Moment, or a Duration-like argument.
1166 // When setting the time, and the moment has an ambiguous time, it then becomes unambiguous.
1167 newMomentProto.time = function(time) {
1168
1169 // Fallback to the original method (if there is one) if this moment wasn't created via FullCalendar.
1170 // `time` is a generic enough method name where this precaution is necessary to avoid collisions w/ other plugins.
1171 if (!this._fullCalendar) {
1172 return oldMomentProto.time.apply(this, arguments);
1173 }
1174
1175 if (time == null) { // getter
1176 return moment.duration({
1177 hours: this.hours(),
1178 minutes: this.minutes(),
1179 seconds: this.seconds(),
1180 milliseconds: this.milliseconds()
1181 });
1182 }
1183 else { // setter
1184
1185 this._ambigTime = false; // mark that the moment now has a time
1186
1187 if (!moment.isDuration(time) && !moment.isMoment(time)) {
1188 time = moment.duration(time);
1189 }
1190
1191 // The day value should cause overflow (so 24 hours becomes 00:00:00 of next day).
1192 // Only for Duration times, not Moment times.
1193 var dayHours = 0;
1194 if (moment.isDuration(time)) {
1195 dayHours = Math.floor(time.asDays()) * 24;
1196 }
1197
1198 // We need to set the individual fields.
1199 // Can't use startOf('day') then add duration. In case of DST at start of day.
1200 return this.hours(dayHours + time.hours())
1201 .minutes(time.minutes())
1202 .seconds(time.seconds())
1203 .milliseconds(time.milliseconds());
1204 }
1205 };
1206
1207 // Converts the moment to UTC, stripping out its time-of-day and timezone offset,
1208 // but preserving its YMD. A moment with a stripped time will display no time
1209 // nor timezone offset when .format() is called.
1210 newMomentProto.stripTime = function() {
1211
1212 if (!this._ambigTime) {
1213
1214 this.utc(true); // keepLocalTime=true (for keeping *date* value)
1215
1216 // set time to zero
1217 this.set({
1218 hours: 0,
1219 minutes: 0,
1220 seconds: 0,
1221 ms: 0
1222 });
1223
1224 // Mark the time as ambiguous. This needs to happen after the .utc() call, which might call .utcOffset(),
1225 // which clears all ambig flags.
1226 this._ambigTime = true;
1227 this._ambigZone = true; // if ambiguous time, also ambiguous timezone offset
1228 }
1229
1230 return this; // for chaining
1231 };
1232
1233 // Returns if the moment has a non-ambiguous time (boolean)
1234 newMomentProto.hasTime = function() {
1235 return !this._ambigTime;
1236 };
1237
1238
1239 // Timezone
1240 // -------------------------------------------------------------------------------------------------
1241
1242 // Converts the moment to UTC, stripping out its timezone offset, but preserving its
1243 // YMD and time-of-day. A moment with a stripped timezone offset will display no
1244 // timezone offset when .format() is called.
1245 newMomentProto.stripZone = function() {
1246 var wasAmbigTime;
1247
1248 if (!this._ambigZone) {
1249
1250 wasAmbigTime = this._ambigTime;
1251
1252 this.utc(true); // keepLocalTime=true (for keeping date and time values)
1253
1254 // the above call to .utc()/.utcOffset() unfortunately might clear the ambig flags, so restore
1255 this._ambigTime = wasAmbigTime || false;
1256
1257 // Mark the zone as ambiguous. This needs to happen after the .utc() call, which might call .utcOffset(),
1258 // which clears the ambig flags.
1259 this._ambigZone = true;
1260 }
1261
1262 return this; // for chaining
1263 };
1264
1265 // Returns of the moment has a non-ambiguous timezone offset (boolean)
1266 newMomentProto.hasZone = function() {
1267 return !this._ambigZone;
1268 };
1269
1270
1271 // implicitly marks a zone
1272 newMomentProto.local = function(keepLocalTime) {
1273
1274 // for when converting from ambiguously-zoned to local,
1275 // keep the time values when converting from UTC -> local
1276 oldMomentProto.local.call(this, this._ambigZone || keepLocalTime);
1277
1278 // ensure non-ambiguous
1279 // this probably already happened via local() -> utcOffset(), but don't rely on Moment's internals
1280 this._ambigTime = false;
1281 this._ambigZone = false;
1282
1283 return this; // for chaining
1284 };
1285
1286
1287 // implicitly marks a zone
1288 newMomentProto.utc = function(keepLocalTime) {
1289
1290 oldMomentProto.utc.call(this, keepLocalTime);
1291
1292 // ensure non-ambiguous
1293 // this probably already happened via utc() -> utcOffset(), but don't rely on Moment's internals
1294 this._ambigTime = false;
1295 this._ambigZone = false;
1296
1297 return this;
1298 };
1299
1300
1301 // implicitly marks a zone (will probably get called upon .utc() and .local())
1302 newMomentProto.utcOffset = function(tzo) {
1303
1304 if (tzo != null) { // setter
1305 // these assignments needs to happen before the original zone method is called.
1306 // I forget why, something to do with a browser crash.
1307 this._ambigTime = false;
1308 this._ambigZone = false;
1309 }
1310
1311 return oldMomentProto.utcOffset.apply(this, arguments);
1312 };
1313
1314
1315 // Formatting
1316 // -------------------------------------------------------------------------------------------------
1317
1318 newMomentProto.format = function() {
1319
1320 if (this._fullCalendar && arguments[0]) { // an enhanced moment? and a format string provided?
1321 return formatDate(this, arguments[0]); // our extended formatting
1322 }
1323 if (this._ambigTime) {
1324 return oldMomentFormat(englishMoment(this), 'YYYY-MM-DD');
1325 }
1326 if (this._ambigZone) {
1327 return oldMomentFormat(englishMoment(this), 'YYYY-MM-DD[T]HH:mm:ss');
1328 }
1329 if (this._fullCalendar) { // enhanced non-ambig moment?
1330 // moment.format() doesn't ensure english, but we want to.
1331 return oldMomentFormat(englishMoment(this));
1332 }
1333
1334 return oldMomentProto.format.apply(this, arguments);
1335 };
1336
1337 newMomentProto.toISOString = function() {
1338
1339 if (this._ambigTime) {
1340 return oldMomentFormat(englishMoment(this), 'YYYY-MM-DD');
1341 }
1342 if (this._ambigZone) {
1343 return oldMomentFormat(englishMoment(this), 'YYYY-MM-DD[T]HH:mm:ss');
1344 }
1345 if (this._fullCalendar) { // enhanced non-ambig moment?
1346 // depending on browser, moment might not output english. ensure english.
1347 // https://github.com/moment/moment/blob/2.18.1/src/lib/moment/format.js#L22
1348 return oldMomentProto.toISOString.apply(englishMoment(this), arguments);
1349 }
1350
1351 return oldMomentProto.toISOString.apply(this, arguments);
1352 };
1353
1354 function englishMoment(mom) {
1355 if (mom.locale() !== 'en') {
1356 return mom.clone().locale('en');
1357 }
1358 return mom;
1359 }
1360
1361 ;;
1362 (function() {
1363
1364 // exports
1365 FC.formatDate = formatDate;
1366 FC.formatRange = formatRange;
1367 FC.oldMomentFormat = oldMomentFormat;
1368 FC.queryMostGranularFormatUnit = queryMostGranularFormatUnit;
1369
1370
1371 // Config
1372 // ---------------------------------------------------------------------------------------------------------------------
1373
1374 /*
1375 Inserted between chunks in the fake ("intermediate") formatting string.
1376 Important that it passes as whitespace (\s) because moment often identifies non-standalone months
1377 via a regexp with an \s.
1378 */
1379 var PART_SEPARATOR = '\u000b'; // vertical tab
1380
1381 /*
1382 Inserted as the first character of a literal-text chunk to indicate that the literal text is not actually literal text,
1383 but rather, a "special" token that has custom rendering (see specialTokens map).
1384 */
1385 var SPECIAL_TOKEN_MARKER = '\u001f'; // information separator 1
1386
1387 /*
1388 Inserted at the beginning and end of a span of text that must have non-zero numeric characters.
1389 Handling of these markers is done in a post-processing step at the very end of text rendering.
1390 */
1391 var MAYBE_MARKER = '\u001e'; // information separator 2
1392 var MAYBE_REGEXP = new RegExp(MAYBE_MARKER + '([^' + MAYBE_MARKER + ']*)' + MAYBE_MARKER, 'g'); // must be global
1393
1394 /*
1395 Addition formatting tokens we want recognized
1396 */
1397 var specialTokens = {
1398 t: function(date) { // "a" or "p"
1399 return oldMomentFormat(date, 'a').charAt(0);
1400 },
1401 T: function(date) { // "A" or "P"
1402 return oldMomentFormat(date, 'A').charAt(0);
1403 }
1404 };
1405
1406 /*
1407 The first characters of formatting tokens for units that are 1 day or larger.
1408 `value` is for ranking relative size (lower means bigger).
1409 `unit` is a normalized unit, used for comparing moments.
1410 */
1411 var largeTokenMap = {
1412 Y: { value: 1, unit: 'year' },
1413 M: { value: 2, unit: 'month' },
1414 W: { value: 3, unit: 'week' }, // ISO week
1415 w: { value: 3, unit: 'week' }, // local week
1416 D: { value: 4, unit: 'day' }, // day of month
1417 d: { value: 4, unit: 'day' } // day of week
1418 };
1419
1420
1421 // Single Date Formatting
1422 // ---------------------------------------------------------------------------------------------------------------------
1423
1424 /*
1425 Formats `date` with a Moment formatting string, but allow our non-zero areas and special token
1426 */
1427 function formatDate(date, formatStr) {
1428 return renderFakeFormatString(
1429 getParsedFormatString(formatStr).fakeFormatString,
1430 date
1431 );
1432 }
1433
1434 /*
1435 Call this if you want Moment's original format method to be used
1436 */
1437 function oldMomentFormat(mom, formatStr) {
1438 return oldMomentProto.format.call(mom, formatStr); // oldMomentProto defined in moment-ext.js
1439 }
1440
1441
1442 // Date Range Formatting
1443 // -------------------------------------------------------------------------------------------------
1444 // TODO: make it work with timezone offset
1445
1446 /*
1447 Using a formatting string meant for a single date, generate a range string, like
1448 "Sep 2 - 9 2013", that intelligently inserts a separator where the dates differ.
1449 If the dates are the same as far as the format string is concerned, just return a single
1450 rendering of one date, without any separator.
1451 */
1452 function formatRange(date1, date2, formatStr, separator, isRTL) {
1453 var localeData;
1454
1455 date1 = FC.moment.parseZone(date1);
1456 date2 = FC.moment.parseZone(date2);
1457
1458 localeData = date1.localeData();
1459
1460 // Expand localized format strings, like "LL" -> "MMMM D YYYY".
1461 // BTW, this is not important for `formatDate` because it is impossible to put custom tokens
1462 // or non-zero areas in Moment's localized format strings.
1463 formatStr = localeData.longDateFormat(formatStr) || formatStr;
1464
1465 return renderParsedFormat(
1466 getParsedFormatString(formatStr),
1467 date1,
1468 date2,
1469 separator || ' - ',
1470 isRTL
1471 );
1472 }
1473
1474 /*
1475 Renders a range with an already-parsed format string.
1476 */
1477 function renderParsedFormat(parsedFormat, date1, date2, separator, isRTL) {
1478 var sameUnits = parsedFormat.sameUnits;
1479 var unzonedDate1 = date1.clone().stripZone(); // for same-unit comparisons
1480 var unzonedDate2 = date2.clone().stripZone(); // "
1481
1482 var renderedParts1 = renderFakeFormatStringParts(parsedFormat.fakeFormatString, date1);
1483 var renderedParts2 = renderFakeFormatStringParts(parsedFormat.fakeFormatString, date2);
1484
1485 var leftI;
1486 var leftStr = '';
1487 var rightI;
1488 var rightStr = '';
1489 var middleI;
1490 var middleStr1 = '';
1491 var middleStr2 = '';
1492 var middleStr = '';
1493
1494 // Start at the leftmost side of the formatting string and continue until you hit a token
1495 // that is not the same between dates.
1496 for (
1497 leftI = 0;
1498 leftI < sameUnits.length && (!sameUnits[leftI] || unzonedDate1.isSame(unzonedDate2, sameUnits[leftI]));
1499 leftI++
1500 ) {
1501 leftStr += renderedParts1[leftI];
1502 }
1503
1504 // Similarly, start at the rightmost side of the formatting string and move left
1505 for (
1506 rightI = sameUnits.length - 1;
1507 rightI > leftI && (!sameUnits[rightI] || unzonedDate1.isSame(unzonedDate2, sameUnits[rightI]));
1508 rightI--
1509 ) {
1510 // If current chunk is on the boundary of unique date-content, and is a special-case
1511 // date-formatting postfix character, then don't consume it. Consider it unique date-content.
1512 // TODO: make configurable
1513 if (rightI - 1 === leftI && renderedParts1[rightI] === '.') {
1514 break;
1515 }
1516
1517 rightStr = renderedParts1[rightI] + rightStr;
1518 }
1519
1520 // The area in the middle is different for both of the dates.
1521 // Collect them distinctly so we can jam them together later.
1522 for (middleI = leftI; middleI <= rightI; middleI++) {
1523 middleStr1 += renderedParts1[middleI];
1524 middleStr2 += renderedParts2[middleI];
1525 }
1526
1527 if (middleStr1 || middleStr2) {
1528 if (isRTL) {
1529 middleStr = middleStr2 + separator + middleStr1;
1530 }
1531 else {
1532 middleStr = middleStr1 + separator + middleStr2;
1533 }
1534 }
1535
1536 return processMaybeMarkers(
1537 leftStr + middleStr + rightStr
1538 );
1539 }
1540
1541
1542 // Format String Parsing
1543 // ---------------------------------------------------------------------------------------------------------------------
1544
1545 var parsedFormatStrCache = {};
1546
1547 /*
1548 Returns a parsed format string, leveraging a cache.
1549 */
1550 function getParsedFormatString(formatStr) {
1551 return parsedFormatStrCache[formatStr] ||
1552 (parsedFormatStrCache[formatStr] = parseFormatString(formatStr));
1553 }
1554
1555 /*
1556 Parses a format string into the following:
1557 - fakeFormatString: a momentJS formatting string, littered with special control characters that get post-processed.
1558 - sameUnits: for every part in fakeFormatString, if the part is a token, the value will be a unit string (like "day"),
1559 that indicates how similar a range's start & end must be in order to share the same formatted text.
1560 If not a token, then the value is null.
1561 Always a flat array (not nested liked "chunks").
1562 */
1563 function parseFormatString(formatStr) {
1564 var chunks = chunkFormatString(formatStr);
1565
1566 return {
1567 fakeFormatString: buildFakeFormatString(chunks),
1568 sameUnits: buildSameUnits(chunks)
1569 };
1570 }
1571
1572 /*
1573 Break the formatting string into an array of chunks.
1574 A 'maybe' chunk will have nested chunks.
1575 */
1576 function chunkFormatString(formatStr) {
1577 var chunks = [];
1578 var match;
1579
1580 // TODO: more descrimination
1581 // \4 is a backreference to the first character of a multi-character set.
1582 var chunker = /\[([^\]]*)\]|\(([^\)]*)\)|(LTS|LT|(\w)\4*o?)|([^\w\[\(]+)/g;
1583
1584 while ((match = chunker.exec(formatStr))) {
1585 if (match[1]) { // a literal string inside [ ... ]
1586 chunks.push.apply(chunks, // append
1587 splitStringLiteral(match[1])
1588 );
1589 }
1590 else if (match[2]) { // non-zero formatting inside ( ... )
1591 chunks.push({ maybe: chunkFormatString(match[2]) });
1592 }
1593 else if (match[3]) { // a formatting token
1594 chunks.push({ token: match[3] });
1595 }
1596 else if (match[5]) { // an unenclosed literal string
1597 chunks.push.apply(chunks, // append
1598 splitStringLiteral(match[5])
1599 );
1600 }
1601 }
1602
1603 return chunks;
1604 }
1605
1606 /*
1607 Potentially splits a literal-text string into multiple parts. For special cases.
1608 */
1609 function splitStringLiteral(s) {
1610 if (s === '. ') {
1611 return [ '.', ' ' ]; // for locales with periods bound to the end of each year/month/date
1612 }
1613 else {
1614 return [ s ];
1615 }
1616 }
1617
1618 /*
1619 Given chunks parsed from a real format string, generate a fake (aka "intermediate") format string with special control
1620 characters that will eventually be given to moment for formatting, and then post-processed.
1621 */
1622 function buildFakeFormatString(chunks) {
1623 var parts = [];
1624 var i, chunk;
1625
1626 for (i = 0; i < chunks.length; i++) {
1627 chunk = chunks[i];
1628
1629 if (typeof chunk === 'string') {
1630 parts.push('[' + chunk + ']');
1631 }
1632 else if (chunk.token) {
1633 if (chunk.token in specialTokens) {
1634 parts.push(
1635 SPECIAL_TOKEN_MARKER + // useful during post-processing
1636 '[' + chunk.token + ']' // preserve as literal text
1637 );
1638 }
1639 else {
1640 parts.push(chunk.token); // unprotected text implies a format string
1641 }
1642 }
1643 else if (chunk.maybe) {
1644 parts.push(
1645 MAYBE_MARKER + // useful during post-processing
1646 buildFakeFormatString(chunk.maybe) +
1647 MAYBE_MARKER
1648 );
1649 }
1650 }
1651
1652 return parts.join(PART_SEPARATOR);
1653 }
1654
1655 /*
1656 Given parsed chunks from a real formatting string, generates an array of unit strings (like "day") that indicate
1657 in which regard two dates must be similar in order to share range formatting text.
1658 The `chunks` can be nested (because of "maybe" chunks), however, the returned array will be flat.
1659 */
1660 function buildSameUnits(chunks) {
1661 var units = [];
1662 var i, chunk;
1663 var tokenInfo;
1664
1665 for (i = 0; i < chunks.length; i++) {
1666 chunk = chunks[i];
1667
1668 if (chunk.token) {
1669 tokenInfo = largeTokenMap[chunk.token.charAt(0)];
1670 units.push(tokenInfo ? tokenInfo.unit : 'second'); // default to a very strict same-second
1671 }
1672 else if (chunk.maybe) {
1673 units.push.apply(units, // append
1674 buildSameUnits(chunk.maybe)
1675 );
1676 }
1677 else {
1678 units.push(null);
1679 }
1680 }
1681
1682 return units;
1683 }
1684
1685
1686 // Rendering to text
1687 // ---------------------------------------------------------------------------------------------------------------------
1688
1689 /*
1690 Formats a date with a fake format string, post-processes the control characters, then returns.
1691 */
1692 function renderFakeFormatString(fakeFormatString, date) {
1693 return processMaybeMarkers(
1694 renderFakeFormatStringParts(fakeFormatString, date).join('')
1695 );
1696 }
1697
1698 /*
1699 Formats a date into parts that will have been post-processed, EXCEPT for the "maybe" markers.
1700 */
1701 function renderFakeFormatStringParts(fakeFormatString, date) {
1702 var parts = [];
1703 var fakeRender = oldMomentFormat(date, fakeFormatString);
1704 var fakeParts = fakeRender.split(PART_SEPARATOR);
1705 var i, fakePart;
1706
1707 for (i = 0; i < fakeParts.length; i++) {
1708 fakePart = fakeParts[i];
1709
1710 if (fakePart.charAt(0) === SPECIAL_TOKEN_MARKER) {
1711 parts.push(
1712 // the literal string IS the token's name.
1713 // call special token's registered function.
1714 specialTokens[fakePart.substring(1)](date)
1715 );
1716 }
1717 else {
1718 parts.push(fakePart);
1719 }
1720 }
1721
1722 return parts;
1723 }
1724
1725 /*
1726 Accepts an almost-finally-formatted string and processes the "maybe" control characters, returning a new string.
1727 */
1728 function processMaybeMarkers(s) {
1729 return s.replace(MAYBE_REGEXP, function(m0, m1) { // regex assumed to have 'g' flag
1730 if (m1.match(/[1-9]/)) { // any non-zero numeric characters?
1731 return m1;
1732 }
1733 else {
1734 return '';
1735 }
1736 });
1737 }
1738
1739
1740 // Misc Utils
1741 // -------------------------------------------------------------------------------------------------
1742
1743 /*
1744 Returns a unit string, either 'year', 'month', 'day', or null for the most granular formatting token in the string.
1745 */
1746 function queryMostGranularFormatUnit(formatStr) {
1747 var chunks = chunkFormatString(formatStr);
1748 var i, chunk;
1749 var candidate;
1750 var best;
1751
1752 for (i = 0; i < chunks.length; i++) {
1753 chunk = chunks[i];
1754
1755 if (chunk.token) {
1756 candidate = largeTokenMap[chunk.token.charAt(0)];
1757 if (candidate) {
1758 if (!best || candidate.value > best.value) {
1759 best = candidate;
1760 }
1761 }
1762 }
1763 }
1764
1765 if (best) {
1766 return best.unit;
1767 }
1768
1769 return null;
1770 };
1771
1772 })();
1773
1774 // quick local references
1775 var formatDate = FC.formatDate;
1776 var formatRange = FC.formatRange;
1777 var oldMomentFormat = FC.oldMomentFormat;
1778
1779 ;;
1780
1781 FC.Class = Class; // export
1782
1783 // Class that all other classes will inherit from
1784 function Class() { }
1785
1786
1787 // Called on a class to create a subclass.
1788 // Last argument contains instance methods. Any argument before the last are considered mixins.
1789 Class.extend = function() {
1790 var members = {};
1791 var i;
1792
1793 for (i = 0; i < arguments.length; i++) {
1794 copyOwnProps(arguments[i], members);
1795 }
1796
1797 return extendClass(this, members);
1798 };
1799
1800
1801 // Adds new member variables/methods to the class's prototype.
1802 // Can be called with another class, or a plain object hash containing new members.
1803 Class.mixin = function(members) {
1804 copyOwnProps(members, this.prototype);
1805 };
1806
1807
1808 function extendClass(superClass, members) {
1809 var subClass;
1810
1811 // ensure a constructor for the subclass, forwarding all arguments to the super-constructor if it doesn't exist
1812 if (hasOwnProp(members, 'constructor')) {
1813 subClass = members.constructor;
1814 }
1815 if (typeof subClass !== 'function') {
1816 subClass = members.constructor = function() {
1817 superClass.apply(this, arguments);
1818 };
1819 }
1820
1821 // build the base prototype for the subclass, which is an new object chained to the superclass's prototype
1822 subClass.prototype = Object.create(superClass.prototype);
1823
1824 // copy each member variable/method onto the the subclass's prototype
1825 copyOwnProps(members, subClass.prototype);
1826
1827 // copy over all class variables/methods to the subclass, such as `extend` and `mixin`
1828 copyOwnProps(superClass, subClass);
1829
1830 return subClass;
1831 }
1832
1833 ;;
1834
1835 var EmitterMixin = FC.EmitterMixin = {
1836
1837 // jQuery-ification via $(this) allows a non-DOM object to have
1838 // the same event handling capabilities (including namespaces).
1839
1840
1841 on: function(types, handler) {
1842 $(this).on(types, this._prepareIntercept(handler));
1843 return this; // for chaining
1844 },
1845
1846
1847 one: function(types, handler) {
1848 $(this).one(types, this._prepareIntercept(handler));
1849 return this; // for chaining
1850 },
1851
1852
1853 _prepareIntercept: function(handler) {
1854 // handlers are always called with an "event" object as their first param.
1855 // sneak the `this` context and arguments into the extra parameter object
1856 // and forward them on to the original handler.
1857 var intercept = function(ev, extra) {
1858 return handler.apply(
1859 extra.context || this,
1860 extra.args || []
1861 );
1862 };
1863
1864 // mimick jQuery's internal "proxy" system (risky, I know)
1865 // causing all functions with the same .guid to appear to be the same.
1866 // https://github.com/jquery/jquery/blob/2.2.4/src/core.js#L448
1867 // this is needed for calling .off with the original non-intercept handler.
1868 if (!handler.guid) {
1869 handler.guid = $.guid++;
1870 }
1871 intercept.guid = handler.guid;
1872
1873 return intercept;
1874 },
1875
1876
1877 off: function(types, handler) {
1878 $(this).off(types, handler);
1879
1880 return this; // for chaining
1881 },
1882
1883
1884 trigger: function(types) {
1885 var args = Array.prototype.slice.call(arguments, 1); // arguments after the first
1886
1887 // pass in "extra" info to the intercept
1888 $(this).triggerHandler(types, { args: args });
1889
1890 return this; // for chaining
1891 },
1892
1893
1894 triggerWith: function(types, context, args) {
1895
1896 // `triggerHandler` is less reliant on the DOM compared to `trigger`.
1897 // pass in "extra" info to the intercept.
1898 $(this).triggerHandler(types, { context: context, args: args });
1899
1900 return this; // for chaining
1901 },
1902
1903
1904 hasHandlers: function(type) {
1905 var hash = $._data(this, 'events'); // http://blog.jquery.com/2012/08/09/jquery-1-8-released/
1906
1907 return hash && hash[type] && hash[type].length > 0;
1908 }
1909
1910 };
1911
1912 ;;
1913
1914 /*
1915 Utility methods for easily listening to events on another object,
1916 and more importantly, easily unlistening from them.
1917 */
1918 var ListenerMixin = FC.ListenerMixin = (function() {
1919 var guid = 0;
1920 var ListenerMixin = {
1921
1922 listenerId: null,
1923
1924 /*
1925 Given an `other` object that has on/off methods, bind the given `callback` to an event by the given name.
1926 The `callback` will be called with the `this` context of the object that .listenTo is being called on.
1927 Can be called:
1928 .listenTo(other, eventName, callback)
1929 OR
1930 .listenTo(other, {
1931 eventName1: callback1,
1932 eventName2: callback2
1933 })
1934 */
1935 listenTo: function(other, arg, callback) {
1936 if (typeof arg === 'object') { // given dictionary of callbacks
1937 for (var eventName in arg) {
1938 if (arg.hasOwnProperty(eventName)) {
1939 this.listenTo(other, eventName, arg[eventName]);
1940 }
1941 }
1942 }
1943 else if (typeof arg === 'string') {
1944 other.on(
1945 arg + '.' + this.getListenerNamespace(), // use event namespacing to identify this object
1946 $.proxy(callback, this) // always use `this` context
1947 // the usually-undesired jQuery guid behavior doesn't matter,
1948 // because we always unbind via namespace
1949 );
1950 }
1951 },
1952
1953 /*
1954 Causes the current object to stop listening to events on the `other` object.
1955 `eventName` is optional. If omitted, will stop listening to ALL events on `other`.
1956 */
1957 stopListeningTo: function(other, eventName) {
1958 other.off((eventName || '') + '.' + this.getListenerNamespace());
1959 },
1960
1961 /*
1962 Returns a string, unique to this object, to be used for event namespacing
1963 */
1964 getListenerNamespace: function() {
1965 if (this.listenerId == null) {
1966 this.listenerId = guid++;
1967 }
1968 return '_listener' + this.listenerId;
1969 }
1970
1971 };
1972 return ListenerMixin;
1973 })();
1974 ;;
1975
1976 var ParsableModelMixin = {
1977
1978 standardPropMap: {}, // will be cloned by allowRawProps
1979
1980
1981 /*
1982 Returns true/false for success
1983 */
1984 applyRawProps: function(rawProps) {
1985 var standardPropMap = this.standardPropMap;
1986 var manualProps = {};
1987 var otherProps = {};
1988 var propName;
1989
1990 for (propName in rawProps) {
1991 if (standardPropMap[propName] === true) { // copy automatically
1992 this[propName] = rawProps[propName];
1993 }
1994 else if (standardPropMap[propName] === false) {
1995 manualProps[propName] = rawProps[propName];
1996 }
1997 else {
1998 otherProps[propName] = rawProps[propName];
1999 }
2000 }
2001
2002 this.applyOtherRawProps(otherProps);
2003
2004 return this.applyManualRawProps(manualProps);
2005 },
2006
2007
2008 /*
2009 If subclasses override, they must call this supermethod and return the boolean response.
2010 */
2011 applyManualRawProps: function(rawProps) {
2012 return true;
2013 },
2014
2015
2016 applyOtherRawProps: function(rawProps) {
2017 // subclasses can implement
2018 }
2019
2020 };
2021
2022
2023 /*
2024 TODO: devise a better system
2025 */
2026 var ParsableModelMixin_allowRawProps = function(propDefs) {
2027 var proto = this.prototype;
2028
2029 proto.standardPropMap = Object.create(proto.standardPropMap);
2030
2031 copyOwnProps(propDefs, proto.standardPropMap);
2032 };
2033
2034
2035 /*
2036 TODO: devise a better system
2037 */
2038 var ParsableModelMixin_copyVerbatimStandardProps = function(src, dest) {
2039 var map = this.prototype.standardPropMap;
2040 var propName;
2041
2042 for (propName in map) {
2043 if (
2044 src[propName] != null && // in the src object?
2045 map[propName] === true // false means "copy verbatim"
2046 ) {
2047 dest[propName] = src[propName];
2048 }
2049 }
2050 };
2051
2052 ;;
2053
2054 var Model = Class.extend(EmitterMixin, ListenerMixin, {
2055
2056 _props: null,
2057 _watchers: null,
2058 _globalWatchArgs: null,
2059
2060 constructor: function() {
2061 this._watchers = {};
2062 this._props = {};
2063 this.applyGlobalWatchers();
2064 },
2065
2066 applyGlobalWatchers: function() {
2067 var argSets = this._globalWatchArgs || [];
2068 var i;
2069
2070 for (i = 0; i < argSets.length; i++) {
2071 this.watch.apply(this, argSets[i]);
2072 }
2073 },
2074
2075 has: function(name) {
2076 return name in this._props;
2077 },
2078
2079 get: function(name) {
2080 if (name === undefined) {
2081 return this._props;
2082 }
2083
2084 return this._props[name];
2085 },
2086
2087 set: function(name, val) {
2088 var newProps;
2089
2090 if (typeof name === 'string') {
2091 newProps = {};
2092 newProps[name] = val === undefined ? null : val;
2093 }
2094 else {
2095 newProps = name;
2096 }
2097
2098 this.setProps(newProps);
2099 },
2100
2101 reset: function(newProps) {
2102 var oldProps = this._props;
2103 var changeset = {}; // will have undefined's to signal unsets
2104 var name;
2105
2106 for (name in oldProps) {
2107 changeset[name] = undefined;
2108 }
2109
2110 for (name in newProps) {
2111 changeset[name] = newProps[name];
2112 }
2113
2114 this.setProps(changeset);
2115 },
2116
2117 unset: function(name) { // accepts a string or array of strings
2118 var newProps = {};
2119 var names;
2120 var i;
2121
2122 if (typeof name === 'string') {
2123 names = [ name ];
2124 }
2125 else {
2126 names = name;
2127 }
2128
2129 for (i = 0; i < names.length; i++) {
2130 newProps[names[i]] = undefined;
2131 }
2132
2133 this.setProps(newProps);
2134 },
2135
2136 setProps: function(newProps) {
2137 var changedProps = {};
2138 var changedCnt = 0;
2139 var name, val;
2140
2141 for (name in newProps) {
2142 val = newProps[name];
2143
2144 // a change in value?
2145 // if an object, don't check equality, because might have been mutated internally.
2146 // TODO: eventually enforce immutability.
2147 if (
2148 typeof val === 'object' ||
2149 val !== this._props[name]
2150 ) {
2151 changedProps[name] = val;
2152 changedCnt++;
2153 }
2154 }
2155
2156 if (changedCnt) {
2157
2158 this.trigger('before:batchChange', changedProps);
2159
2160 for (name in changedProps) {
2161 val = changedProps[name];
2162
2163 this.trigger('before:change', name, val);
2164 this.trigger('before:change:' + name, val);
2165 }
2166
2167 for (name in changedProps) {
2168 val = changedProps[name];
2169
2170 if (val === undefined) {
2171 delete this._props[name];
2172 }
2173 else {
2174 this._props[name] = val;
2175 }
2176
2177 this.trigger('change:' + name, val);
2178 this.trigger('change', name, val);
2179 }
2180
2181 this.trigger('batchChange', changedProps);
2182 }
2183 },
2184
2185 watch: function(name, depList, startFunc, stopFunc) {
2186 var _this = this;
2187
2188 this.unwatch(name);
2189
2190 this._watchers[name] = this._watchDeps(depList, function(deps) {
2191 var res = startFunc.call(_this, deps);
2192
2193 if (res && res.then) {
2194 _this.unset(name); // put in an unset state while resolving
2195 res.then(function(val) {
2196 _this.set(name, val);
2197 });
2198 }
2199 else {
2200 _this.set(name, res);
2201 }
2202 }, function() {
2203 _this.unset(name);
2204
2205 if (stopFunc) {
2206 stopFunc.call(_this);
2207 }
2208 });
2209 },
2210
2211 unwatch: function(name) {
2212 var watcher = this._watchers[name];
2213
2214 if (watcher) {
2215 delete this._watchers[name];
2216 watcher.teardown();
2217 }
2218 },
2219
2220 _watchDeps: function(depList, startFunc, stopFunc) {
2221 var _this = this;
2222 var queuedChangeCnt = 0;
2223 var depCnt = depList.length;
2224 var satisfyCnt = 0;
2225 var values = {}; // what's passed as the `deps` arguments
2226 var bindTuples = []; // array of [ eventName, handlerFunc ] arrays
2227 var isCallingStop = false;
2228
2229 function onBeforeDepChange(depName, val, isOptional) {
2230 queuedChangeCnt++;
2231 if (queuedChangeCnt === 1) { // first change to cause a "stop" ?
2232 if (satisfyCnt === depCnt) { // all deps previously satisfied?
2233 isCallingStop = true;
2234 stopFunc();
2235 isCallingStop = false;
2236 }
2237 }
2238 }
2239
2240 function onDepChange(depName, val, isOptional) {
2241
2242 if (val === undefined) { // unsetting a value?
2243
2244 // required dependency that was previously set?
2245 if (!isOptional && values[depName] !== undefined) {
2246 satisfyCnt--;
2247 }
2248
2249 delete values[depName];
2250 }
2251 else { // setting a value?
2252
2253 // required dependency that was previously unset?
2254 if (!isOptional && values[depName] === undefined) {
2255 satisfyCnt++;
2256 }
2257
2258 values[depName] = val;
2259 }
2260
2261 queuedChangeCnt--;
2262 if (!queuedChangeCnt) { // last change to cause a "start"?
2263
2264 // now finally satisfied or satisfied all along?
2265 if (satisfyCnt === depCnt) {
2266
2267 // if the stopFunc initiated another value change, ignore it.
2268 // it will be processed by another change event anyway.
2269 if (!isCallingStop) {
2270 startFunc(values);
2271 }
2272 }
2273 }
2274 }
2275
2276 // intercept for .on() that remembers handlers
2277 function bind(eventName, handler) {
2278 _this.on(eventName, handler);
2279 bindTuples.push([ eventName, handler ]);
2280 }
2281
2282 // listen to dependency changes
2283 depList.forEach(function(depName) {
2284 var isOptional = false;
2285
2286 if (depName.charAt(0) === '?') { // TODO: more DRY
2287 depName = depName.substring(1);
2288 isOptional = true;
2289 }
2290
2291 bind('before:change:' + depName, function(val) {
2292 onBeforeDepChange(depName, val, isOptional);
2293 });
2294
2295 bind('change:' + depName, function(val) {
2296 onDepChange(depName, val, isOptional);
2297 });
2298 });
2299
2300 // process current dependency values
2301 depList.forEach(function(depName) {
2302 var isOptional = false;
2303
2304 if (depName.charAt(0) === '?') { // TODO: more DRY
2305 depName = depName.substring(1);
2306 isOptional = true;
2307 }
2308
2309 if (_this.has(depName)) {
2310 values[depName] = _this.get(depName);
2311 satisfyCnt++;
2312 }
2313 else if (isOptional) {
2314 satisfyCnt++;
2315 }
2316 });
2317
2318 // initially satisfied
2319 if (satisfyCnt === depCnt) {
2320 startFunc(values);
2321 }
2322
2323 return {
2324 teardown: function() {
2325 // remove all handlers
2326 for (var i = 0; i < bindTuples.length; i++) {
2327 _this.off(bindTuples[i][0], bindTuples[i][1]);
2328 }
2329 bindTuples = null;
2330
2331 // was satisfied, so call stopFunc
2332 if (satisfyCnt === depCnt) {
2333 stopFunc();
2334 }
2335 },
2336 flash: function() {
2337 if (satisfyCnt === depCnt) {
2338 stopFunc();
2339 startFunc(values);
2340 }
2341 }
2342 };
2343 },
2344
2345 flash: function(name) {
2346 var watcher = this._watchers[name];
2347
2348 if (watcher) {
2349 watcher.flash();
2350 }
2351 }
2352
2353 });
2354
2355
2356 Model.watch = function(/* same arguments as this.watch() */) {
2357 var proto = this.prototype;
2358
2359 if (!proto._globalWatchArgs) {
2360 proto._globalWatchArgs = [];
2361 }
2362
2363 proto._globalWatchArgs.push(arguments);
2364 };
2365
2366
2367 FC.Model = Model;
2368
2369
2370 ;;
2371
2372 var Promise = {
2373
2374 construct: function(executor) {
2375 var deferred = $.Deferred();
2376 var promise = deferred.promise();
2377
2378 if (typeof executor === 'function') {
2379 executor(
2380 function(val) { // resolve
2381 deferred.resolve(val);
2382 attachImmediatelyResolvingThen(promise, val);
2383 },
2384 function() { // reject
2385 deferred.reject();
2386 attachImmediatelyRejectingThen(promise);
2387 }
2388 );
2389 }
2390
2391 return promise;
2392 },
2393
2394 resolve: function(val) {
2395 var deferred = $.Deferred().resolve(val);
2396 var promise = deferred.promise();
2397
2398 attachImmediatelyResolvingThen(promise, val);
2399
2400 return promise;
2401 },
2402
2403 reject: function() {
2404 var deferred = $.Deferred().reject();
2405 var promise = deferred.promise();
2406
2407 attachImmediatelyRejectingThen(promise);
2408
2409 return promise;
2410 }
2411
2412 };
2413
2414
2415 function attachImmediatelyResolvingThen(promise, val) {
2416 promise.then = function(onResolve) {
2417 if (typeof onResolve === 'function') {
2418 return Promise.resolve(onResolve(val));
2419 }
2420 return promise;
2421 };
2422 }
2423
2424
2425 function attachImmediatelyRejectingThen(promise) {
2426 promise.then = function(onResolve, onReject) {
2427 if (typeof onReject === 'function') {
2428 onReject();
2429 }
2430 return promise;
2431 };
2432 }
2433
2434
2435 FC.Promise = Promise;
2436
2437 ;;
2438
2439 var TaskQueue = Class.extend(EmitterMixin, {
2440
2441 q: null,
2442 isPaused: false,
2443 isRunning: false,
2444
2445
2446 constructor: function() {
2447 this.q = [];
2448 },
2449
2450
2451 queue: function(/* taskFunc, taskFunc... */) {
2452 this.q.push.apply(this.q, arguments); // append
2453 this.tryStart();
2454 },
2455
2456
2457 pause: function() {
2458 this.isPaused = true;
2459 },
2460
2461
2462 resume: function() {
2463 this.isPaused = false;
2464 this.tryStart();
2465 },
2466
2467
2468 tryStart: function() {
2469 if (!this.isRunning && this.canRunNext()) {
2470 this.isRunning = true;
2471 this.trigger('start');
2472 this.runNext();
2473 }
2474 },
2475
2476
2477 canRunNext: function() {
2478 return !this.isPaused && this.q.length;
2479 },
2480
2481
2482 runNext: function() { // does not check canRunNext
2483 this.runTask(this.q.shift());
2484 },
2485
2486
2487 runTask: function(task) {
2488 this.runTaskFunc(task);
2489 },
2490
2491
2492 runTaskFunc: function(taskFunc) {
2493 var _this = this;
2494 var res = taskFunc();
2495
2496 if (res && res.then) {
2497 res.then(done);
2498 }
2499 else {
2500 done();
2501 }
2502
2503 function done() {
2504 if (_this.canRunNext()) {
2505 _this.runNext();
2506 }
2507 else {
2508 _this.isRunning = false;
2509 _this.trigger('stop');
2510 }
2511 }
2512 }
2513
2514 });
2515
2516 FC.TaskQueue = TaskQueue;
2517
2518 ;;
2519
2520 var RenderQueue = TaskQueue.extend({
2521
2522 waitsByNamespace: null,
2523 waitNamespace: null,
2524 waitId: null,
2525
2526
2527 constructor: function(waitsByNamespace) {
2528 TaskQueue.call(this); // super-constructor
2529
2530 this.waitsByNamespace = waitsByNamespace || {};
2531 },
2532
2533
2534 queue: function(taskFunc, namespace, type) {
2535 var task = {
2536 func: taskFunc,
2537 namespace: namespace,
2538 type: type
2539 };
2540 var waitMs;
2541
2542 if (namespace) {
2543 waitMs = this.waitsByNamespace[namespace];
2544 }
2545
2546 if (this.waitNamespace) {
2547 if (namespace === this.waitNamespace && waitMs != null) {
2548 this.delayWait(waitMs);
2549 }
2550 else {
2551 this.clearWait();
2552 this.tryStart();
2553 }
2554 }
2555
2556 if (this.compoundTask(task)) { // appended to queue?
2557
2558 if (!this.waitNamespace && waitMs != null) {
2559 this.startWait(namespace, waitMs);
2560 }
2561 else {
2562 this.tryStart();
2563 }
2564 }
2565 },
2566
2567
2568 startWait: function(namespace, waitMs) {
2569 this.waitNamespace = namespace;
2570 this.spawnWait(waitMs);
2571 },
2572
2573
2574 delayWait: function(waitMs) {
2575 clearTimeout(this.waitId);
2576 this.spawnWait(waitMs);
2577 },
2578
2579
2580 spawnWait: function(waitMs) {
2581 var _this = this;
2582
2583 this.waitId = setTimeout(function() {
2584 _this.waitNamespace = null;
2585 _this.tryStart();
2586 }, waitMs);
2587 },
2588
2589
2590 clearWait: function() {
2591 if (this.waitNamespace) {
2592 clearTimeout(this.waitId);
2593 this.waitId = null;
2594 this.waitNamespace = null;
2595 }
2596 },
2597
2598
2599 canRunNext: function() {
2600 if (!TaskQueue.prototype.canRunNext.apply(this, arguments)) {
2601 return false;
2602 }
2603
2604 // waiting for a certain namespace to stop receiving tasks?
2605 if (this.waitNamespace) {
2606
2607 // if there was a different namespace task in the meantime,
2608 // that forces all previously-waiting tasks to suddenly execute.
2609 // TODO: find a way to do this in constant time.
2610 for (var q = this.q, i = 0; i < q.length; i++) {
2611 if (q[i].namespace !== this.waitNamespace) {
2612 return true; // allow execution
2613 }
2614 }
2615
2616 return false;
2617 }
2618
2619 return true;
2620 },
2621
2622
2623 runTask: function(task) {
2624 this.runTaskFunc(task.func);
2625 },
2626
2627
2628 compoundTask: function(newTask) {
2629 var q = this.q;
2630 var shouldAppend = true;
2631 var i, task;
2632
2633 if (newTask.namespace) {
2634
2635 if (newTask.type === 'destroy' || newTask.type === 'init') {
2636
2637 // remove all add/remove ops with same namespace, regardless of order
2638 for (i = q.length - 1; i >= 0; i--) {
2639 task = q[i];
2640
2641 if (
2642 task.namespace === newTask.namespace &&
2643 (task.type === 'add' || task.type === 'remove')
2644 ) {
2645 q.splice(i, 1); // remove task
2646 }
2647 }
2648
2649 if (newTask.type === 'destroy') {
2650 // eat away final init/destroy operation
2651 if (q.length) {
2652 task = q[q.length - 1]; // last task
2653
2654 if (task.namespace === newTask.namespace) {
2655
2656 // the init and our destroy cancel each other out
2657 if (task.type === 'init') {
2658 shouldAppend = false;
2659 q.pop();
2660 }
2661 // prefer to use the destroy operation that's already present
2662 else if (task.type === 'destroy') {
2663 shouldAppend = false;
2664 }
2665 }
2666 }
2667 }
2668 else if (newTask.type === 'init') {
2669 // eat away final init operation
2670 if (q.length) {
2671 task = q[q.length - 1]; // last task
2672
2673 if (
2674 task.namespace === newTask.namespace &&
2675 task.type === 'init'
2676 ) {
2677 // our init operation takes precedence
2678 q.pop();
2679 }
2680 }
2681 }
2682 }
2683 }
2684
2685 if (shouldAppend) {
2686 q.push(newTask);
2687 }
2688
2689 return shouldAppend;
2690 }
2691
2692 });
2693
2694 FC.RenderQueue = RenderQueue;
2695
2696 ;;
2697
2698 /* A rectangular panel that is absolutely positioned over other content
2699 ------------------------------------------------------------------------------------------------------------------------
2700 Options:
2701 - className (string)
2702 - content (HTML string or jQuery element set)
2703 - parentEl
2704 - top
2705 - left
2706 - right (the x coord of where the right edge should be. not a "CSS" right)
2707 - autoHide (boolean)
2708 - show (callback)
2709 - hide (callback)
2710 */
2711
2712 var Popover = Class.extend(ListenerMixin, {
2713
2714 isHidden: true,
2715 options: null,
2716 el: null, // the container element for the popover. generated by this object
2717 margin: 10, // the space required between the popover and the edges of the scroll container
2718
2719
2720 constructor: function(options) {
2721 this.options = options || {};
2722 },
2723
2724
2725 // Shows the popover on the specified position. Renders it if not already
2726 show: function() {
2727 if (this.isHidden) {
2728 if (!this.el) {
2729 this.render();
2730 }
2731 this.el.show();
2732 this.position();
2733 this.isHidden = false;
2734 this.trigger('show');
2735 }
2736 },
2737
2738
2739 // Hides the popover, through CSS, but does not remove it from the DOM
2740 hide: function() {
2741 if (!this.isHidden) {
2742 this.el.hide();
2743 this.isHidden = true;
2744 this.trigger('hide');
2745 }
2746 },
2747
2748
2749 // Creates `this.el` and renders content inside of it
2750 render: function() {
2751 var _this = this;
2752 var options = this.options;
2753
2754 this.el = $('<div class="fc-popover"/>')
2755 .addClass(options.className || '')
2756 .css({
2757 // position initially to the top left to avoid creating scrollbars
2758 top: 0,
2759 left: 0
2760 })
2761 .append(options.content)
2762 .appendTo(options.parentEl);
2763
2764 // when a click happens on anything inside with a 'fc-close' className, hide the popover
2765 this.el.on('click', '.fc-close', function() {
2766 _this.hide();
2767 });
2768
2769 if (options.autoHide) {
2770 this.listenTo($(document), 'mousedown', this.documentMousedown);
2771 }
2772 },
2773
2774
2775 // Triggered when the user clicks *anywhere* in the document, for the autoHide feature
2776 documentMousedown: function(ev) {
2777 // only hide the popover if the click happened outside the popover
2778 if (this.el && !$(ev.target).closest(this.el).length) {
2779 this.hide();
2780 }
2781 },
2782
2783
2784 // Hides and unregisters any handlers
2785 removeElement: function() {
2786 this.hide();
2787
2788 if (this.el) {
2789 this.el.remove();
2790 this.el = null;
2791 }
2792
2793 this.stopListeningTo($(document), 'mousedown');
2794 },
2795
2796
2797 // Positions the popover optimally, using the top/left/right options
2798 position: function() {
2799 var options = this.options;
2800 var origin = this.el.offsetParent().offset();
2801 var width = this.el.outerWidth();
2802 var height = this.el.outerHeight();
2803 var windowEl = $(window);
2804 var viewportEl = getScrollParent(this.el);
2805 var viewportTop;
2806 var viewportLeft;
2807 var viewportOffset;
2808 var top; // the "position" (not "offset") values for the popover
2809 var left; //
2810
2811 // compute top and left
2812 top = options.top || 0;
2813 if (options.left !== undefined) {
2814 left = options.left;
2815 }
2816 else if (options.right !== undefined) {
2817 left = options.right - width; // derive the left value from the right value
2818 }
2819 else {
2820 left = 0;
2821 }
2822
2823 if (viewportEl.is(window) || viewportEl.is(document)) { // normalize getScrollParent's result
2824 viewportEl = windowEl;
2825 viewportTop = 0; // the window is always at the top left
2826 viewportLeft = 0; // (and .offset() won't work if called here)
2827 }
2828 else {
2829 viewportOffset = viewportEl.offset();
2830 viewportTop = viewportOffset.top;
2831 viewportLeft = viewportOffset.left;
2832 }
2833
2834 // if the window is scrolled, it causes the visible area to be further down
2835 viewportTop += windowEl.scrollTop();
2836 viewportLeft += windowEl.scrollLeft();
2837
2838 // constrain to the view port. if constrained by two edges, give precedence to top/left
2839 if (options.viewportConstrain !== false) {
2840 top = Math.min(top, viewportTop + viewportEl.outerHeight() - height - this.margin);
2841 top = Math.max(top, viewportTop + this.margin);
2842 left = Math.min(left, viewportLeft + viewportEl.outerWidth() - width - this.margin);
2843 left = Math.max(left, viewportLeft + this.margin);
2844 }
2845
2846 this.el.css({
2847 top: top - origin.top,
2848 left: left - origin.left
2849 });
2850 },
2851
2852
2853 // Triggers a callback. Calls a function in the option hash of the same name.
2854 // Arguments beyond the first `name` are forwarded on.
2855 // TODO: better code reuse for this. Repeat code
2856 trigger: function(name) {
2857 if (this.options[name]) {
2858 this.options[name].apply(this, Array.prototype.slice.call(arguments, 1));
2859 }
2860 }
2861
2862 });
2863
2864 ;;
2865
2866 /*
2867 A cache for the left/right/top/bottom/width/height values for one or more elements.
2868 Works with both offset (from topleft document) and position (from offsetParent).
2869
2870 options:
2871 - els
2872 - isHorizontal
2873 - isVertical
2874 */
2875 var CoordCache = FC.CoordCache = Class.extend({
2876
2877 els: null, // jQuery set (assumed to be siblings)
2878 forcedOffsetParentEl: null, // options can override the natural offsetParent
2879 origin: null, // {left,top} position of offsetParent of els
2880 boundingRect: null, // constrain cordinates to this rectangle. {left,right,top,bottom} or null
2881 isHorizontal: false, // whether to query for left/right/width
2882 isVertical: false, // whether to query for top/bottom/height
2883
2884 // arrays of coordinates (offsets from topleft of document)
2885 lefts: null,
2886 rights: null,
2887 tops: null,
2888 bottoms: null,
2889
2890
2891 constructor: function(options) {
2892 this.els = $(options.els);
2893 this.isHorizontal = options.isHorizontal;
2894 this.isVertical = options.isVertical;
2895 this.forcedOffsetParentEl = options.offsetParent ? $(options.offsetParent) : null;
2896 },
2897
2898
2899 // Queries the els for coordinates and stores them.
2900 // Call this method before using and of the get* methods below.
2901 build: function() {
2902 var offsetParentEl = this.forcedOffsetParentEl;
2903 if (!offsetParentEl && this.els.length > 0) {
2904 offsetParentEl = this.els.eq(0).offsetParent();
2905 }
2906
2907 this.origin = offsetParentEl ?
2908 offsetParentEl.offset() :
2909 null;
2910
2911 this.boundingRect = this.queryBoundingRect();
2912
2913 if (this.isHorizontal) {
2914 this.buildElHorizontals();
2915 }
2916 if (this.isVertical) {
2917 this.buildElVerticals();
2918 }
2919 },
2920
2921
2922 // Destroys all internal data about coordinates, freeing memory
2923 clear: function() {
2924 this.origin = null;
2925 this.boundingRect = null;
2926 this.lefts = null;
2927 this.rights = null;
2928 this.tops = null;
2929 this.bottoms = null;
2930 },
2931
2932
2933 // When called, if coord caches aren't built, builds them
2934 ensureBuilt: function() {
2935 if (!this.origin) {
2936 this.build();
2937 }
2938 },
2939
2940
2941 // Populates the left/right internal coordinate arrays
2942 buildElHorizontals: function() {
2943 var lefts = [];
2944 var rights = [];
2945
2946 this.els.each(function(i, node) {
2947 var el = $(node);
2948 var left = el.offset().left;
2949 var width = el.outerWidth();
2950
2951 lefts.push(left);
2952 rights.push(left + width);
2953 });
2954
2955 this.lefts = lefts;
2956 this.rights = rights;
2957 },
2958
2959
2960 // Populates the top/bottom internal coordinate arrays
2961 buildElVerticals: function() {
2962 var tops = [];
2963 var bottoms = [];
2964
2965 this.els.each(function(i, node) {
2966 var el = $(node);
2967 var top = el.offset().top;
2968 var height = el.outerHeight();
2969
2970 tops.push(top);
2971 bottoms.push(top + height);
2972 });
2973
2974 this.tops = tops;
2975 this.bottoms = bottoms;
2976 },
2977
2978
2979 // Given a left offset (from document left), returns the index of the el that it horizontally intersects.
2980 // If no intersection is made, returns undefined.
2981 getHorizontalIndex: function(leftOffset) {
2982 this.ensureBuilt();
2983
2984 var lefts = this.lefts;
2985 var rights = this.rights;
2986 var len = lefts.length;
2987 var i;
2988
2989 for (i = 0; i < len; i++) {
2990 if (leftOffset >= lefts[i] && leftOffset < rights[i]) {
2991 return i;
2992 }
2993 }
2994 },
2995
2996
2997 // Given a top offset (from document top), returns the index of the el that it vertically intersects.
2998 // If no intersection is made, returns undefined.
2999 getVerticalIndex: function(topOffset) {
3000 this.ensureBuilt();
3001
3002 var tops = this.tops;
3003 var bottoms = this.bottoms;
3004 var len = tops.length;
3005 var i;
3006
3007 for (i = 0; i < len; i++) {
3008 if (topOffset >= tops[i] && topOffset < bottoms[i]) {
3009 return i;
3010 }
3011 }
3012 },
3013
3014
3015 // Gets the left offset (from document left) of the element at the given index
3016 getLeftOffset: function(leftIndex) {
3017 this.ensureBuilt();
3018 return this.lefts[leftIndex];
3019 },
3020
3021
3022 // Gets the left position (from offsetParent left) of the element at the given index
3023 getLeftPosition: function(leftIndex) {
3024 this.ensureBuilt();
3025 return this.lefts[leftIndex] - this.origin.left;
3026 },
3027
3028
3029 // Gets the right offset (from document left) of the element at the given index.
3030 // This value is NOT relative to the document's right edge, like the CSS concept of "right" would be.
3031 getRightOffset: function(leftIndex) {
3032 this.ensureBuilt();
3033 return this.rights[leftIndex];
3034 },
3035
3036
3037 // Gets the right position (from offsetParent left) of the element at the given index.
3038 // This value is NOT relative to the offsetParent's right edge, like the CSS concept of "right" would be.
3039 getRightPosition: function(leftIndex) {
3040 this.ensureBuilt();
3041 return this.rights[leftIndex] - this.origin.left;
3042 },
3043
3044
3045 // Gets the width of the element at the given index
3046 getWidth: function(leftIndex) {
3047 this.ensureBuilt();
3048 return this.rights[leftIndex] - this.lefts[leftIndex];
3049 },
3050
3051
3052 // Gets the top offset (from document top) of the element at the given index
3053 getTopOffset: function(topIndex) {
3054 this.ensureBuilt();
3055 return this.tops[topIndex];
3056 },
3057
3058
3059 // Gets the top position (from offsetParent top) of the element at the given position
3060 getTopPosition: function(topIndex) {
3061 this.ensureBuilt();
3062 return this.tops[topIndex] - this.origin.top;
3063 },
3064
3065 // Gets the bottom offset (from the document top) of the element at the given index.
3066 // This value is NOT relative to the offsetParent's bottom edge, like the CSS concept of "bottom" would be.
3067 getBottomOffset: function(topIndex) {
3068 this.ensureBuilt();
3069 return this.bottoms[topIndex];
3070 },
3071
3072
3073 // Gets the bottom position (from the offsetParent top) of the element at the given index.
3074 // This value is NOT relative to the offsetParent's bottom edge, like the CSS concept of "bottom" would be.
3075 getBottomPosition: function(topIndex) {
3076 this.ensureBuilt();
3077 return this.bottoms[topIndex] - this.origin.top;
3078 },
3079
3080
3081 // Gets the height of the element at the given index
3082 getHeight: function(topIndex) {
3083 this.ensureBuilt();
3084 return this.bottoms[topIndex] - this.tops[topIndex];
3085 },
3086
3087
3088 // Bounding Rect
3089 // TODO: decouple this from CoordCache
3090
3091 // Compute and return what the elements' bounding rectangle is, from the user's perspective.
3092 // Right now, only returns a rectangle if constrained by an overflow:scroll element.
3093 // Returns null if there are no elements
3094 queryBoundingRect: function() {
3095 var scrollParentEl;
3096
3097 if (this.els.length > 0) {
3098 scrollParentEl = getScrollParent(this.els.eq(0));
3099
3100 if (!scrollParentEl.is(document)) {
3101 return getClientRect(scrollParentEl);
3102 }
3103 }
3104
3105 return null;
3106 },
3107
3108 isPointInBounds: function(leftOffset, topOffset) {
3109 return this.isLeftInBounds(leftOffset) && this.isTopInBounds(topOffset);
3110 },
3111
3112 isLeftInBounds: function(leftOffset) {
3113 return !this.boundingRect || (leftOffset >= this.boundingRect.left && leftOffset < this.boundingRect.right);
3114 },
3115
3116 isTopInBounds: function(topOffset) {
3117 return !this.boundingRect || (topOffset >= this.boundingRect.top && topOffset < this.boundingRect.bottom);
3118 }
3119
3120 });
3121
3122 ;;
3123
3124 /* Tracks a drag's mouse movement, firing various handlers
3125 ----------------------------------------------------------------------------------------------------------------------*/
3126 // TODO: use Emitter
3127
3128 var DragListener = FC.DragListener = Class.extend(ListenerMixin, {
3129
3130 options: null,
3131 subjectEl: null,
3132
3133 // coordinates of the initial mousedown
3134 originX: null,
3135 originY: null,
3136
3137 // the wrapping element that scrolls, or MIGHT scroll if there's overflow.
3138 // TODO: do this for wrappers that have overflow:hidden as well.
3139 scrollEl: null,
3140
3141 isInteracting: false,
3142 isDistanceSurpassed: false,
3143 isDelayEnded: false,
3144 isDragging: false,
3145 isTouch: false,
3146 isGeneric: false, // initiated by 'dragstart' (jqui)
3147
3148 delay: null,
3149 delayTimeoutId: null,
3150 minDistance: null,
3151
3152 shouldCancelTouchScroll: true,
3153 scrollAlwaysKills: false,
3154
3155
3156 constructor: function(options) {
3157 this.options = options || {};
3158 },
3159
3160
3161 // Interaction (high-level)
3162 // -----------------------------------------------------------------------------------------------------------------
3163
3164
3165 startInteraction: function(ev, extraOptions) {
3166
3167 if (ev.type === 'mousedown') {
3168 if (GlobalEmitter.get().shouldIgnoreMouse()) {
3169 return;
3170 }
3171 else if (!isPrimaryMouseButton(ev)) {
3172 return;
3173 }
3174 else {
3175 ev.preventDefault(); // prevents native selection in most browsers
3176 }
3177 }
3178
3179 if (!this.isInteracting) {
3180
3181 // process options
3182 extraOptions = extraOptions || {};
3183 this.delay = firstDefined(extraOptions.delay, this.options.delay, 0);
3184 this.minDistance = firstDefined(extraOptions.distance, this.options.distance, 0);
3185 this.subjectEl = this.options.subjectEl;
3186
3187 preventSelection($('body'));
3188
3189 this.isInteracting = true;
3190 this.isTouch = getEvIsTouch(ev);
3191 this.isGeneric = ev.type === 'dragstart';
3192 this.isDelayEnded = false;
3193 this.isDistanceSurpassed = false;
3194
3195 this.originX = getEvX(ev);
3196 this.originY = getEvY(ev);
3197 this.scrollEl = getScrollParent($(ev.target));
3198
3199 this.bindHandlers();
3200 this.initAutoScroll();
3201 this.handleInteractionStart(ev);
3202 this.startDelay(ev);
3203
3204 if (!this.minDistance) {
3205 this.handleDistanceSurpassed(ev);
3206 }
3207 }
3208 },
3209
3210
3211 handleInteractionStart: function(ev) {
3212 this.trigger('interactionStart', ev);
3213 },
3214
3215
3216 endInteraction: function(ev, isCancelled) {
3217 if (this.isInteracting) {
3218 this.endDrag(ev);
3219
3220 if (this.delayTimeoutId) {
3221 clearTimeout(this.delayTimeoutId);
3222 this.delayTimeoutId = null;
3223 }
3224
3225 this.destroyAutoScroll();
3226 this.unbindHandlers();
3227
3228 this.isInteracting = false;
3229 this.handleInteractionEnd(ev, isCancelled);
3230
3231 allowSelection($('body'));
3232 }
3233 },
3234
3235
3236 handleInteractionEnd: function(ev, isCancelled) {
3237 this.trigger('interactionEnd', ev, isCancelled || false);
3238 },
3239
3240
3241 // Binding To DOM
3242 // -----------------------------------------------------------------------------------------------------------------
3243
3244
3245 bindHandlers: function() {
3246 // some browsers (Safari in iOS 10) don't allow preventDefault on touch events that are bound after touchstart,
3247 // so listen to the GlobalEmitter singleton, which is always bound, instead of the document directly.
3248 var globalEmitter = GlobalEmitter.get();
3249
3250 if (this.isGeneric) {
3251 this.listenTo($(document), { // might only work on iOS because of GlobalEmitter's bind :(
3252 drag: this.handleMove,
3253 dragstop: this.endInteraction
3254 });
3255 }
3256 else if (this.isTouch) {
3257 this.listenTo(globalEmitter, {
3258 touchmove: this.handleTouchMove,
3259 touchend: this.endInteraction,
3260 scroll: this.handleTouchScroll
3261 });
3262 }
3263 else {
3264 this.listenTo(globalEmitter, {
3265 mousemove: this.handleMouseMove,
3266 mouseup: this.endInteraction
3267 });
3268 }
3269
3270 this.listenTo(globalEmitter, {
3271 selectstart: preventDefault, // don't allow selection while dragging
3272 contextmenu: preventDefault // long taps would open menu on Chrome dev tools
3273 });
3274 },
3275
3276
3277 unbindHandlers: function() {
3278 this.stopListeningTo(GlobalEmitter.get());
3279 this.stopListeningTo($(document)); // for isGeneric
3280 },
3281
3282
3283 // Drag (high-level)
3284 // -----------------------------------------------------------------------------------------------------------------
3285
3286
3287 // extraOptions ignored if drag already started
3288 startDrag: function(ev, extraOptions) {
3289 this.startInteraction(ev, extraOptions); // ensure interaction began
3290
3291 if (!this.isDragging) {
3292 this.isDragging = true;
3293 this.handleDragStart(ev);
3294 }
3295 },
3296
3297
3298 handleDragStart: function(ev) {
3299 this.trigger('dragStart', ev);
3300 },
3301
3302
3303 handleMove: function(ev) {
3304 var dx = getEvX(ev) - this.originX;
3305 var dy = getEvY(ev) - this.originY;
3306 var minDistance = this.minDistance;
3307 var distanceSq; // current distance from the origin, squared
3308
3309 if (!this.isDistanceSurpassed) {
3310 distanceSq = dx * dx + dy * dy;
3311 if (distanceSq >= minDistance * minDistance) { // use pythagorean theorem
3312 this.handleDistanceSurpassed(ev);
3313 }
3314 }
3315
3316 if (this.isDragging) {
3317 this.handleDrag(dx, dy, ev);
3318 }
3319 },
3320
3321
3322 // Called while the mouse is being moved and when we know a legitimate drag is taking place
3323 handleDrag: function(dx, dy, ev) {
3324 this.trigger('drag', dx, dy, ev);
3325 this.updateAutoScroll(ev); // will possibly cause scrolling
3326 },
3327
3328
3329 endDrag: function(ev) {
3330 if (this.isDragging) {
3331 this.isDragging = false;
3332 this.handleDragEnd(ev);
3333 }
3334 },
3335
3336
3337 handleDragEnd: function(ev) {
3338 this.trigger('dragEnd', ev);
3339 },
3340
3341
3342 // Delay
3343 // -----------------------------------------------------------------------------------------------------------------
3344
3345
3346 startDelay: function(initialEv) {
3347 var _this = this;
3348
3349 if (this.delay) {
3350 this.delayTimeoutId = setTimeout(function() {
3351 _this.handleDelayEnd(initialEv);
3352 }, this.delay);
3353 }
3354 else {
3355 this.handleDelayEnd(initialEv);
3356 }
3357 },
3358
3359
3360 handleDelayEnd: function(initialEv) {
3361 this.isDelayEnded = true;
3362
3363 if (this.isDistanceSurpassed) {
3364 this.startDrag(initialEv);
3365 }
3366 },
3367
3368
3369 // Distance
3370 // -----------------------------------------------------------------------------------------------------------------
3371
3372
3373 handleDistanceSurpassed: function(ev) {
3374 this.isDistanceSurpassed = true;
3375
3376 if (this.isDelayEnded) {
3377 this.startDrag(ev);
3378 }
3379 },
3380
3381
3382 // Mouse / Touch
3383 // -----------------------------------------------------------------------------------------------------------------
3384
3385
3386 handleTouchMove: function(ev) {
3387
3388 // prevent inertia and touchmove-scrolling while dragging
3389 if (this.isDragging && this.shouldCancelTouchScroll) {
3390 ev.preventDefault();
3391 }
3392
3393 this.handleMove(ev);
3394 },
3395
3396
3397 handleMouseMove: function(ev) {
3398 this.handleMove(ev);
3399 },
3400
3401
3402 // Scrolling (unrelated to auto-scroll)
3403 // -----------------------------------------------------------------------------------------------------------------
3404
3405
3406 handleTouchScroll: function(ev) {
3407 // if the drag is being initiated by touch, but a scroll happens before
3408 // the drag-initiating delay is over, cancel the drag
3409 if (!this.isDragging || this.scrollAlwaysKills) {
3410 this.endInteraction(ev, true); // isCancelled=true
3411 }
3412 },
3413
3414
3415 // Utils
3416 // -----------------------------------------------------------------------------------------------------------------
3417
3418
3419 // Triggers a callback. Calls a function in the option hash of the same name.
3420 // Arguments beyond the first `name` are forwarded on.
3421 trigger: function(name) {
3422 if (this.options[name]) {
3423 this.options[name].apply(this, Array.prototype.slice.call(arguments, 1));
3424 }
3425 // makes _methods callable by event name. TODO: kill this
3426 if (this['_' + name]) {
3427 this['_' + name].apply(this, Array.prototype.slice.call(arguments, 1));
3428 }
3429 }
3430
3431
3432 });
3433
3434 ;;
3435 /*
3436 this.scrollEl is set in DragListener
3437 */
3438 DragListener.mixin({
3439
3440 isAutoScroll: false,
3441
3442 scrollBounds: null, // { top, bottom, left, right }
3443 scrollTopVel: null, // pixels per second
3444 scrollLeftVel: null, // pixels per second
3445 scrollIntervalId: null, // ID of setTimeout for scrolling animation loop
3446
3447 // defaults
3448 scrollSensitivity: 30, // pixels from edge for scrolling to start
3449 scrollSpeed: 200, // pixels per second, at maximum speed
3450 scrollIntervalMs: 50, // millisecond wait between scroll increment
3451
3452
3453 initAutoScroll: function() {
3454 var scrollEl = this.scrollEl;
3455
3456 this.isAutoScroll =
3457 this.options.scroll &&
3458 scrollEl &&
3459 !scrollEl.is(window) &&
3460 !scrollEl.is(document);
3461
3462 if (this.isAutoScroll) {
3463 // debounce makes sure rapid calls don't happen
3464 this.listenTo(scrollEl, 'scroll', debounce(this.handleDebouncedScroll, 100));
3465 }
3466 },
3467
3468
3469 destroyAutoScroll: function() {
3470 this.endAutoScroll(); // kill any animation loop
3471
3472 // remove the scroll handler if there is a scrollEl
3473 if (this.isAutoScroll) {
3474 this.stopListeningTo(this.scrollEl, 'scroll'); // will probably get removed by unbindHandlers too :(
3475 }
3476 },
3477
3478
3479 // Computes and stores the bounding rectangle of scrollEl
3480 computeScrollBounds: function() {
3481 if (this.isAutoScroll) {
3482 this.scrollBounds = getOuterRect(this.scrollEl);
3483 // TODO: use getClientRect in future. but prevents auto scrolling when on top of scrollbars
3484 }
3485 },
3486
3487
3488 // Called when the dragging is in progress and scrolling should be updated
3489 updateAutoScroll: function(ev) {
3490 var sensitivity = this.scrollSensitivity;
3491 var bounds = this.scrollBounds;
3492 var topCloseness, bottomCloseness;
3493 var leftCloseness, rightCloseness;
3494 var topVel = 0;
3495 var leftVel = 0;
3496
3497 if (bounds) { // only scroll if scrollEl exists
3498
3499 // compute closeness to edges. valid range is from 0.0 - 1.0
3500 topCloseness = (sensitivity - (getEvY(ev) - bounds.top)) / sensitivity;
3501 bottomCloseness = (sensitivity - (bounds.bottom - getEvY(ev))) / sensitivity;
3502 leftCloseness = (sensitivity - (getEvX(ev) - bounds.left)) / sensitivity;
3503 rightCloseness = (sensitivity - (bounds.right - getEvX(ev))) / sensitivity;
3504
3505 // translate vertical closeness into velocity.
3506 // mouse must be completely in bounds for velocity to happen.
3507 if (topCloseness >= 0 && topCloseness <= 1) {
3508 topVel = topCloseness * this.scrollSpeed * -1; // negative. for scrolling up
3509 }
3510 else if (bottomCloseness >= 0 && bottomCloseness <= 1) {
3511 topVel = bottomCloseness * this.scrollSpeed;
3512 }
3513
3514 // translate horizontal closeness into velocity
3515 if (leftCloseness >= 0 && leftCloseness <= 1) {
3516 leftVel = leftCloseness * this.scrollSpeed * -1; // negative. for scrolling left
3517 }
3518 else if (rightCloseness >= 0 && rightCloseness <= 1) {
3519 leftVel = rightCloseness * this.scrollSpeed;
3520 }
3521 }
3522
3523 this.setScrollVel(topVel, leftVel);
3524 },
3525
3526
3527 // Sets the speed-of-scrolling for the scrollEl
3528 setScrollVel: function(topVel, leftVel) {
3529
3530 this.scrollTopVel = topVel;
3531 this.scrollLeftVel = leftVel;
3532
3533 this.constrainScrollVel(); // massages into realistic values
3534
3535 // if there is non-zero velocity, and an animation loop hasn't already started, then START
3536 if ((this.scrollTopVel || this.scrollLeftVel) && !this.scrollIntervalId) {
3537 this.scrollIntervalId = setInterval(
3538 proxy(this, 'scrollIntervalFunc'), // scope to `this`
3539 this.scrollIntervalMs
3540 );
3541 }
3542 },
3543
3544
3545 // Forces scrollTopVel and scrollLeftVel to be zero if scrolling has already gone all the way
3546 constrainScrollVel: function() {
3547 var el = this.scrollEl;
3548
3549 if (this.scrollTopVel < 0) { // scrolling up?
3550 if (el.scrollTop() <= 0) { // already scrolled all the way up?
3551 this.scrollTopVel = 0;
3552 }
3553 }
3554 else if (this.scrollTopVel > 0) { // scrolling down?
3555 if (el.scrollTop() + el[0].clientHeight >= el[0].scrollHeight) { // already scrolled all the way down?
3556 this.scrollTopVel = 0;
3557 }
3558 }
3559
3560 if (this.scrollLeftVel < 0) { // scrolling left?
3561 if (el.scrollLeft() <= 0) { // already scrolled all the left?
3562 this.scrollLeftVel = 0;
3563 }
3564 }
3565 else if (this.scrollLeftVel > 0) { // scrolling right?
3566 if (el.scrollLeft() + el[0].clientWidth >= el[0].scrollWidth) { // already scrolled all the way right?
3567 this.scrollLeftVel = 0;
3568 }
3569 }
3570 },
3571
3572
3573 // This function gets called during every iteration of the scrolling animation loop
3574 scrollIntervalFunc: function() {
3575 var el = this.scrollEl;
3576 var frac = this.scrollIntervalMs / 1000; // considering animation frequency, what the vel should be mult'd by
3577
3578 // change the value of scrollEl's scroll
3579 if (this.scrollTopVel) {
3580 el.scrollTop(el.scrollTop() + this.scrollTopVel * frac);
3581 }
3582 if (this.scrollLeftVel) {
3583 el.scrollLeft(el.scrollLeft() + this.scrollLeftVel * frac);
3584 }
3585
3586 this.constrainScrollVel(); // since the scroll values changed, recompute the velocities
3587
3588 // if scrolled all the way, which causes the vels to be zero, stop the animation loop
3589 if (!this.scrollTopVel && !this.scrollLeftVel) {
3590 this.endAutoScroll();
3591 }
3592 },
3593
3594
3595 // Kills any existing scrolling animation loop
3596 endAutoScroll: function() {
3597 if (this.scrollIntervalId) {
3598 clearInterval(this.scrollIntervalId);
3599 this.scrollIntervalId = null;
3600
3601 this.handleScrollEnd();
3602 }
3603 },
3604
3605
3606 // Get called when the scrollEl is scrolled (NOTE: this is delayed via debounce)
3607 handleDebouncedScroll: function() {
3608 // recompute all coordinates, but *only* if this is *not* part of our scrolling animation
3609 if (!this.scrollIntervalId) {
3610 this.handleScrollEnd();
3611 }
3612 },
3613
3614
3615 // Called when scrolling has stopped, whether through auto scroll, or the user scrolling
3616 handleScrollEnd: function() {
3617 }
3618
3619 });
3620 ;;
3621
3622 /* Tracks mouse movements over a component and raises events about which hit the mouse is over.
3623 ------------------------------------------------------------------------------------------------------------------------
3624 options:
3625 - subjectEl
3626 - subjectCenter
3627 */
3628
3629 var HitDragListener = DragListener.extend({
3630
3631 component: null, // converts coordinates to hits
3632 // methods: hitsNeeded, hitsNotNeeded, queryHit
3633
3634 origHit: null, // the hit the mouse was over when listening started
3635 hit: null, // the hit the mouse is over
3636 coordAdjust: null, // delta that will be added to the mouse coordinates when computing collisions
3637
3638
3639 constructor: function(component, options) {
3640 DragListener.call(this, options); // call the super-constructor
3641
3642 this.component = component;
3643 },
3644
3645
3646 // Called when drag listening starts (but a real drag has not necessarily began).
3647 // ev might be undefined if dragging was started manually.
3648 handleInteractionStart: function(ev) {
3649 var subjectEl = this.subjectEl;
3650 var subjectRect;
3651 var origPoint;
3652 var point;
3653
3654 this.component.hitsNeeded();
3655 this.computeScrollBounds(); // for autoscroll
3656
3657 if (ev) {
3658 origPoint = { left: getEvX(ev), top: getEvY(ev) };
3659 point = origPoint;
3660
3661 // constrain the point to bounds of the element being dragged
3662 if (subjectEl) {
3663 subjectRect = getOuterRect(subjectEl); // used for centering as well
3664 point = constrainPoint(point, subjectRect);
3665 }
3666
3667 this.origHit = this.queryHit(point.left, point.top);
3668
3669 // treat the center of the subject as the collision point?
3670 if (subjectEl && this.options.subjectCenter) {
3671
3672 // only consider the area the subject overlaps the hit. best for large subjects.
3673 // TODO: skip this if hit didn't supply left/right/top/bottom
3674 if (this.origHit) {
3675 subjectRect = intersectRects(this.origHit, subjectRect) ||
3676 subjectRect; // in case there is no intersection
3677 }
3678
3679 point = getRectCenter(subjectRect);
3680 }
3681
3682 this.coordAdjust = diffPoints(point, origPoint); // point - origPoint
3683 }
3684 else {
3685 this.origHit = null;
3686 this.coordAdjust = null;
3687 }
3688
3689 // call the super-method. do it after origHit has been computed
3690 DragListener.prototype.handleInteractionStart.apply(this, arguments);
3691 },
3692
3693
3694 // Called when the actual drag has started
3695 handleDragStart: function(ev) {
3696 var hit;
3697
3698 DragListener.prototype.handleDragStart.apply(this, arguments); // call the super-method
3699
3700 // might be different from this.origHit if the min-distance is large
3701 hit = this.queryHit(getEvX(ev), getEvY(ev));
3702
3703 // report the initial hit the mouse is over
3704 // especially important if no min-distance and drag starts immediately
3705 if (hit) {
3706 this.handleHitOver(hit);
3707 }
3708 },
3709
3710
3711 // Called when the drag moves
3712 handleDrag: function(dx, dy, ev) {
3713 var hit;
3714
3715 DragListener.prototype.handleDrag.apply(this, arguments); // call the super-method
3716
3717 hit = this.queryHit(getEvX(ev), getEvY(ev));
3718
3719 if (!isHitsEqual(hit, this.hit)) { // a different hit than before?
3720 if (this.hit) {
3721 this.handleHitOut();
3722 }
3723 if (hit) {
3724 this.handleHitOver(hit);
3725 }
3726 }
3727 },
3728
3729
3730 // Called when dragging has been stopped
3731 handleDragEnd: function() {
3732 this.handleHitDone();
3733 DragListener.prototype.handleDragEnd.apply(this, arguments); // call the super-method
3734 },
3735
3736
3737 // Called when a the mouse has just moved over a new hit
3738 handleHitOver: function(hit) {
3739 var isOrig = isHitsEqual(hit, this.origHit);
3740
3741 this.hit = hit;
3742
3743 this.trigger('hitOver', this.hit, isOrig, this.origHit);
3744 },
3745
3746
3747 // Called when the mouse has just moved out of a hit
3748 handleHitOut: function() {
3749 if (this.hit) {
3750 this.trigger('hitOut', this.hit);
3751 this.handleHitDone();
3752 this.hit = null;
3753 }
3754 },
3755
3756
3757 // Called after a hitOut. Also called before a dragStop
3758 handleHitDone: function() {
3759 if (this.hit) {
3760 this.trigger('hitDone', this.hit);
3761 }
3762 },
3763
3764
3765 // Called when the interaction ends, whether there was a real drag or not
3766 handleInteractionEnd: function() {
3767 DragListener.prototype.handleInteractionEnd.apply(this, arguments); // call the super-method
3768
3769 this.origHit = null;
3770 this.hit = null;
3771
3772 this.component.hitsNotNeeded();
3773 },
3774
3775
3776 // Called when scrolling has stopped, whether through auto scroll, or the user scrolling
3777 handleScrollEnd: function() {
3778 DragListener.prototype.handleScrollEnd.apply(this, arguments); // call the super-method
3779
3780 // hits' absolute positions will be in new places after a user's scroll.
3781 // HACK for recomputing.
3782 if (this.isDragging) {
3783 this.component.releaseHits();
3784 this.component.prepareHits();
3785 }
3786 },
3787
3788
3789 // Gets the hit underneath the coordinates for the given mouse event
3790 queryHit: function(left, top) {
3791
3792 if (this.coordAdjust) {
3793 left += this.coordAdjust.left;
3794 top += this.coordAdjust.top;
3795 }
3796
3797 return this.component.queryHit(left, top);
3798 }
3799
3800 });
3801
3802
3803 // Returns `true` if the hits are identically equal. `false` otherwise. Must be from the same component.
3804 // Two null values will be considered equal, as two "out of the component" states are the same.
3805 function isHitsEqual(hit0, hit1) {
3806
3807 if (!hit0 && !hit1) {
3808 return true;
3809 }
3810
3811 if (hit0 && hit1) {
3812 return hit0.component === hit1.component &&
3813 isHitPropsWithin(hit0, hit1) &&
3814 isHitPropsWithin(hit1, hit0); // ensures all props are identical
3815 }
3816
3817 return false;
3818 }
3819
3820
3821 // Returns true if all of subHit's non-standard properties are within superHit
3822 function isHitPropsWithin(subHit, superHit) {
3823 for (var propName in subHit) {
3824 if (!/^(component|left|right|top|bottom)$/.test(propName)) {
3825 if (subHit[propName] !== superHit[propName]) {
3826 return false;
3827 }
3828 }
3829 }
3830 return true;
3831 }
3832
3833 ;;
3834
3835 /*
3836 Listens to document and window-level user-interaction events, like touch events and mouse events,
3837 and fires these events as-is to whoever is observing a GlobalEmitter.
3838 Best when used as a singleton via GlobalEmitter.get()
3839
3840 Normalizes mouse/touch events. For examples:
3841 - ignores the the simulated mouse events that happen after a quick tap: mousemove+mousedown+mouseup+click
3842 - compensates for various buggy scenarios where a touchend does not fire
3843 */
3844
3845 FC.touchMouseIgnoreWait = 500;
3846
3847 var GlobalEmitter = Class.extend(ListenerMixin, EmitterMixin, {
3848
3849 isTouching: false,
3850 mouseIgnoreDepth: 0,
3851 handleScrollProxy: null,
3852
3853
3854 bind: function() {
3855 var _this = this;
3856
3857 this.listenTo($(document), {
3858 touchstart: this.handleTouchStart,
3859 touchcancel: this.handleTouchCancel,
3860 touchend: this.handleTouchEnd,
3861 mousedown: this.handleMouseDown,
3862 mousemove: this.handleMouseMove,
3863 mouseup: this.handleMouseUp,
3864 click: this.handleClick,
3865 selectstart: this.handleSelectStart,
3866 contextmenu: this.handleContextMenu
3867 });
3868
3869 // because we need to call preventDefault
3870 // because https://www.chromestatus.com/features/5093566007214080
3871 // TODO: investigate performance because this is a global handler
3872 window.addEventListener(
3873 'touchmove',
3874 this.handleTouchMoveProxy = function(ev) {
3875 _this.handleTouchMove($.Event(ev));
3876 },
3877 { passive: false } // allows preventDefault()
3878 );
3879
3880 // attach a handler to get called when ANY scroll action happens on the page.
3881 // this was impossible to do with normal on/off because 'scroll' doesn't bubble.
3882 // http://stackoverflow.com/a/32954565/96342
3883 window.addEventListener(
3884 'scroll',
3885 this.handleScrollProxy = function(ev) {
3886 _this.handleScroll($.Event(ev));
3887 },
3888 true // useCapture
3889 );
3890 },
3891
3892 unbind: function() {
3893 this.stopListeningTo($(document));
3894
3895 window.removeEventListener(
3896 'touchmove',
3897 this.handleTouchMoveProxy
3898 );
3899
3900 window.removeEventListener(
3901 'scroll',
3902 this.handleScrollProxy,
3903 true // useCapture
3904 );
3905 },
3906
3907
3908 // Touch Handlers
3909 // -----------------------------------------------------------------------------------------------------------------
3910
3911 handleTouchStart: function(ev) {
3912
3913 // if a previous touch interaction never ended with a touchend, then implicitly end it,
3914 // but since a new touch interaction is about to begin, don't start the mouse ignore period.
3915 this.stopTouch(ev, true); // skipMouseIgnore=true
3916
3917 this.isTouching = true;
3918 this.trigger('touchstart', ev);
3919 },
3920
3921 handleTouchMove: function(ev) {
3922 if (this.isTouching) {
3923 this.trigger('touchmove', ev);
3924 }
3925 },
3926
3927 handleTouchCancel: function(ev) {
3928 if (this.isTouching) {
3929 this.trigger('touchcancel', ev);
3930
3931 // Have touchcancel fire an artificial touchend. That way, handlers won't need to listen to both.
3932 // If touchend fires later, it won't have any effect b/c isTouching will be false.
3933 this.stopTouch(ev);
3934 }
3935 },
3936
3937 handleTouchEnd: function(ev) {
3938 this.stopTouch(ev);
3939 },
3940
3941
3942 // Mouse Handlers
3943 // -----------------------------------------------------------------------------------------------------------------
3944
3945 handleMouseDown: function(ev) {
3946 if (!this.shouldIgnoreMouse()) {
3947 this.trigger('mousedown', ev);
3948 }
3949 },
3950
3951 handleMouseMove: function(ev) {
3952 if (!this.shouldIgnoreMouse()) {
3953 this.trigger('mousemove', ev);
3954 }
3955 },
3956
3957 handleMouseUp: function(ev) {
3958 if (!this.shouldIgnoreMouse()) {
3959 this.trigger('mouseup', ev);
3960 }
3961 },
3962
3963 handleClick: function(ev) {
3964 if (!this.shouldIgnoreMouse()) {
3965 this.trigger('click', ev);
3966 }
3967 },
3968
3969
3970 // Misc Handlers
3971 // -----------------------------------------------------------------------------------------------------------------
3972
3973 handleSelectStart: function(ev) {
3974 this.trigger('selectstart', ev);
3975 },
3976
3977 handleContextMenu: function(ev) {
3978 this.trigger('contextmenu', ev);
3979 },
3980
3981 handleScroll: function(ev) {
3982 this.trigger('scroll', ev);
3983 },
3984
3985
3986 // Utils
3987 // -----------------------------------------------------------------------------------------------------------------
3988
3989 stopTouch: function(ev, skipMouseIgnore) {
3990 if (this.isTouching) {
3991 this.isTouching = false;
3992 this.trigger('touchend', ev);
3993
3994 if (!skipMouseIgnore) {
3995 this.startTouchMouseIgnore();
3996 }
3997 }
3998 },
3999
4000 startTouchMouseIgnore: function() {
4001 var _this = this;
4002 var wait = FC.touchMouseIgnoreWait;
4003
4004 if (wait) {
4005 this.mouseIgnoreDepth++;
4006 setTimeout(function() {
4007 _this.mouseIgnoreDepth--;
4008 }, wait);
4009 }
4010 },
4011
4012 shouldIgnoreMouse: function() {
4013 return this.isTouching || Boolean(this.mouseIgnoreDepth);
4014 }
4015
4016 });
4017
4018
4019 // Singleton
4020 // ---------------------------------------------------------------------------------------------------------------------
4021
4022 (function() {
4023 var globalEmitter = null;
4024 var neededCount = 0;
4025
4026
4027 // gets the singleton
4028 GlobalEmitter.get = function() {
4029
4030 if (!globalEmitter) {
4031 globalEmitter = new GlobalEmitter();
4032 globalEmitter.bind();
4033 }
4034
4035 return globalEmitter;
4036 };
4037
4038
4039 // called when an object knows it will need a GlobalEmitter in the near future.
4040 GlobalEmitter.needed = function() {
4041 GlobalEmitter.get(); // ensures globalEmitter
4042 neededCount++;
4043 };
4044
4045
4046 // called when the object that originally called needed() doesn't need a GlobalEmitter anymore.
4047 GlobalEmitter.unneeded = function() {
4048 neededCount--;
4049
4050 if (!neededCount) { // nobody else needs it
4051 globalEmitter.unbind();
4052 globalEmitter = null;
4053 }
4054 };
4055
4056 })();
4057
4058 ;;
4059
4060 /* Creates a clone of an element and lets it track the mouse as it moves
4061 ----------------------------------------------------------------------------------------------------------------------*/
4062
4063 var MouseFollower = Class.extend(ListenerMixin, {
4064
4065 options: null,
4066
4067 sourceEl: null, // the element that will be cloned and made to look like it is dragging
4068 el: null, // the clone of `sourceEl` that will track the mouse
4069 parentEl: null, // the element that `el` (the clone) will be attached to
4070
4071 // the initial position of el, relative to the offset parent. made to match the initial offset of sourceEl
4072 top0: null,
4073 left0: null,
4074
4075 // the absolute coordinates of the initiating touch/mouse action
4076 y0: null,
4077 x0: null,
4078
4079 // the number of pixels the mouse has moved from its initial position
4080 topDelta: null,
4081 leftDelta: null,
4082
4083 isFollowing: false,
4084 isHidden: false,
4085 isAnimating: false, // doing the revert animation?
4086
4087 constructor: function(sourceEl, options) {
4088 this.options = options = options || {};
4089 this.sourceEl = sourceEl;
4090 this.parentEl = options.parentEl ? $(options.parentEl) : sourceEl.parent(); // default to sourceEl's parent
4091 },
4092
4093
4094 // Causes the element to start following the mouse
4095 start: function(ev) {
4096 if (!this.isFollowing) {
4097 this.isFollowing = true;
4098
4099 this.y0 = getEvY(ev);
4100 this.x0 = getEvX(ev);
4101 this.topDelta = 0;
4102 this.leftDelta = 0;
4103
4104 if (!this.isHidden) {
4105 this.updatePosition();
4106 }
4107
4108 if (getEvIsTouch(ev)) {
4109 this.listenTo($(document), 'touchmove', this.handleMove);
4110 }
4111 else {
4112 this.listenTo($(document), 'mousemove', this.handleMove);
4113 }
4114 }
4115 },
4116
4117
4118 // Causes the element to stop following the mouse. If shouldRevert is true, will animate back to original position.
4119 // `callback` gets invoked when the animation is complete. If no animation, it is invoked immediately.
4120 stop: function(shouldRevert, callback) {
4121 var _this = this;
4122 var revertDuration = this.options.revertDuration;
4123
4124 function complete() { // might be called by .animate(), which might change `this` context
4125 _this.isAnimating = false;
4126 _this.removeElement();
4127
4128 _this.top0 = _this.left0 = null; // reset state for future updatePosition calls
4129
4130 if (callback) {
4131 callback();
4132 }
4133 }
4134
4135 if (this.isFollowing && !this.isAnimating) { // disallow more than one stop animation at a time
4136 this.isFollowing = false;
4137
4138 this.stopListeningTo($(document));
4139
4140 if (shouldRevert && revertDuration && !this.isHidden) { // do a revert animation?
4141 this.isAnimating = true;
4142 this.el.animate({
4143 top: this.top0,
4144 left: this.left0
4145 }, {
4146 duration: revertDuration,
4147 complete: complete
4148 });
4149 }
4150 else {
4151 complete();
4152 }
4153 }
4154 },
4155
4156
4157 // Gets the tracking element. Create it if necessary
4158 getEl: function() {
4159 var el = this.el;
4160
4161 if (!el) {
4162 el = this.el = this.sourceEl.clone()
4163 .addClass(this.options.additionalClass || '')
4164 .css({
4165 position: 'absolute',
4166 visibility: '', // in case original element was hidden (commonly through hideEvents())
4167 display: this.isHidden ? 'none' : '', // for when initially hidden
4168 margin: 0,
4169 right: 'auto', // erase and set width instead
4170 bottom: 'auto', // erase and set height instead
4171 width: this.sourceEl.width(), // explicit height in case there was a 'right' value
4172 height: this.sourceEl.height(), // explicit width in case there was a 'bottom' value
4173 opacity: this.options.opacity || '',
4174 zIndex: this.options.zIndex
4175 });
4176
4177 // we don't want long taps or any mouse interaction causing selection/menus.
4178 // would use preventSelection(), but that prevents selectstart, causing problems.
4179 el.addClass('fc-unselectable');
4180
4181 el.appendTo(this.parentEl);
4182 }
4183
4184 return el;
4185 },
4186
4187
4188 // Removes the tracking element if it has already been created
4189 removeElement: function() {
4190 if (this.el) {
4191 this.el.remove();
4192 this.el = null;
4193 }
4194 },
4195
4196
4197 // Update the CSS position of the tracking element
4198 updatePosition: function() {
4199 var sourceOffset;
4200 var origin;
4201
4202 this.getEl(); // ensure this.el
4203
4204 // make sure origin info was computed
4205 if (this.top0 === null) {
4206 sourceOffset = this.sourceEl.offset();
4207 origin = this.el.offsetParent().offset();
4208 this.top0 = sourceOffset.top - origin.top;
4209 this.left0 = sourceOffset.left - origin.left;
4210 }
4211
4212 this.el.css({
4213 top: this.top0 + this.topDelta,
4214 left: this.left0 + this.leftDelta
4215 });
4216 },
4217
4218
4219 // Gets called when the user moves the mouse
4220 handleMove: function(ev) {
4221 this.topDelta = getEvY(ev) - this.y0;
4222 this.leftDelta = getEvX(ev) - this.x0;
4223
4224 if (!this.isHidden) {
4225 this.updatePosition();
4226 }
4227 },
4228
4229
4230 // Temporarily makes the tracking element invisible. Can be called before following starts
4231 hide: function() {
4232 if (!this.isHidden) {
4233 this.isHidden = true;
4234 if (this.el) {
4235 this.el.hide();
4236 }
4237 }
4238 },
4239
4240
4241 // Show the tracking element after it has been temporarily hidden
4242 show: function() {
4243 if (this.isHidden) {
4244 this.isHidden = false;
4245 this.updatePosition();
4246 this.getEl().show();
4247 }
4248 }
4249
4250 });
4251
4252 ;;
4253
4254 var ChronoComponent = Model.extend({
4255
4256 children: null,
4257
4258 el: null, // the view's containing element. set by Calendar(?)
4259
4260 // frequently accessed options
4261 isRTL: false,
4262 nextDayThreshold: null,
4263
4264
4265 constructor: function() {
4266 Model.call(this);
4267
4268 this.children = [];
4269
4270 this.nextDayThreshold = moment.duration(this.opt('nextDayThreshold'));
4271 this.isRTL = this.opt('isRTL');
4272 },
4273
4274
4275 addChild: function(chronoComponent) {
4276 this.children.push(chronoComponent);
4277 },
4278
4279
4280 // Options
4281 // -----------------------------------------------------------------------------------------------------------------
4282
4283
4284 opt: function(name) {
4285 // subclasses must implement
4286 },
4287
4288
4289 publiclyTrigger: function(/**/) {
4290 var calendar = this._getCalendar();
4291
4292 return calendar.publiclyTrigger.apply(calendar, arguments);
4293 },
4294
4295
4296 hasPublicHandlers: function(/**/) {
4297 var calendar = this._getCalendar();
4298
4299 return calendar.hasPublicHandlers.apply(calendar, arguments);
4300 },
4301
4302
4303 // Element
4304 // -----------------------------------------------------------------------------------------------------------------
4305
4306
4307 // Sets the container element that the view should render inside of, does global DOM-related initializations,
4308 // and renders all the non-date-related content inside.
4309 setElement: function(el) {
4310 this.el = el;
4311 this.bindGlobalHandlers();
4312 this.renderSkeleton();
4313 },
4314
4315
4316 // Removes the view's container element from the DOM, clearing any content beforehand.
4317 // Undoes any other DOM-related attachments.
4318 removeElement: function() {
4319 this.unrenderSkeleton();
4320 this.unbindGlobalHandlers();
4321
4322 this.el.remove();
4323 // NOTE: don't null-out this.el in case the View was destroyed within an API callback.
4324 // We don't null-out the View's other jQuery element references upon destroy,
4325 // so we shouldn't kill this.el either.
4326 },
4327
4328
4329 bindGlobalHandlers: function() {
4330 },
4331
4332
4333 unbindGlobalHandlers: function() {
4334 },
4335
4336
4337 // Skeleton
4338 // -----------------------------------------------------------------------------------------------------------------
4339
4340
4341 // Renders the basic structure of the view before any content is rendered
4342 renderSkeleton: function() {
4343 // subclasses should implement
4344 },
4345
4346
4347 // Unrenders the basic structure of the view
4348 unrenderSkeleton: function() {
4349 // subclasses should implement
4350 },
4351
4352
4353 // Date Low-level Rendering
4354 // -----------------------------------------------------------------------------------------------------------------
4355
4356
4357 // date-cell content only
4358 renderDates: function() {
4359 // subclasses should implement
4360 },
4361
4362
4363 // date-cell content only
4364 unrenderDates: function() {
4365 // subclasses should override
4366 },
4367
4368
4369 // Now-Indicator
4370 // -----------------------------------------------------------------------------------------------------------------
4371
4372
4373 // Returns a string unit, like 'second' or 'minute' that defined how often the current time indicator
4374 // should be refreshed. If something falsy is returned, no time indicator is rendered at all.
4375 getNowIndicatorUnit: function() {
4376 // subclasses should implement
4377 },
4378
4379
4380 // Renders a current time indicator at the given datetime
4381 renderNowIndicator: function(date) {
4382 this.callChildren('renderNowIndicator', date);
4383 },
4384
4385
4386 // Undoes the rendering actions from renderNowIndicator
4387 unrenderNowIndicator: function() {
4388 this.callChildren('unrenderNowIndicator');
4389 },
4390
4391
4392 // Business Hours
4393 // ---------------------------------------------------------------------------------------------------------------
4394
4395
4396 // Renders business-hours onto the view. Assumes updateSize has already been called.
4397 renderBusinessHours: function() {
4398 this.callChildren('renderBusinessHours');
4399 },
4400
4401
4402 // Unrenders previously-rendered business-hours
4403 unrenderBusinessHours: function() {
4404 this.callChildren('unrenderBusinessHours');
4405 },
4406
4407
4408 // Event Low-level Rendering
4409 // -----------------------------------------------------------------------------------------------------------------
4410
4411
4412 // Renders the events onto the view.
4413 // TODO: eventually rename to `renderEvents` once legacy is gone.
4414 renderEventsPayload: function(eventsPayload) {
4415 this.callChildren('renderEventsPayload', eventsPayload);
4416 },
4417
4418
4419 // Removes event elements from the view.
4420 unrenderEvents: function() {
4421 this.callChildren('unrenderEvents');
4422
4423 // we DON'T need to call updateHeight() because
4424 // a renderEventsPayload() call always happens after this, which will eventually call updateHeight()
4425 },
4426
4427
4428 // Retrieves all segment objects that are rendered in the view
4429 getEventSegs: function() {
4430 var children = this.children;
4431 var segs = [];
4432 var i;
4433
4434 for (i = 0; i < children.length; i++) {
4435 segs.push.apply( // append
4436 segs,
4437 children[i].getEventSegs()
4438 );
4439 }
4440
4441 return segs;
4442 },
4443
4444
4445 // Drag-n-Drop Rendering (for both events and external elements)
4446 // ---------------------------------------------------------------------------------------------------------------
4447
4448
4449 // Renders a visual indication of a event or external-element drag over the given drop zone.
4450 // If an external-element, seg will be `null`.
4451 // Must return elements used for any mock events.
4452 renderDrag: function(eventFootprints, seg) {
4453 var dragEls = null;
4454 var children = this.children;
4455 var i;
4456 var childDragEls;
4457
4458 for (i = 0; i < children.length; i++) {
4459 childDragEls = children[i].renderDrag(eventFootprints, seg);
4460
4461 if (childDragEls) {
4462 if (!dragEls) {
4463 dragEls = childDragEls;
4464 }
4465 else {
4466 dragEls = dragEls.add(childDragEls);
4467 }
4468 }
4469 }
4470
4471 return dragEls;
4472 },
4473
4474
4475 // Unrenders a visual indication of an event or external-element being dragged.
4476 unrenderDrag: function() {
4477 this.callChildren('unrenderDrag');
4478 },
4479
4480
4481 // Selection
4482 // ---------------------------------------------------------------------------------------------------------------
4483
4484
4485 // Renders a visual indication of the selection
4486 // TODO: rename to `renderSelection` after legacy is gone
4487 renderSelectionFootprint: function(componentFootprint) {
4488 this.callChildren('renderSelectionFootprint', componentFootprint);
4489 },
4490
4491
4492 // Unrenders a visual indication of selection
4493 unrenderSelection: function() {
4494 this.callChildren('unrenderSelection');
4495 },
4496
4497
4498 // Hit Areas
4499 // ---------------------------------------------------------------------------------------------------------------
4500
4501
4502 hitsNeeded: function() {
4503 this.callChildren('hitsNeeded');
4504 },
4505
4506
4507 hitsNotNeeded: function() {
4508 this.callChildren('hitsNotNeeded');
4509 },
4510
4511
4512 // Called before one or more queryHit calls might happen. Should prepare any cached coordinates for queryHit
4513 prepareHits: function() {
4514 this.callChildren('prepareHits');
4515 },
4516
4517
4518 // Called when queryHit calls have subsided. Good place to clear any coordinate caches.
4519 releaseHits: function() {
4520 this.callChildren('releaseHits');
4521 },
4522
4523
4524 // Given coordinates from the topleft of the document, return data about the date-related area underneath.
4525 // Can return an object with arbitrary properties (although top/right/left/bottom are encouraged).
4526 // Must have a `grid` property, a reference to this current grid. TODO: avoid this
4527 // The returned object will be processed by getHitFootprint and getHitEl.
4528 queryHit: function(leftOffset, topOffset) {
4529 var children = this.children;
4530 var i;
4531 var hit;
4532
4533 for (i = 0; i < children.length; i++) {
4534 hit = children[i].queryHit(leftOffset, topOffset);
4535
4536 if (hit) {
4537 break;
4538 }
4539 }
4540
4541 return hit;
4542 },
4543
4544
4545
4546 // Event Drag-n-Drop
4547 // ---------------------------------------------------------------------------------------------------------------
4548
4549
4550 // Computes if the given event is allowed to be dragged by the user
4551 isEventDefDraggable: function(eventDef) {
4552 return this.isEventDefStartEditable(eventDef);
4553 },
4554
4555
4556 isEventDefStartEditable: function(eventDef) {
4557 var isEditable = eventDef.isStartExplicitlyEditable();
4558
4559 if (isEditable == null) {
4560 isEditable = this.opt('eventStartEditable');
4561
4562 if (isEditable == null) {
4563 isEditable = this.isEventDefGenerallyEditable(eventDef);
4564 }
4565 }
4566
4567 return isEditable;
4568 },
4569
4570
4571 isEventDefGenerallyEditable: function(eventDef) {
4572 var isEditable = eventDef.isExplicitlyEditable();
4573
4574 if (isEditable == null) {
4575 isEditable = this.opt('editable');
4576 }
4577
4578 return isEditable;
4579 },
4580
4581
4582 // Event Resizing
4583 // ---------------------------------------------------------------------------------------------------------------
4584
4585
4586 // Computes if the given event is allowed to be resized from its starting edge
4587 isEventDefResizableFromStart: function(eventDef) {
4588 return this.opt('eventResizableFromStart') && this.isEventDefResizable(eventDef);
4589 },
4590
4591
4592 // Computes if the given event is allowed to be resized from its ending edge
4593 isEventDefResizableFromEnd: function(eventDef) {
4594 return this.isEventDefResizable(eventDef);
4595 },
4596
4597
4598 // Computes if the given event is allowed to be resized by the user at all
4599 isEventDefResizable: function(eventDef) {
4600 var isResizable = eventDef.isDurationExplicitlyEditable();
4601
4602 if (isResizable == null) {
4603 isResizable = this.opt('eventDurationEditable');
4604
4605 if (isResizable == null) {
4606 isResizable = this.isEventDefGenerallyEditable(eventDef);
4607 }
4608 }
4609 return isResizable;
4610 },
4611
4612
4613 // Foreground Segment Rendering
4614 // ---------------------------------------------------------------------------------------------------------------
4615
4616
4617 // Renders foreground event segments onto the grid. May return a subset of segs that were rendered.
4618 renderFgSegs: function(segs) {
4619 // subclasses must implement
4620 },
4621
4622
4623 // Unrenders all currently rendered foreground segments
4624 unrenderFgSegs: function() {
4625 // subclasses must implement
4626 },
4627
4628
4629 // Renders and assigns an `el` property for each foreground event segment.
4630 // Only returns segments that successfully rendered.
4631 // A utility that subclasses may use.
4632 renderFgSegEls: function(segs, disableResizing) {
4633 var _this = this;
4634 var hasEventRenderHandlers = this.hasPublicHandlers('eventRender');
4635 var html = '';
4636 var renderedSegs = [];
4637 var i;
4638
4639 if (segs.length) { // don't build an empty html string
4640
4641 // build a large concatenation of event segment HTML
4642 for (i = 0; i < segs.length; i++) {
4643 html += this.fgSegHtml(segs[i], disableResizing);
4644 }
4645
4646 // Grab individual elements from the combined HTML string. Use each as the default rendering.
4647 // Then, compute the 'el' for each segment. An el might be null if the eventRender callback returned false.
4648 $(html).each(function(i, node) {
4649 var seg = segs[i];
4650 var el = $(node);
4651
4652 if (hasEventRenderHandlers) { // optimization
4653 el = _this.filterEventRenderEl(seg.footprint, el);
4654 }
4655
4656 if (el) {
4657 el.data('fc-seg', seg); // used by handlers
4658 seg.el = el;
4659 renderedSegs.push(seg);
4660 }
4661 });
4662 }
4663
4664 return renderedSegs;
4665 },
4666
4667
4668 // Generates the HTML for the default rendering of a foreground event segment. Used by renderFgSegEls()
4669 fgSegHtml: function(seg, disableResizing) {
4670 // subclasses should implement
4671 },
4672
4673
4674 // Given an event and the default element used for rendering, returns the element that should actually be used.
4675 // Basically runs events and elements through the eventRender hook.
4676 filterEventRenderEl: function(eventFootprint, el) {
4677 var legacy = eventFootprint.getEventLegacy();
4678
4679 var custom = this.publiclyTrigger('eventRender', {
4680 context: legacy,
4681 args: [ legacy, el, this._getView() ]
4682 });
4683
4684 if (custom === false) { // means don't render at all
4685 el = null;
4686 }
4687 else if (custom && custom !== true) {
4688 el = $(custom);
4689 }
4690
4691 return el;
4692 },
4693
4694
4695 // Navigation
4696 // ----------------------------------------------------------------------------------------------------------------
4697
4698
4699 // Generates HTML for an anchor to another view into the calendar.
4700 // Will either generate an <a> tag or a non-clickable <span> tag, depending on enabled settings.
4701 // `gotoOptions` can either be a moment input, or an object with the form:
4702 // { date, type, forceOff }
4703 // `type` is a view-type like "day" or "week". default value is "day".
4704 // `attrs` and `innerHtml` are use to generate the rest of the HTML tag.
4705 buildGotoAnchorHtml: function(gotoOptions, attrs, innerHtml) {
4706 var date, type, forceOff;
4707 var finalOptions;
4708
4709 if ($.isPlainObject(gotoOptions)) {
4710 date = gotoOptions.date;
4711 type = gotoOptions.type;
4712 forceOff = gotoOptions.forceOff;
4713 }
4714 else {
4715 date = gotoOptions; // a single moment input
4716 }
4717 date = FC.moment(date); // if a string, parse it
4718
4719 finalOptions = { // for serialization into the link
4720 date: date.format('YYYY-MM-DD'),
4721 type: type || 'day'
4722 };
4723
4724 if (typeof attrs === 'string') {
4725 innerHtml = attrs;
4726 attrs = null;
4727 }
4728
4729 attrs = attrs ? ' ' + attrsToStr(attrs) : ''; // will have a leading space
4730 innerHtml = innerHtml || '';
4731
4732 if (!forceOff && this.opt('navLinks')) {
4733 return '<a' + attrs +
4734 ' data-goto="' + htmlEscape(JSON.stringify(finalOptions)) + '">' +
4735 innerHtml +
4736 '</a>';
4737 }
4738 else {
4739 return '<span' + attrs + '>' +
4740 innerHtml +
4741 '</span>';
4742 }
4743 },
4744
4745
4746 // Date Formatting Utils
4747 // ---------------------------------------------------------------------------------------------------------------
4748
4749
4750 // Utility for formatting a range. Accepts a range object, formatting string, and optional separator.
4751 // Displays all-day ranges naturally, with an inclusive end. Takes the current isRTL into account.
4752 // The timezones of the dates within `range` will be respected.
4753 formatRange: function(range, isAllDay, formatStr, separator) {
4754 var end = range.end;
4755
4756 if (isAllDay) {
4757 end = end.clone().subtract(1); // convert to inclusive. last ms of previous day
4758 }
4759
4760 return formatRange(range.start, end, formatStr, separator, this.isRTL);
4761 },
4762
4763
4764 getAllDayHtml: function() {
4765 return this.opt('allDayHtml') || htmlEscape(this.opt('allDayText'));
4766 },
4767
4768
4769 // Computes HTML classNames for a single-day element
4770 getDayClasses: function(date, noThemeHighlight) {
4771 var view = this._getView();
4772 var classes = [];
4773 var today;
4774
4775 if (!view.activeUnzonedRange.containsDate(date)) {
4776 classes.push('fc-disabled-day'); // TODO: jQuery UI theme?
4777 }
4778 else {
4779 classes.push('fc-' + dayIDs[date.day()]);
4780
4781 if (view.isDateInOtherMonth(date)) { // TODO: use ChronoComponent subclass somehow
4782 classes.push('fc-other-month');
4783 }
4784
4785 today = view.calendar.getNow();
4786
4787 if (date.isSame(today, 'day')) {
4788 classes.push('fc-today');
4789
4790 if (noThemeHighlight !== true) {
4791 classes.push(view.calendar.theme.getClass('today'));
4792 }
4793 }
4794 else if (date < today) {
4795 classes.push('fc-past');
4796 }
4797 else {
4798 classes.push('fc-future');
4799 }
4800 }
4801
4802 return classes;
4803 },
4804
4805
4806 // Date Utils
4807 // ---------------------------------------------------------------------------------------------------------------
4808
4809
4810 // Returns the date range of the full days the given range visually appears to occupy.
4811 // Returns a plain object with start/end, NOT an UnzonedRange!
4812 computeDayRange: function(unzonedRange) {
4813 var calendar = this._getCalendar();
4814 var startDay = calendar.msToUtcMoment(unzonedRange.startMs, true); // the beginning of the day the range starts
4815 var end = calendar.msToUtcMoment(unzonedRange.endMs);
4816 var endTimeMS = +end.time(); // # of milliseconds into `endDay`
4817 var endDay = end.clone().stripTime(); // the beginning of the day the range exclusively ends
4818
4819 // If the end time is actually inclusively part of the next day and is equal to or
4820 // beyond the next day threshold, adjust the end to be the exclusive end of `endDay`.
4821 // Otherwise, leaving it as inclusive will cause it to exclude `endDay`.
4822 if (endTimeMS && endTimeMS >= this.nextDayThreshold) {
4823 endDay.add(1, 'days');
4824 }
4825
4826 // If end is within `startDay` but not past nextDayThreshold, assign the default duration of one day.
4827 if (endDay <= startDay) {
4828 endDay = startDay.clone().add(1, 'days');
4829 }
4830
4831 return { start: startDay, end: endDay };
4832 },
4833
4834
4835 // Does the given range visually appear to occupy more than one day?
4836 isMultiDayRange: function(unzonedRange) {
4837 var dayRange = this.computeDayRange(unzonedRange);
4838
4839 return dayRange.end.diff(dayRange.start, 'days') > 1;
4840 },
4841
4842
4843 // Utils
4844 // ---------------------------------------------------------------------------------------------------------------
4845
4846
4847 callChildren: function(methodName) {
4848 var args = Array.prototype.slice.call(arguments, 1);
4849 var children = this.children;
4850 var i, child;
4851
4852 for (i = 0; i < children.length; i++) {
4853 child = children[i];
4854 child[methodName].apply(child, args);
4855 }
4856 },
4857
4858
4859 _getCalendar: function() { // TODO: strip out. move to generic parent.
4860 return this.calendar || this.view.calendar;
4861 },
4862
4863
4864 _getView: function() { // TODO: strip out. move to generic parent.
4865 return this.view;
4866 }
4867
4868 });
4869
4870 ;;
4871
4872 /* An abstract class comprised of a "grid" of areas that each represent a specific datetime
4873 ----------------------------------------------------------------------------------------------------------------------
4874 Contains:
4875 - hit system
4876 - range->footprint->seg pipeline
4877 - initializing day click
4878 - initializing selection system
4879 - initializing mouse/touch handlers for everything
4880 - initializing event rendering-related options
4881 */
4882
4883 var Grid = FC.Grid = ChronoComponent.extend({
4884
4885 // self-config, overridable by subclasses
4886 hasDayInteractions: true, // can user click/select ranges of time?
4887
4888 view: null, // a View object
4889 isRTL: null, // shortcut to the view's isRTL option
4890
4891 unzonedRange: null,
4892
4893 hitsNeededDepth: 0, // necessary because multiple callers might need the same hits
4894
4895 dayClickListener: null,
4896 daySelectListener: null,
4897 segDragListener: null,
4898 segResizeListener: null,
4899 externalDragListener: null,
4900
4901
4902 constructor: function(view) {
4903 this.view = view;
4904
4905 ChronoComponent.call(this);
4906
4907 this.initFillInternals();
4908
4909 this.dayClickListener = this.buildDayClickListener();
4910 this.daySelectListener = this.buildDaySelectListener();
4911 },
4912
4913
4914 opt: function(name) {
4915 return this.view.opt(name);
4916 },
4917
4918
4919 /* Dates
4920 ------------------------------------------------------------------------------------------------------------------*/
4921
4922
4923 // Tells the grid about what period of time to display.
4924 // Any date-related internal data should be generated.
4925 setRange: function(unzonedRange) {
4926 this.unzonedRange = unzonedRange;
4927
4928 this.rangeUpdated();
4929 this.processRangeOptions();
4930 },
4931
4932
4933 // Called when internal variables that rely on the range should be updated
4934 rangeUpdated: function() {
4935 },
4936
4937
4938 // Updates values that rely on options and also relate to range
4939 processRangeOptions: function() {
4940 var displayEventTime;
4941 var displayEventEnd;
4942
4943 this.eventTimeFormat = // for Grid.event-rendering.js
4944 this.opt('eventTimeFormat') ||
4945 this.opt('timeFormat') || // deprecated
4946 this.computeEventTimeFormat();
4947
4948 displayEventTime = this.opt('displayEventTime');
4949 if (displayEventTime == null) {
4950 displayEventTime = this.computeDisplayEventTime(); // might be based off of range
4951 }
4952
4953 displayEventEnd = this.opt('displayEventEnd');
4954 if (displayEventEnd == null) {
4955 displayEventEnd = this.computeDisplayEventEnd(); // might be based off of range
4956 }
4957
4958 this.displayEventTime = displayEventTime;
4959 this.displayEventEnd = displayEventEnd;
4960 },
4961
4962
4963
4964 /* Hit Area
4965 ------------------------------------------------------------------------------------------------------------------*/
4966
4967
4968 hitsNeeded: function() {
4969 if (!(this.hitsNeededDepth++)) {
4970 this.prepareHits();
4971 }
4972 },
4973
4974
4975 hitsNotNeeded: function() {
4976 if (this.hitsNeededDepth && !(--this.hitsNeededDepth)) {
4977 this.releaseHits();
4978 }
4979 },
4980
4981
4982 getSafeHitFootprint: function(hit) {
4983 var footprint = this.getHitFootprint(hit);
4984
4985 if (!this.view.activeUnzonedRange.containsRange(footprint.unzonedRange)) {
4986 return null;
4987 }
4988
4989 return footprint;
4990 },
4991
4992
4993 getHitFootprint: function(hit) {
4994 },
4995
4996
4997 // Given position-level information about a date-related area within the grid,
4998 // should return a jQuery element that best represents it. passed to dayClick callback.
4999 getHitEl: function(hit) {
5000 },
5001
5002
5003 /* Rendering
5004 ------------------------------------------------------------------------------------------------------------------*/
5005
5006
5007 // Sets the container element that the grid should render inside of.
5008 // Does other DOM-related initializations.
5009 setElement: function(el) {
5010 ChronoComponent.prototype.setElement.apply(this, arguments);
5011
5012 if (this.hasDayInteractions) {
5013 preventSelection(el);
5014
5015 this.bindDayHandler('touchstart', this.dayTouchStart);
5016 this.bindDayHandler('mousedown', this.dayMousedown);
5017 }
5018
5019 // attach event-element-related handlers. in Grid.events
5020 // same garbage collection note as above.
5021 this.bindSegHandlers();
5022 },
5023
5024
5025 bindDayHandler: function(name, handler) {
5026 var _this = this;
5027
5028 // attach a handler to the grid's root element.
5029 // jQuery will take care of unregistering them when removeElement gets called.
5030 this.el.on(name, function(ev) {
5031 if (
5032 !$(ev.target).is(
5033 _this.segSelector + ',' + // directly on an event element
5034 _this.segSelector + ' *,' + // within an event element
5035 '.fc-more,' + // a "more.." link
5036 'a[data-goto]' // a clickable nav link
5037 )
5038 ) {
5039 return handler.call(_this, ev);
5040 }
5041 });
5042 },
5043
5044
5045 // Removes the grid's container element from the DOM. Undoes any other DOM-related attachments.
5046 // DOES NOT remove any content beforehand (doesn't clear events or call unrenderDates), unlike View
5047 removeElement: function() {
5048 ChronoComponent.prototype.removeElement.apply(this, arguments);
5049
5050 this.clearDragListeners();
5051 },
5052
5053
5054 /* Handlers
5055 ------------------------------------------------------------------------------------------------------------------*/
5056
5057
5058 // Binds DOM handlers to elements that reside outside the grid, such as the document
5059 bindGlobalHandlers: function() {
5060 ChronoComponent.prototype.bindGlobalHandlers.apply(this, arguments);
5061
5062 this.listenTo($(document), {
5063 dragstart: this.externalDragStart, // jqui
5064 sortstart: this.externalDragStart // jqui
5065 });
5066 },
5067
5068
5069 // Unbinds DOM handlers from elements that reside outside the grid
5070 unbindGlobalHandlers: function() {
5071 ChronoComponent.prototype.unbindGlobalHandlers.apply(this, arguments);
5072
5073 this.stopListeningTo($(document));
5074 },
5075
5076
5077 // Process a mousedown on an element that represents a day. For day clicking and selecting.
5078 dayMousedown: function(ev) {
5079
5080 // HACK
5081 // This will still work even though bindDayHandler doesn't use GlobalEmitter.
5082 if (GlobalEmitter.get().shouldIgnoreMouse()) {
5083 return;
5084 }
5085
5086 this.dayClickListener.startInteraction(ev);
5087
5088 if (this.opt('selectable')) {
5089 this.daySelectListener.startInteraction(ev, {
5090 distance: this.opt('selectMinDistance')
5091 });
5092 }
5093 },
5094
5095
5096 dayTouchStart: function(ev) {
5097 var view = this.view;
5098 var selectLongPressDelay;
5099
5100 // On iOS (and Android?) when a new selection is initiated overtop another selection,
5101 // the touchend never fires because the elements gets removed mid-touch-interaction (my theory).
5102 // HACK: simply don't allow this to happen.
5103 // ALSO: prevent selection when an *event* is already raised.
5104 if (view.isSelected || view.selectedEvent) {
5105 return;
5106 }
5107
5108 selectLongPressDelay = this.opt('selectLongPressDelay');
5109 if (selectLongPressDelay == null) {
5110 selectLongPressDelay = this.opt('longPressDelay'); // fallback
5111 }
5112
5113 this.dayClickListener.startInteraction(ev);
5114
5115 if (this.opt('selectable')) {
5116 this.daySelectListener.startInteraction(ev, {
5117 delay: selectLongPressDelay
5118 });
5119 }
5120 },
5121
5122
5123 // Kills all in-progress dragging.
5124 // Useful for when public API methods that result in re-rendering are invoked during a drag.
5125 // Also useful for when touch devices misbehave and don't fire their touchend.
5126 clearDragListeners: function() {
5127 this.dayClickListener.endInteraction();
5128 this.daySelectListener.endInteraction();
5129
5130 if (this.segDragListener) {
5131 this.segDragListener.endInteraction(); // will clear this.segDragListener
5132 }
5133 if (this.segResizeListener) {
5134 this.segResizeListener.endInteraction(); // will clear this.segResizeListener
5135 }
5136 if (this.externalDragListener) {
5137 this.externalDragListener.endInteraction(); // will clear this.externalDragListener
5138 }
5139 },
5140
5141
5142 /* Highlight
5143 ------------------------------------------------------------------------------------------------------------------*/
5144
5145
5146 // Renders an emphasis on the given date range. Given a span (unzoned start/end and other misc data)
5147 renderHighlight: function(componentFootprint) {
5148 this.renderFill('highlight', this.componentFootprintToSegs(componentFootprint));
5149 },
5150
5151
5152 // Unrenders the emphasis on a date range
5153 unrenderHighlight: function() {
5154 this.unrenderFill('highlight');
5155 },
5156
5157
5158 /* Converting eventRange -> eventFootprint
5159 ------------------------------------------------------------------------------------------------------------------*/
5160
5161
5162 eventRangesToEventFootprints: function(eventRanges) {
5163 var eventFootprints = [];
5164 var i;
5165
5166 for (i = 0; i < eventRanges.length; i++) {
5167 eventFootprints.push.apply(eventFootprints,
5168 this.eventRangeToEventFootprints(eventRanges[i])
5169 );
5170 }
5171
5172 return eventFootprints;
5173 },
5174
5175
5176 // Given an event's unzoned date range, return an array of eventSpan objects.
5177 // eventSpan - { start, end, isStart, isEnd, otherthings... }
5178 // Subclasses can override.
5179 // Subclasses are obligated to forward eventRange.isStart/isEnd to the resulting spans.
5180 // TODO: somehow more DRY with Calendar::eventRangeToEventFootprints
5181 eventRangeToEventFootprints: function(eventRange) {
5182 return [
5183 new EventFootprint(
5184 new ComponentFootprint(
5185 eventRange.unzonedRange,
5186 eventRange.eventDef.isAllDay()
5187 ),
5188 eventRange.eventDef,
5189 eventRange.eventInstance // might not exist
5190 )
5191 ];
5192 },
5193
5194
5195 /* Converting componentFootprint/eventFootprint -> segs
5196 ------------------------------------------------------------------------------------------------------------------*/
5197
5198
5199 eventFootprintsToSegs: function(eventFootprints) {
5200 var segs = [];
5201 var i;
5202
5203 for (i = 0; i < eventFootprints.length; i++) {
5204 segs.push.apply(segs,
5205 this.eventFootprintToSegs(eventFootprints[i])
5206 );
5207 }
5208
5209 return segs;
5210 },
5211
5212
5213 // Given an event's span (unzoned start/end and other misc data), and the event itself,
5214 // slices into segments and attaches event-derived properties to them.
5215 // eventSpan - { start, end, isStart, isEnd, otherthings... }
5216 // constraintRange allow additional clipping. optional. eventually remove this.
5217 eventFootprintToSegs: function(eventFootprint, constraintRange) {
5218 var unzonedRange = eventFootprint.componentFootprint.unzonedRange;
5219 var segs;
5220 var i, seg;
5221
5222 if (constraintRange) {
5223 unzonedRange = unzonedRange.intersect(constraintRange);
5224 }
5225
5226 segs = this.componentFootprintToSegs(eventFootprint.componentFootprint);
5227
5228 for (i = 0; i < segs.length; i++) {
5229 seg = segs[i];
5230
5231 if (!unzonedRange.isStart) {
5232 seg.isStart = false;
5233 }
5234 if (!unzonedRange.isEnd) {
5235 seg.isEnd = false;
5236 }
5237
5238 seg.footprint = eventFootprint;
5239 // TODO: rename to seg.eventFootprint
5240 }
5241
5242 return segs;
5243 },
5244
5245
5246 componentFootprintToSegs: function(componentFootprint) {
5247 // subclasses must implement
5248 }
5249
5250 });
5251
5252 ;;
5253
5254 Grid.mixin({
5255
5256 // Creates a listener that tracks the user's drag across day elements, for day clicking.
5257 buildDayClickListener: function() {
5258 var _this = this;
5259 var dayClickHit; // null if invalid dayClick
5260
5261 var dragListener = new HitDragListener(this, {
5262 scroll: this.opt('dragScroll'),
5263 interactionStart: function() {
5264 dayClickHit = dragListener.origHit;
5265 },
5266 hitOver: function(hit, isOrig, origHit) {
5267 // if user dragged to another cell at any point, it can no longer be a dayClick
5268 if (!isOrig) {
5269 dayClickHit = null;
5270 }
5271 },
5272 hitOut: function() { // called before mouse moves to a different hit OR moved out of all hits
5273 dayClickHit = null;
5274 },
5275 interactionEnd: function(ev, isCancelled) {
5276 var componentFootprint;
5277
5278 if (!isCancelled && dayClickHit) {
5279 componentFootprint = _this.getSafeHitFootprint(dayClickHit);
5280
5281 if (componentFootprint) {
5282 _this.view.triggerDayClick(componentFootprint, _this.getHitEl(dayClickHit), ev);
5283 }
5284 }
5285 }
5286 });
5287
5288 // because dayClickListener won't be called with any time delay, "dragging" will begin immediately,
5289 // which will kill any touchmoving/scrolling. Prevent this.
5290 dragListener.shouldCancelTouchScroll = false;
5291
5292 dragListener.scrollAlwaysKills = true;
5293
5294 return dragListener;
5295 }
5296
5297 });
5298
5299 ;;
5300
5301 Grid.mixin({
5302
5303 // Creates a listener that tracks the user's drag across day elements, for day selecting.
5304 buildDaySelectListener: function() {
5305 var _this = this;
5306 var selectionFootprint; // null if invalid selection
5307
5308 var dragListener = new HitDragListener(this, {
5309 scroll: this.opt('dragScroll'),
5310 interactionStart: function() {
5311 selectionFootprint = null;
5312 },
5313 dragStart: function() {
5314 _this.view.unselect(); // since we could be rendering a new selection, we want to clear any old one
5315 },
5316 hitOver: function(hit, isOrig, origHit) {
5317 var origHitFootprint;
5318 var hitFootprint;
5319
5320 if (origHit) { // click needs to have started on a hit
5321
5322 origHitFootprint = _this.getSafeHitFootprint(origHit);
5323 hitFootprint = _this.getSafeHitFootprint(hit);
5324
5325 if (origHitFootprint && hitFootprint) {
5326 selectionFootprint = _this.computeSelection(origHitFootprint, hitFootprint);
5327 }
5328 else {
5329 selectionFootprint = null;
5330 }
5331
5332 if (selectionFootprint) {
5333 _this.renderSelectionFootprint(selectionFootprint);
5334 }
5335 else if (selectionFootprint === false) {
5336 disableCursor();
5337 }
5338 }
5339 },
5340 hitOut: function() { // called before mouse moves to a different hit OR moved out of all hits
5341 selectionFootprint = null;
5342 _this.unrenderSelection();
5343 },
5344 hitDone: function() { // called after a hitOut OR before a dragEnd
5345 enableCursor();
5346 },
5347 interactionEnd: function(ev, isCancelled) {
5348 if (!isCancelled && selectionFootprint) {
5349 // the selection will already have been rendered. just report it
5350 _this.view.reportSelection(selectionFootprint, ev);
5351 }
5352 }
5353 });
5354
5355 return dragListener;
5356 },
5357
5358
5359 // Renders a visual indication of a selection. Will highlight by default but can be overridden by subclasses.
5360 // Given a span (unzoned start/end and other misc data)
5361 renderSelectionFootprint: function(componentFootprint) {
5362 this.renderHighlight(componentFootprint);
5363 },
5364
5365
5366 // Unrenders any visual indications of a selection. Will unrender a highlight by default.
5367 unrenderSelection: function() {
5368 this.unrenderHighlight();
5369 },
5370
5371
5372 // Given the first and last date-spans of a selection, returns another date-span object.
5373 // Subclasses can override and provide additional data in the span object. Will be passed to renderSelectionFootprint().
5374 // Will return false if the selection is invalid and this should be indicated to the user.
5375 // Will return null/undefined if a selection invalid but no error should be reported.
5376 computeSelection: function(footprint0, footprint1) {
5377 var wholeFootprint = this.computeSelectionFootprint(footprint0, footprint1);
5378
5379 if (wholeFootprint && !this.isSelectionFootprintAllowed(wholeFootprint)) {
5380 return false;
5381 }
5382
5383 return wholeFootprint;
5384 },
5385
5386
5387 // Given two spans, must return the combination of the two.
5388 // TODO: do this separation of concerns (combining VS validation) for event dnd/resize too.
5389 // Assumes both footprints are non-open-ended.
5390 computeSelectionFootprint: function(footprint0, footprint1) {
5391 var ms = [
5392 footprint0.unzonedRange.startMs,
5393 footprint0.unzonedRange.endMs,
5394 footprint1.unzonedRange.startMs,
5395 footprint1.unzonedRange.endMs
5396 ];
5397
5398 ms.sort(compareNumbers);
5399
5400 return new ComponentFootprint(
5401 new UnzonedRange(ms[0], ms[3]),
5402 footprint0.isAllDay
5403 );
5404 },
5405
5406
5407 isSelectionFootprintAllowed: function(componentFootprint) {
5408 return this.view.validUnzonedRange.containsRange(componentFootprint.unzonedRange) &&
5409 this.view.calendar.isSelectionFootprintAllowed(componentFootprint);
5410 }
5411
5412 });
5413
5414 ;;
5415
5416 Grid.mixin({
5417
5418 // Generates an array of classNames to be used for the rendering business hours overlay. Called by the fill system.
5419 // Called by fillSegHtml.
5420 businessHoursSegClasses: function(seg) {
5421 return [ 'fc-nonbusiness', 'fc-bgevent' ];
5422 },
5423
5424
5425 // Compute business hour segs for the grid's current date range.
5426 // Caller must ask if whole-day business hours are needed.
5427 buildBusinessHourSegs: function(wholeDay) {
5428 return this.eventFootprintsToSegs(
5429 this.buildBusinessHourEventFootprints(wholeDay)
5430 );
5431 },
5432
5433
5434 // Compute business hour *events* for the grid's current date range.
5435 // Caller must ask if whole-day business hours are needed.
5436 // FOR RENDERING
5437 buildBusinessHourEventFootprints: function(wholeDay) {
5438 var calendar = this.view.calendar;
5439
5440 return this._buildBusinessHourEventFootprints(wholeDay, calendar.opt('businessHours'));
5441 },
5442
5443
5444 _buildBusinessHourEventFootprints: function(wholeDay, businessHourDef) {
5445 var calendar = this.view.calendar;
5446 var eventInstanceGroup;
5447 var eventRanges;
5448
5449 eventInstanceGroup = calendar.buildBusinessInstanceGroup(
5450 wholeDay,
5451 businessHourDef,
5452 this.unzonedRange
5453 );
5454
5455 if (eventInstanceGroup) {
5456 eventRanges = eventInstanceGroup.sliceRenderRanges(
5457 this.unzonedRange,
5458 calendar
5459 );
5460 }
5461 else {
5462 eventRanges = [];
5463 }
5464
5465 return this.eventRangesToEventFootprints(eventRanges);
5466 }
5467
5468 });
5469
5470 ;;
5471
5472 Grid.mixin({
5473
5474 segs: null, // the *event* segments currently rendered in the grid. TODO: rename to `eventSegs`
5475
5476 // derived from options
5477 // TODO: move initialization from Grid.js
5478 eventTimeFormat: null,
5479 displayEventTime: null,
5480 displayEventEnd: null,
5481
5482
5483 // Generates the format string used for event time text, if not explicitly defined by 'timeFormat'
5484 computeEventTimeFormat: function() {
5485 return this.opt('smallTimeFormat');
5486 },
5487
5488
5489 // Determines whether events should have their end times displayed, if not explicitly defined by 'displayEventTime'.
5490 // Only applies to non-all-day events.
5491 computeDisplayEventTime: function() {
5492 return true;
5493 },
5494
5495
5496 // Determines whether events should have their end times displayed, if not explicitly defined by 'displayEventEnd'
5497 computeDisplayEventEnd: function() {
5498 return true;
5499 },
5500
5501
5502 renderEventsPayload: function(eventsPayload) {
5503 var id, eventInstanceGroup;
5504 var eventRenderRanges;
5505 var eventFootprints;
5506 var eventSegs;
5507 var bgSegs = [];
5508 var fgSegs = [];
5509
5510 for (id in eventsPayload) {
5511 eventInstanceGroup = eventsPayload[id];
5512
5513 eventRenderRanges = eventInstanceGroup.sliceRenderRanges(this.view.activeUnzonedRange);
5514 eventFootprints = this.eventRangesToEventFootprints(eventRenderRanges);
5515 eventSegs = this.eventFootprintsToSegs(eventFootprints);
5516
5517 if (eventInstanceGroup.getEventDef().hasBgRendering()) {
5518 bgSegs.push.apply(bgSegs, // append
5519 eventSegs
5520 );
5521 }
5522 else {
5523 fgSegs.push.apply(fgSegs, // append
5524 eventSegs
5525 );
5526 }
5527 }
5528
5529 this.segs = [].concat( // record all segs
5530 this.renderBgSegs(bgSegs) || bgSegs,
5531 this.renderFgSegs(fgSegs) || fgSegs
5532 );
5533 },
5534
5535
5536 // Unrenders all events currently rendered on the grid
5537 unrenderEvents: function() {
5538 this.handleSegMouseout(); // trigger an eventMouseout if user's mouse is over an event
5539 this.clearDragListeners();
5540
5541 this.unrenderFgSegs();
5542 this.unrenderBgSegs();
5543
5544 this.segs = null;
5545 },
5546
5547
5548 // Retrieves all rendered segment objects currently rendered on the grid
5549 getEventSegs: function() {
5550 return this.segs || [];
5551 },
5552
5553
5554 // Background Segment Rendering
5555 // ---------------------------------------------------------------------------------------------------------------
5556 // TODO: move this to ChronoComponent, but without fill
5557
5558
5559 // Renders the given background event segments onto the grid.
5560 // Returns a subset of the segs that were actually rendered.
5561 renderBgSegs: function(segs) {
5562 return this.renderFill('bgEvent', segs);
5563 },
5564
5565
5566 // Unrenders all the currently rendered background event segments
5567 unrenderBgSegs: function() {
5568 this.unrenderFill('bgEvent');
5569 },
5570
5571
5572 // Renders a background event element, given the default rendering. Called by the fill system.
5573 bgEventSegEl: function(seg, el) {
5574 return this.filterEventRenderEl(seg.footprint, el);
5575 },
5576
5577
5578 // Generates an array of classNames to be used for the default rendering of a background event.
5579 // Called by fillSegHtml.
5580 bgEventSegClasses: function(seg) {
5581 var eventDef = seg.footprint.eventDef;
5582
5583 return [ 'fc-bgevent' ].concat(
5584 eventDef.className,
5585 eventDef.source.className
5586 );
5587 },
5588
5589
5590 // Generates a semicolon-separated CSS string to be used for the default rendering of a background event.
5591 // Called by fillSegHtml.
5592 bgEventSegCss: function(seg) {
5593 return {
5594 'background-color': this.getSegSkinCss(seg)['background-color']
5595 };
5596 },
5597
5598
5599 /* Rendering Utils
5600 ------------------------------------------------------------------------------------------------------------------*/
5601
5602
5603 // Compute the text that should be displayed on an event's element.
5604 // `range` can be the Event object itself, or something range-like, with at least a `start`.
5605 // If event times are disabled, or the event has no time, will return a blank string.
5606 // If not specified, formatStr will default to the eventTimeFormat setting,
5607 // and displayEnd will default to the displayEventEnd setting.
5608 getEventTimeText: function(eventFootprint, formatStr, displayEnd) {
5609 return this._getEventTimeText(
5610 eventFootprint.eventInstance.dateProfile.start,
5611 eventFootprint.eventInstance.dateProfile.end,
5612 eventFootprint.componentFootprint.isAllDay,
5613 formatStr,
5614 displayEnd
5615 );
5616 },
5617
5618
5619 _getEventTimeText: function(start, end, isAllDay, formatStr, displayEnd) {
5620
5621 if (formatStr == null) {
5622 formatStr = this.eventTimeFormat;
5623 }
5624
5625 if (displayEnd == null) {
5626 displayEnd = this.displayEventEnd;
5627 }
5628
5629 if (this.displayEventTime && !isAllDay) {
5630 if (displayEnd && end) {
5631 return this.view.formatRange(
5632 { start: start, end: end },
5633 false, // allDay
5634 formatStr
5635 );
5636 }
5637 else {
5638 return start.format(formatStr);
5639 }
5640 }
5641
5642 return '';
5643 },
5644
5645
5646 // Generic utility for generating the HTML classNames for an event segment's element
5647 getSegClasses: function(seg, isDraggable, isResizable) {
5648 var view = this.view;
5649 var classes = [
5650 'fc-event',
5651 seg.isStart ? 'fc-start' : 'fc-not-start',
5652 seg.isEnd ? 'fc-end' : 'fc-not-end'
5653 ].concat(this.getSegCustomClasses(seg));
5654
5655 if (isDraggable) {
5656 classes.push('fc-draggable');
5657 }
5658 if (isResizable) {
5659 classes.push('fc-resizable');
5660 }
5661
5662 // event is currently selected? attach a className.
5663 if (view.isEventDefSelected(seg.footprint.eventDef)) {
5664 classes.push('fc-selected');
5665 }
5666
5667 return classes;
5668 },
5669
5670
5671 // List of classes that were defined by the caller of the API in some way
5672 getSegCustomClasses: function(seg) {
5673 var eventDef = seg.footprint.eventDef;
5674
5675 return [].concat(
5676 eventDef.className, // guaranteed to be an array
5677 eventDef.source.className
5678 );
5679 },
5680
5681
5682 // Utility for generating event skin-related CSS properties
5683 getSegSkinCss: function(seg) {
5684 return {
5685 'background-color': this.getSegBackgroundColor(seg),
5686 'border-color': this.getSegBorderColor(seg),
5687 color: this.getSegTextColor(seg)
5688 };
5689 },
5690
5691
5692 // Queries for caller-specified color, then falls back to default
5693 getSegBackgroundColor: function(seg) {
5694 var eventDef = seg.footprint.eventDef;
5695
5696 return eventDef.backgroundColor ||
5697 eventDef.color ||
5698 this.getSegDefaultBackgroundColor(seg);
5699 },
5700
5701
5702 getSegDefaultBackgroundColor: function(seg) {
5703 var source = seg.footprint.eventDef.source;
5704
5705 return source.backgroundColor ||
5706 source.color ||
5707 this.opt('eventBackgroundColor') ||
5708 this.opt('eventColor');
5709 },
5710
5711
5712 // Queries for caller-specified color, then falls back to default
5713 getSegBorderColor: function(seg) {
5714 var eventDef = seg.footprint.eventDef;
5715
5716 return eventDef.borderColor ||
5717 eventDef.color ||
5718 this.getSegDefaultBorderColor(seg);
5719 },
5720
5721
5722 getSegDefaultBorderColor: function(seg) {
5723 var source = seg.footprint.eventDef.source;
5724
5725 return source.borderColor ||
5726 source.color ||
5727 this.opt('eventBorderColor') ||
5728 this.opt('eventColor');
5729 },
5730
5731
5732 // Queries for caller-specified color, then falls back to default
5733 getSegTextColor: function(seg) {
5734 var eventDef = seg.footprint.eventDef;
5735
5736 return eventDef.textColor ||
5737 this.getSegDefaultTextColor(seg);
5738 },
5739
5740
5741 getSegDefaultTextColor: function(seg) {
5742 var source = seg.footprint.eventDef.source;
5743
5744 return source.textColor ||
5745 this.opt('eventTextColor');
5746 },
5747
5748
5749 sortEventSegs: function(segs) {
5750 segs.sort(proxy(this, 'compareEventSegs'));
5751 },
5752
5753
5754 // A cmp function for determining which segments should take visual priority
5755 compareEventSegs: function(seg1, seg2) {
5756 var f1 = seg1.footprint.componentFootprint;
5757 var r1 = f1.unzonedRange;
5758 var f2 = seg2.footprint.componentFootprint;
5759 var r2 = f2.unzonedRange;
5760
5761 return r1.startMs - r2.startMs || // earlier events go first
5762 (r2.endMs - r2.startMs) - (r1.endMs - r1.startMs) || // tie? longer events go first
5763 f2.isAllDay - f1.isAllDay || // tie? put all-day events first (booleans cast to 0/1)
5764 compareByFieldSpecs(
5765 seg1.footprint.eventDef,
5766 seg2.footprint.eventDef,
5767 this.view.eventOrderSpecs
5768 );
5769 }
5770
5771 });
5772
5773 ;;
5774
5775 /*
5776 Contains:
5777 - event clicking/mouseover/mouseout
5778 - things that are common to event dragging AND resizing
5779 - event helper rendering
5780 */
5781 Grid.mixin({
5782
5783 // self-config, overridable by subclasses
5784 segSelector: '.fc-event-container > *', // what constitutes an event element?
5785
5786 mousedOverSeg: null, // the segment object the user's mouse is over. null if over nothing
5787
5788 // if defined, holds the unit identified (ex: "year" or "month") that determines the level of granularity
5789 // of the date areas. if not defined, assumes to be day and time granularity.
5790 // TODO: port isTimeScale into same system?
5791 largeUnit: null,
5792
5793
5794 // Diffs the two dates, returning a duration, based on granularity of the grid
5795 // TODO: port isTimeScale into this system?
5796 diffDates: function(a, b) {
5797 if (this.largeUnit) {
5798 return diffByUnit(a, b, this.largeUnit);
5799 }
5800 else {
5801 return diffDayTime(a, b);
5802 }
5803 },
5804
5805
5806 // Attaches event-element-related handlers for *all* rendered event segments of the view.
5807 bindSegHandlers: function() {
5808 this.bindSegHandlersToEl(this.el);
5809 },
5810
5811
5812 // Attaches event-element-related handlers to an arbitrary container element. leverages bubbling.
5813 bindSegHandlersToEl: function(el) {
5814 this.bindSegHandlerToEl(el, 'touchstart', this.handleSegTouchStart);
5815 this.bindSegHandlerToEl(el, 'mouseenter', this.handleSegMouseover);
5816 this.bindSegHandlerToEl(el, 'mouseleave', this.handleSegMouseout);
5817 this.bindSegHandlerToEl(el, 'mousedown', this.handleSegMousedown);
5818 this.bindSegHandlerToEl(el, 'click', this.handleSegClick);
5819 },
5820
5821
5822 // Executes a handler for any a user-interaction on a segment.
5823 // Handler gets called with (seg, ev), and with the `this` context of the Grid
5824 bindSegHandlerToEl: function(el, name, handler) {
5825 var _this = this;
5826
5827 el.on(name, this.segSelector, function(ev) {
5828 var seg = $(this).data('fc-seg'); // grab segment data. put there by View::renderEventsPayload
5829
5830 // only call the handlers if there is not a drag/resize in progress
5831 if (seg && !_this.isDraggingSeg && !_this.isResizingSeg) {
5832 return handler.call(_this, seg, ev); // context will be the Grid
5833 }
5834 });
5835 },
5836
5837
5838 handleSegClick: function(seg, ev) {
5839 var res = this.publiclyTrigger('eventClick', { // can return `false` to cancel
5840 context: seg.el[0],
5841 args: [ seg.footprint.getEventLegacy(), ev, this.view ]
5842 });
5843
5844 if (res === false) {
5845 ev.preventDefault();
5846 }
5847 },
5848
5849
5850 // Updates internal state and triggers handlers for when an event element is moused over
5851 handleSegMouseover: function(seg, ev) {
5852 if (
5853 !GlobalEmitter.get().shouldIgnoreMouse() &&
5854 !this.mousedOverSeg
5855 ) {
5856 this.mousedOverSeg = seg;
5857
5858 if (this.view.isEventDefResizable(seg.footprint.eventDef)) {
5859 seg.el.addClass('fc-allow-mouse-resize');
5860 }
5861
5862 this.publiclyTrigger('eventMouseover', {
5863 context: seg.el[0],
5864 args: [ seg.footprint.getEventLegacy(), ev, this.view ]
5865 });
5866 }
5867 },
5868
5869
5870 // Updates internal state and triggers handlers for when an event element is moused out.
5871 // Can be given no arguments, in which case it will mouseout the segment that was previously moused over.
5872 handleSegMouseout: function(seg, ev) {
5873 ev = ev || {}; // if given no args, make a mock mouse event
5874
5875 if (this.mousedOverSeg) {
5876 seg = seg || this.mousedOverSeg; // if given no args, use the currently moused-over segment
5877 this.mousedOverSeg = null;
5878
5879 if (this.view.isEventDefResizable(seg.footprint.eventDef)) {
5880 seg.el.removeClass('fc-allow-mouse-resize');
5881 }
5882
5883 this.publiclyTrigger('eventMouseout', {
5884 context: seg.el[0],
5885 args: [ seg.footprint.getEventLegacy(), ev, this.view ]
5886 });
5887 }
5888 },
5889
5890
5891 handleSegMousedown: function(seg, ev) {
5892 var isResizing = this.startSegResize(seg, ev, { distance: 5 });
5893
5894 if (!isResizing && this.view.isEventDefDraggable(seg.footprint.eventDef)) {
5895 this.buildSegDragListener(seg)
5896 .startInteraction(ev, {
5897 distance: 5
5898 });
5899 }
5900 },
5901
5902
5903 handleSegTouchStart: function(seg, ev) {
5904 var view = this.view;
5905 var eventDef = seg.footprint.eventDef;
5906 var isSelected = view.isEventDefSelected(eventDef);
5907 var isDraggable = view.isEventDefDraggable(eventDef);
5908 var isResizable = view.isEventDefResizable(eventDef);
5909 var isResizing = false;
5910 var dragListener;
5911 var eventLongPressDelay;
5912
5913 if (isSelected && isResizable) {
5914 // only allow resizing of the event is selected
5915 isResizing = this.startSegResize(seg, ev);
5916 }
5917
5918 if (!isResizing && (isDraggable || isResizable)) { // allowed to be selected?
5919
5920 eventLongPressDelay = this.opt('eventLongPressDelay');
5921 if (eventLongPressDelay == null) {
5922 eventLongPressDelay = this.opt('longPressDelay'); // fallback
5923 }
5924
5925 dragListener = isDraggable ?
5926 this.buildSegDragListener(seg) :
5927 this.buildSegSelectListener(seg); // seg isn't draggable, but still needs to be selected
5928
5929 dragListener.startInteraction(ev, { // won't start if already started
5930 delay: isSelected ? 0 : eventLongPressDelay // do delay if not already selected
5931 });
5932 }
5933 },
5934
5935
5936 // seg isn't draggable, but let's use a generic DragListener
5937 // simply for the delay, so it can be selected.
5938 // Has side effect of setting/unsetting `segDragListener`
5939 buildSegSelectListener: function(seg) {
5940 var _this = this;
5941 var view = this.view;
5942 var eventDef = seg.footprint.eventDef;
5943 var eventInstance = seg.footprint.eventInstance; // null for inverse-background events
5944
5945 if (this.segDragListener) {
5946 return this.segDragListener;
5947 }
5948
5949 var dragListener = this.segDragListener = new DragListener({
5950 dragStart: function(ev) {
5951 if (
5952 dragListener.isTouch &&
5953 !view.isEventDefSelected(eventDef) &&
5954 eventInstance
5955 ) {
5956 // if not previously selected, will fire after a delay. then, select the event
5957 view.selectEventInstance(eventInstance);
5958 }
5959 },
5960 interactionEnd: function(ev) {
5961 _this.segDragListener = null;
5962 }
5963 });
5964
5965 return dragListener;
5966 },
5967
5968
5969 // is it allowed, in relation to the view's validRange?
5970 // NOTE: very similar to isExternalInstanceGroupAllowed
5971 isEventInstanceGroupAllowed: function(eventInstanceGroup) {
5972 var eventFootprints = this.eventRangesToEventFootprints(eventInstanceGroup.getAllEventRanges());
5973 var i;
5974
5975 for (i = 0; i < eventFootprints.length; i++) {
5976 // TODO: just use getAllEventRanges directly
5977 if (!this.view.validUnzonedRange.containsRange(eventFootprints[i].componentFootprint.unzonedRange)) {
5978 return false;
5979 }
5980 }
5981
5982 return this.view.calendar.isEventInstanceGroupAllowed(eventInstanceGroup);
5983 },
5984
5985
5986 /* Event Helper
5987 ------------------------------------------------------------------------------------------------------------------*/
5988 // TODO: should probably move this to Grid.events, like we did event dragging / resizing
5989
5990
5991 renderHelperEventFootprints: function(eventFootprints, sourceSeg) {
5992 return this.renderHelperEventFootprintEls(eventFootprints, sourceSeg)
5993 .addClass('fc-helper');
5994 },
5995
5996
5997 renderHelperEventFootprintEls: function(eventFootprints, sourceSeg) {
5998 // Subclasses must implement.
5999 // Must return all mock event elements.
6000 },
6001
6002
6003 // Unrenders a mock event
6004 // TODO: have this in ChronoComponent
6005 unrenderHelper: function() {
6006 // subclasses must implement
6007 },
6008
6009
6010 fabricateEventFootprint: function(componentFootprint) {
6011 var calendar = this.view.calendar;
6012 var eventDateProfile = calendar.footprintToDateProfile(componentFootprint);
6013 var dummyEvent = new SingleEventDef(new EventSource(calendar));
6014 var dummyInstance;
6015
6016 dummyEvent.dateProfile = eventDateProfile;
6017 dummyInstance = dummyEvent.buildInstance();
6018
6019 return new EventFootprint(componentFootprint, dummyEvent, dummyInstance);
6020 }
6021
6022 });
6023
6024 ;;
6025
6026 /*
6027 Wired up via Grid.event-interation.js by calling
6028 buildSegDragListener
6029 */
6030 Grid.mixin({
6031
6032 isDraggingSeg: false, // is a segment being dragged? boolean
6033
6034
6035 // Builds a listener that will track user-dragging on an event segment.
6036 // Generic enough to work with any type of Grid.
6037 // Has side effect of setting/unsetting `segDragListener`
6038 buildSegDragListener: function(seg) {
6039 var _this = this;
6040 var view = this.view;
6041 var calendar = view.calendar;
6042 var eventManager = calendar.eventManager;
6043 var el = seg.el;
6044 var eventDef = seg.footprint.eventDef;
6045 var eventInstance = seg.footprint.eventInstance; // null for inverse-background events
6046 var isDragging;
6047 var mouseFollower; // A clone of the original element that will move with the mouse
6048 var eventDefMutation;
6049
6050 if (this.segDragListener) {
6051 return this.segDragListener;
6052 }
6053
6054 // Tracks mouse movement over the *view's* coordinate map. Allows dragging and dropping between subcomponents
6055 // of the view.
6056 var dragListener = this.segDragListener = new HitDragListener(view, {
6057 scroll: this.opt('dragScroll'),
6058 subjectEl: el,
6059 subjectCenter: true,
6060 interactionStart: function(ev) {
6061 seg.component = _this; // for renderDrag
6062 isDragging = false;
6063 mouseFollower = new MouseFollower(seg.el, {
6064 additionalClass: 'fc-dragging',
6065 parentEl: view.el,
6066 opacity: dragListener.isTouch ? null : _this.opt('dragOpacity'),
6067 revertDuration: _this.opt('dragRevertDuration'),
6068 zIndex: 2 // one above the .fc-view
6069 });
6070 mouseFollower.hide(); // don't show until we know this is a real drag
6071 mouseFollower.start(ev);
6072 },
6073 dragStart: function(ev) {
6074 if (
6075 dragListener.isTouch &&
6076 !view.isEventDefSelected(eventDef) &&
6077 eventInstance
6078 ) {
6079 // if not previously selected, will fire after a delay. then, select the event
6080 view.selectEventInstance(eventInstance);
6081 }
6082 isDragging = true;
6083 _this.handleSegMouseout(seg, ev); // ensure a mouseout on the manipulated event has been reported
6084 _this.segDragStart(seg, ev);
6085 view.hideEventsWithId(eventDef.id); // hide all event segments. our mouseFollower will take over
6086 },
6087 hitOver: function(hit, isOrig, origHit) {
6088 var isAllowed = true;
6089 var origFootprint;
6090 var footprint;
6091 var mutatedEventInstanceGroup;
6092 var dragHelperEls;
6093
6094 // starting hit could be forced (DayGrid.limit)
6095 if (seg.hit) {
6096 origHit = seg.hit;
6097 }
6098
6099 // hit might not belong to this grid, so query origin grid
6100 origFootprint = origHit.component.getSafeHitFootprint(origHit);
6101 footprint = hit.component.getSafeHitFootprint(hit);
6102
6103 if (origFootprint && footprint) {
6104 eventDefMutation = _this.computeEventDropMutation(origFootprint, footprint, eventDef);
6105
6106 if (eventDefMutation) {
6107 mutatedEventInstanceGroup = eventManager.buildMutatedEventInstanceGroup(
6108 eventDef.id,
6109 eventDefMutation
6110 );
6111 isAllowed = _this.isEventInstanceGroupAllowed(mutatedEventInstanceGroup);
6112 }
6113 else {
6114 isAllowed = false;
6115 }
6116 }
6117 else {
6118 isAllowed = false;
6119 }
6120
6121 if (!isAllowed) {
6122 eventDefMutation = null;
6123 disableCursor();
6124 }
6125
6126 // if a valid drop location, have the subclass render a visual indication
6127 if (
6128 eventDefMutation &&
6129 (dragHelperEls = view.renderDrag(
6130 _this.eventRangesToEventFootprints(
6131 mutatedEventInstanceGroup.sliceRenderRanges(_this.unzonedRange, calendar)
6132 ),
6133 seg
6134 ))
6135 ) {
6136 dragHelperEls.addClass('fc-dragging');
6137 if (!dragListener.isTouch) {
6138 _this.applyDragOpacity(dragHelperEls);
6139 }
6140
6141 mouseFollower.hide(); // if the subclass is already using a mock event "helper", hide our own
6142 }
6143 else {
6144 mouseFollower.show(); // otherwise, have the helper follow the mouse (no snapping)
6145 }
6146
6147 if (isOrig) {
6148 // needs to have moved hits to be a valid drop
6149 eventDefMutation = null;
6150 }
6151 },
6152 hitOut: function() { // called before mouse moves to a different hit OR moved out of all hits
6153 view.unrenderDrag(); // unrender whatever was done in renderDrag
6154 mouseFollower.show(); // show in case we are moving out of all hits
6155 eventDefMutation = null;
6156 },
6157 hitDone: function() { // Called after a hitOut OR before a dragEnd
6158 enableCursor();
6159 },
6160 interactionEnd: function(ev) {
6161 delete seg.component; // prevent side effects
6162
6163 // do revert animation if hasn't changed. calls a callback when finished (whether animation or not)
6164 mouseFollower.stop(!eventDefMutation, function() {
6165 if (isDragging) {
6166 view.unrenderDrag();
6167 _this.segDragStop(seg, ev);
6168 }
6169
6170 if (eventDefMutation) {
6171 // no need to re-show original, will rerender all anyways. esp important if eventRenderWait
6172 view.reportEventDrop(eventInstance, eventDefMutation, el, ev);
6173 }
6174 else {
6175 view.showEventsWithId(eventDef.id);
6176 }
6177 });
6178 _this.segDragListener = null;
6179 }
6180 });
6181
6182 return dragListener;
6183 },
6184
6185
6186 // Called before event segment dragging starts
6187 segDragStart: function(seg, ev) {
6188 this.isDraggingSeg = true;
6189 this.publiclyTrigger('eventDragStart', {
6190 context: seg.el[0],
6191 args: [
6192 seg.footprint.getEventLegacy(),
6193 ev,
6194 {}, // jqui dummy
6195 this.view
6196 ]
6197 });
6198 },
6199
6200
6201 // Called after event segment dragging stops
6202 segDragStop: function(seg, ev) {
6203 this.isDraggingSeg = false;
6204 this.publiclyTrigger('eventDragStop', {
6205 context: seg.el[0],
6206 args: [
6207 seg.footprint.getEventLegacy(),
6208 ev,
6209 {}, // jqui dummy
6210 this.view
6211 ]
6212 });
6213 },
6214
6215
6216 // DOES NOT consider overlap/constraint
6217 computeEventDropMutation: function(startFootprint, endFootprint, eventDef) {
6218 var date0 = startFootprint.unzonedRange.getStart();
6219 var date1 = endFootprint.unzonedRange.getStart();
6220 var clearEnd = false;
6221 var forceTimed = false;
6222 var forceAllDay = false;
6223 var dateDelta;
6224 var dateMutation;
6225 var eventDefMutation;
6226
6227 if (startFootprint.isAllDay !== endFootprint.isAllDay) {
6228 clearEnd = true;
6229
6230 if (endFootprint.isAllDay) {
6231 forceAllDay = true;
6232 date0.stripTime();
6233 }
6234 else {
6235 forceTimed = true;
6236 }
6237 }
6238
6239 dateDelta = this.diffDates(date1, date0);
6240
6241 dateMutation = new EventDefDateMutation();
6242 dateMutation.clearEnd = clearEnd;
6243 dateMutation.forceTimed = forceTimed;
6244 dateMutation.forceAllDay = forceAllDay;
6245 dateMutation.setDateDelta(dateDelta);
6246
6247 eventDefMutation = new EventDefMutation();
6248 eventDefMutation.setDateMutation(dateMutation);
6249
6250 return eventDefMutation;
6251 },
6252
6253
6254 // Utility for apply dragOpacity to a jQuery set
6255 applyDragOpacity: function(els) {
6256 var opacity = this.opt('dragOpacity');
6257
6258 if (opacity != null) {
6259 els.css('opacity', opacity);
6260 }
6261 }
6262
6263 });
6264
6265 ;;
6266
6267 /*
6268 Wired up via Grid.event-interation.js by calling
6269 startSegResize
6270 */
6271 Grid.mixin({
6272
6273 isResizingSeg: false, // is a segment being resized? boolean
6274
6275
6276 // returns boolean whether resizing actually started or not.
6277 // assumes the seg allows resizing.
6278 // `dragOptions` are optional.
6279 startSegResize: function(seg, ev, dragOptions) {
6280 if ($(ev.target).is('.fc-resizer')) {
6281 this.buildSegResizeListener(seg, $(ev.target).is('.fc-start-resizer'))
6282 .startInteraction(ev, dragOptions);
6283 return true;
6284 }
6285 return false;
6286 },
6287
6288
6289 // Creates a listener that tracks the user as they resize an event segment.
6290 // Generic enough to work with any type of Grid.
6291 buildSegResizeListener: function(seg, isStart) {
6292 var _this = this;
6293 var view = this.view;
6294 var calendar = view.calendar;
6295 var eventManager = calendar.eventManager;
6296 var el = seg.el;
6297 var eventDef = seg.footprint.eventDef;
6298 var eventInstance = seg.footprint.eventInstance;
6299 var isDragging;
6300 var resizeMutation; // zoned event date properties. falsy if invalid resize
6301
6302 // Tracks mouse movement over the *grid's* coordinate map
6303 var dragListener = this.segResizeListener = new HitDragListener(this, {
6304 scroll: this.opt('dragScroll'),
6305 subjectEl: el,
6306 interactionStart: function() {
6307 isDragging = false;
6308 },
6309 dragStart: function(ev) {
6310 isDragging = true;
6311 _this.handleSegMouseout(seg, ev); // ensure a mouseout on the manipulated event has been reported
6312 _this.segResizeStart(seg, ev);
6313 },
6314 hitOver: function(hit, isOrig, origHit) {
6315 var isAllowed = true;
6316 var origHitFootprint = _this.getSafeHitFootprint(origHit);
6317 var hitFootprint = _this.getSafeHitFootprint(hit);
6318 var mutatedEventInstanceGroup;
6319
6320 if (origHitFootprint && hitFootprint) {
6321 resizeMutation = isStart ?
6322 _this.computeEventStartResizeMutation(origHitFootprint, hitFootprint, seg.footprint) :
6323 _this.computeEventEndResizeMutation(origHitFootprint, hitFootprint, seg.footprint);
6324
6325 if (resizeMutation) {
6326 mutatedEventInstanceGroup = eventManager.buildMutatedEventInstanceGroup(
6327 eventDef.id,
6328 resizeMutation
6329 );
6330 isAllowed = _this.isEventInstanceGroupAllowed(mutatedEventInstanceGroup);
6331 }
6332 else {
6333 isAllowed = false;
6334 }
6335 }
6336 else {
6337 isAllowed = false;
6338 }
6339
6340 if (!isAllowed) {
6341 resizeMutation = null;
6342 disableCursor();
6343 }
6344 else if (resizeMutation.isEmpty()) {
6345 // no change. (FYI, event dates might have zones)
6346 resizeMutation = null;
6347 }
6348
6349 if (resizeMutation) {
6350 view.hideEventsWithId(eventDef.id);
6351
6352 _this.renderEventResize(
6353 _this.eventRangesToEventFootprints(
6354 mutatedEventInstanceGroup.sliceRenderRanges(_this.unzonedRange, calendar)
6355 ),
6356 seg
6357 );
6358 }
6359 },
6360 hitOut: function() { // called before mouse moves to a different hit OR moved out of all hits
6361 resizeMutation = null;
6362 view.showEventsWithId(eventDef.id); // for when out-of-bounds. show original
6363 },
6364 hitDone: function() { // resets the rendering to show the original event
6365 _this.unrenderEventResize();
6366 enableCursor();
6367 },
6368 interactionEnd: function(ev) {
6369 if (isDragging) {
6370 _this.segResizeStop(seg, ev);
6371 }
6372
6373 if (resizeMutation) { // valid date to resize to?
6374 // no need to re-show original, will rerender all anyways. esp important if eventRenderWait
6375 view.reportEventResize(eventInstance, resizeMutation, el, ev);
6376 }
6377 else {
6378 view.showEventsWithId(eventDef.id);
6379 }
6380 _this.segResizeListener = null;
6381 }
6382 });
6383
6384 return dragListener;
6385 },
6386
6387
6388 // Called before event segment resizing starts
6389 segResizeStart: function(seg, ev) {
6390 this.isResizingSeg = true;
6391 this.publiclyTrigger('eventResizeStart', {
6392 context: seg.el[0],
6393 args: [
6394 seg.footprint.getEventLegacy(),
6395 ev,
6396 {}, // jqui dummy
6397 this.view
6398 ]
6399 });
6400 },
6401
6402
6403 // Called after event segment resizing stops
6404 segResizeStop: function(seg, ev) {
6405 this.isResizingSeg = false;
6406 this.publiclyTrigger('eventResizeStop', {
6407 context: seg.el[0],
6408 args: [
6409 seg.footprint.getEventLegacy(),
6410 ev,
6411 {}, // jqui dummy
6412 this.view
6413 ]
6414 });
6415 },
6416
6417
6418 // Returns new date-information for an event segment being resized from its start
6419 computeEventStartResizeMutation: function(startFootprint, endFootprint, origEventFootprint) {
6420 var origRange = origEventFootprint.componentFootprint.unzonedRange;
6421 var startDelta = this.diffDates(
6422 endFootprint.unzonedRange.getStart(),
6423 startFootprint.unzonedRange.getStart()
6424 );
6425 var dateMutation;
6426 var eventDefMutation;
6427
6428 if (origRange.getStart().add(startDelta) < origRange.getEnd()) {
6429
6430 dateMutation = new EventDefDateMutation();
6431 dateMutation.setStartDelta(startDelta);
6432
6433 eventDefMutation = new EventDefMutation();
6434 eventDefMutation.setDateMutation(dateMutation);
6435
6436 return eventDefMutation;
6437 }
6438
6439 return false;
6440 },
6441
6442
6443 // Returns new date-information for an event segment being resized from its end
6444 computeEventEndResizeMutation: function(startFootprint, endFootprint, origEventFootprint) {
6445 var origRange = origEventFootprint.componentFootprint.unzonedRange;
6446 var endDelta = this.diffDates(
6447 endFootprint.unzonedRange.getEnd(),
6448 startFootprint.unzonedRange.getEnd()
6449 );
6450 var dateMutation;
6451 var eventDefMutation;
6452
6453 if (origRange.getEnd().add(endDelta) > origRange.getStart()) {
6454
6455 dateMutation = new EventDefDateMutation();
6456 dateMutation.setEndDelta(endDelta);
6457
6458 eventDefMutation = new EventDefMutation();
6459 eventDefMutation.setDateMutation(dateMutation);
6460
6461 return eventDefMutation;
6462 }
6463
6464 return false;
6465 },
6466
6467
6468 // Renders a visual indication of an event being resized.
6469 // Must return elements used for any mock events.
6470 renderEventResize: function(eventFootprints, seg) {
6471 // subclasses must implement
6472 },
6473
6474
6475 // Unrenders a visual indication of an event being resized.
6476 unrenderEventResize: function() {
6477 // subclasses must implement
6478 }
6479
6480 });
6481
6482 ;;
6483
6484 /*
6485 Wired up via Grid.js by calling
6486 externalDragStart
6487 */
6488 Grid.mixin({
6489
6490 isDraggingExternal: false, // jqui-dragging an external element? boolean
6491
6492
6493 // Called when a jQuery UI drag is initiated anywhere in the DOM
6494 externalDragStart: function(ev, ui) {
6495 var el;
6496 var accept;
6497
6498 if (this.opt('droppable')) { // only listen if this setting is on
6499 el = $((ui ? ui.item : null) || ev.target);
6500
6501 // Test that the dragged element passes the dropAccept selector or filter function.
6502 // FYI, the default is "*" (matches all)
6503 accept = this.opt('dropAccept');
6504 if ($.isFunction(accept) ? accept.call(el[0], el) : el.is(accept)) {
6505 if (!this.isDraggingExternal) { // prevent double-listening if fired twice
6506 this.listenToExternalDrag(el, ev, ui);
6507 }
6508 }
6509 }
6510 },
6511
6512
6513 // Called when a jQuery UI drag starts and it needs to be monitored for dropping
6514 listenToExternalDrag: function(el, ev, ui) {
6515 var _this = this;
6516 var view = this.view;
6517 var meta = getDraggedElMeta(el); // extra data about event drop, including possible event to create
6518 var singleEventDef; // a null value signals an unsuccessful drag
6519
6520 // listener that tracks mouse movement over date-associated pixel regions
6521 var dragListener = _this.externalDragListener = new HitDragListener(this, {
6522 interactionStart: function() {
6523 _this.isDraggingExternal = true;
6524 },
6525 hitOver: function(hit) {
6526 var isAllowed = true;
6527 var hitFootprint = hit.component.getSafeHitFootprint(hit); // hit might not belong to this grid
6528 var mutatedEventInstanceGroup;
6529
6530 if (hitFootprint) {
6531 singleEventDef = _this.computeExternalDrop(hitFootprint, meta);
6532
6533 if (singleEventDef) {
6534 mutatedEventInstanceGroup = new EventInstanceGroup(
6535 singleEventDef.buildInstances()
6536 );
6537 isAllowed = meta.eventProps ? // isEvent?
6538 _this.isEventInstanceGroupAllowed(mutatedEventInstanceGroup) :
6539 _this.isExternalInstanceGroupAllowed(mutatedEventInstanceGroup);
6540 }
6541 else {
6542 isAllowed = false;
6543 }
6544 }
6545 else {
6546 isAllowed = false;
6547 }
6548
6549 if (!isAllowed) {
6550 singleEventDef = null;
6551 disableCursor();
6552 }
6553
6554 if (singleEventDef) {
6555 _this.renderDrag( // called without a seg parameter
6556 _this.eventRangesToEventFootprints(
6557 mutatedEventInstanceGroup.sliceRenderRanges(_this.unzonedRange, view.calendar)
6558 )
6559 );
6560 }
6561 },
6562 hitOut: function() {
6563 singleEventDef = null; // signal unsuccessful
6564 },
6565 hitDone: function() { // Called after a hitOut OR before a dragEnd
6566 enableCursor();
6567 _this.unrenderDrag();
6568 },
6569 interactionEnd: function(ev) {
6570
6571 if (singleEventDef) { // element was dropped on a valid hit
6572 view.reportExternalDrop(
6573 singleEventDef,
6574 Boolean(meta.eventProps), // isEvent
6575 Boolean(meta.stick), // isSticky
6576 el, ev, ui
6577 );
6578 }
6579
6580 _this.isDraggingExternal = false;
6581 _this.externalDragListener = null;
6582 }
6583 });
6584
6585 dragListener.startDrag(ev); // start listening immediately
6586 },
6587
6588
6589 // Given a hit to be dropped upon, and misc data associated with the jqui drag (guaranteed to be a plain object),
6590 // returns the zoned start/end dates for the event that would result from the hypothetical drop. end might be null.
6591 // Returning a null value signals an invalid drop hit.
6592 // DOES NOT consider overlap/constraint.
6593 // Assumes both footprints are non-open-ended.
6594 computeExternalDrop: function(componentFootprint, meta) {
6595 var calendar = this.view.calendar;
6596 var start = FC.moment.utc(componentFootprint.unzonedRange.startMs).stripZone();
6597 var end;
6598 var eventDef;
6599
6600 if (componentFootprint.isAllDay) {
6601 // if dropped on an all-day span, and element's metadata specified a time, set it
6602 if (meta.startTime) {
6603 start.time(meta.startTime);
6604 }
6605 else {
6606 start.stripTime();
6607 }
6608 }
6609
6610 if (meta.duration) {
6611 end = start.clone().add(meta.duration);
6612 }
6613
6614 start = calendar.applyTimezone(start);
6615
6616 if (end) {
6617 end = calendar.applyTimezone(end);
6618 }
6619
6620 eventDef = SingleEventDef.parse(
6621 $.extend({}, meta.eventProps, {
6622 start: start,
6623 end: end
6624 }),
6625 new EventSource(calendar)
6626 );
6627
6628 return eventDef;
6629 },
6630
6631
6632 // NOTE: very similar to isEventInstanceGroupAllowed
6633 // when it's a completely anonymous external drag, no event.
6634 isExternalInstanceGroupAllowed: function(eventInstanceGroup) {
6635 var calendar = this.view.calendar;
6636 var eventFootprints = this.eventRangesToEventFootprints(eventInstanceGroup.getAllEventRanges());
6637 var i;
6638
6639 for (i = 0; i < eventFootprints.length; i++) {
6640 if (!this.view.validUnzonedRange.containsRange(eventFootprints[i].componentFootprint.unzonedRange)) {
6641 return false;
6642 }
6643 }
6644
6645 for (i = 0; i < eventFootprints.length; i++) {
6646 // treat it as a selection
6647 // TODO: pass in eventInstanceGroup instead
6648 // because we don't want calendar's constraint system to depend on a component's
6649 // determination of footprints.
6650 if (!calendar.isSelectionFootprintAllowed(eventFootprints[i].componentFootprint)) {
6651 return false;
6652 }
6653 }
6654
6655 return true;
6656 }
6657
6658 });
6659
6660
6661 /* External-Dragging-Element Data
6662 ----------------------------------------------------------------------------------------------------------------------*/
6663
6664 // Require all HTML5 data-* attributes used by FullCalendar to have this prefix.
6665 // A value of '' will query attributes like data-event. A value of 'fc' will query attributes like data-fc-event.
6666 FC.dataAttrPrefix = '';
6667
6668 // Given a jQuery element that might represent a dragged FullCalendar event, returns an intermediate data structure
6669 // to be used for Event Object creation.
6670 // A defined `.eventProps`, even when empty, indicates that an event should be created.
6671 function getDraggedElMeta(el) {
6672 var prefix = FC.dataAttrPrefix;
6673 var eventProps; // properties for creating the event, not related to date/time
6674 var startTime; // a Duration
6675 var duration;
6676 var stick;
6677
6678 if (prefix) { prefix += '-'; }
6679 eventProps = el.data(prefix + 'event') || null;
6680
6681 if (eventProps) {
6682 if (typeof eventProps === 'object') {
6683 eventProps = $.extend({}, eventProps); // make a copy
6684 }
6685 else { // something like 1 or true. still signal event creation
6686 eventProps = {};
6687 }
6688
6689 // pluck special-cased date/time properties
6690 startTime = eventProps.start;
6691 if (startTime == null) { startTime = eventProps.time; } // accept 'time' as well
6692 duration = eventProps.duration;
6693 stick = eventProps.stick;
6694 delete eventProps.start;
6695 delete eventProps.time;
6696 delete eventProps.duration;
6697 delete eventProps.stick;
6698 }
6699
6700 // fallback to standalone attribute values for each of the date/time properties
6701 if (startTime == null) { startTime = el.data(prefix + 'start'); }
6702 if (startTime == null) { startTime = el.data(prefix + 'time'); } // accept 'time' as well
6703 if (duration == null) { duration = el.data(prefix + 'duration'); }
6704 if (stick == null) { stick = el.data(prefix + 'stick'); }
6705
6706 // massage into correct data types
6707 startTime = startTime != null ? moment.duration(startTime) : null;
6708 duration = duration != null ? moment.duration(duration) : null;
6709 stick = Boolean(stick);
6710
6711 return { eventProps: eventProps, startTime: startTime, duration: duration, stick: stick };
6712 }
6713
6714 ;;
6715
6716 Grid.mixin({
6717
6718 /* Fill System (highlight, background events, business hours)
6719 --------------------------------------------------------------------------------------------------------------------
6720 TODO: remove this system. like we did in TimeGrid
6721 */
6722
6723
6724 elsByFill: null, // a hash of jQuery element sets used for rendering each fill. Keyed by fill name.
6725
6726
6727 initFillInternals: function() {
6728 this.elsByFill = {};
6729 },
6730
6731
6732 // Renders a set of rectangles over the given segments of time.
6733 // MUST RETURN a subset of segs, the segs that were actually rendered.
6734 // Responsible for populating this.elsByFill. TODO: better API for expressing this requirement
6735 renderFill: function(type, segs) {
6736 // subclasses must implement
6737 },
6738
6739
6740 // Unrenders a specific type of fill that is currently rendered on the grid
6741 unrenderFill: function(type) {
6742 var el = this.elsByFill[type];
6743
6744 if (el) {
6745 el.remove();
6746 delete this.elsByFill[type];
6747 }
6748 },
6749
6750
6751 // Renders and assigns an `el` property for each fill segment. Generic enough to work with different types.
6752 // Only returns segments that successfully rendered.
6753 // To be harnessed by renderFill (implemented by subclasses).
6754 // Analagous to renderFgSegEls.
6755 renderFillSegEls: function(type, segs) {
6756 var _this = this;
6757 var segElMethod = this[type + 'SegEl'];
6758 var html = '';
6759 var renderedSegs = [];
6760 var i;
6761
6762 if (segs.length) {
6763
6764 // build a large concatenation of segment HTML
6765 for (i = 0; i < segs.length; i++) {
6766 html += this.fillSegHtml(type, segs[i]);
6767 }
6768
6769 // Grab individual elements from the combined HTML string. Use each as the default rendering.
6770 // Then, compute the 'el' for each segment.
6771 $(html).each(function(i, node) {
6772 var seg = segs[i];
6773 var el = $(node);
6774
6775 // allow custom filter methods per-type
6776 if (segElMethod) {
6777 el = segElMethod.call(_this, seg, el);
6778 }
6779
6780 if (el) { // custom filters did not cancel the render
6781 el = $(el); // allow custom filter to return raw DOM node
6782
6783 // correct element type? (would be bad if a non-TD were inserted into a table for example)
6784 if (el.is(_this.fillSegTag)) {
6785 seg.el = el;
6786 renderedSegs.push(seg);
6787 }
6788 }
6789 });
6790 }
6791
6792 return renderedSegs;
6793 },
6794
6795
6796 fillSegTag: 'div', // subclasses can override
6797
6798
6799 // Builds the HTML needed for one fill segment. Generic enough to work with different types.
6800 fillSegHtml: function(type, seg) {
6801
6802 // custom hooks per-type
6803 var classesMethod = this[type + 'SegClasses'];
6804 var cssMethod = this[type + 'SegCss'];
6805
6806 var classes = classesMethod ? classesMethod.call(this, seg) : [];
6807 var css = cssToStr(cssMethod ? cssMethod.call(this, seg) : {});
6808
6809 return '<' + this.fillSegTag +
6810 (classes.length ? ' class="' + classes.join(' ') + '"' : '') +
6811 (css ? ' style="' + css + '"' : '') +
6812 ' />';
6813 },
6814
6815
6816 // Generates an array of classNames for rendering the highlight. Used by the fill system.
6817 highlightSegClasses: function() {
6818 return [ 'fc-highlight' ];
6819 }
6820
6821 });
6822
6823 ;;
6824
6825 /*
6826 A set of rendering and date-related methods for a visual component comprised of one or more rows of day columns.
6827 Prerequisite: the object being mixed into needs to be a *Grid*
6828 */
6829 var DayTableMixin = FC.DayTableMixin = {
6830
6831 breakOnWeeks: false, // should create a new row for each week?
6832 dayDates: null, // whole-day dates for each column. left to right
6833 dayIndices: null, // for each day from start, the offset
6834 daysPerRow: null,
6835 rowCnt: null,
6836 colCnt: null,
6837 colHeadFormat: null,
6838
6839
6840 // Populates internal variables used for date calculation and rendering
6841 updateDayTable: function() {
6842 var view = this.view;
6843 var calendar = view.calendar;
6844 var date = calendar.msToUtcMoment(this.unzonedRange.startMs, true);
6845 var end = calendar.msToUtcMoment(this.unzonedRange.endMs, true);
6846 var dayIndex = -1;
6847 var dayIndices = [];
6848 var dayDates = [];
6849 var daysPerRow;
6850 var firstDay;
6851 var rowCnt;
6852
6853 while (date.isBefore(end)) { // loop each day from start to end
6854 if (view.isHiddenDay(date)) {
6855 dayIndices.push(dayIndex + 0.5); // mark that it's between indices
6856 }
6857 else {
6858 dayIndex++;
6859 dayIndices.push(dayIndex);
6860 dayDates.push(date.clone());
6861 }
6862 date.add(1, 'days');
6863 }
6864
6865 if (this.breakOnWeeks) {
6866 // count columns until the day-of-week repeats
6867 firstDay = dayDates[0].day();
6868 for (daysPerRow = 1; daysPerRow < dayDates.length; daysPerRow++) {
6869 if (dayDates[daysPerRow].day() == firstDay) {
6870 break;
6871 }
6872 }
6873 rowCnt = Math.ceil(dayDates.length / daysPerRow);
6874 }
6875 else {
6876 rowCnt = 1;
6877 daysPerRow = dayDates.length;
6878 }
6879
6880 this.dayDates = dayDates;
6881 this.dayIndices = dayIndices;
6882 this.daysPerRow = daysPerRow;
6883 this.rowCnt = rowCnt;
6884
6885 this.updateDayTableCols();
6886 },
6887
6888
6889 // Computes and assigned the colCnt property and updates any options that may be computed from it
6890 updateDayTableCols: function() {
6891 this.colCnt = this.computeColCnt();
6892 this.colHeadFormat = this.opt('columnFormat') || this.computeColHeadFormat();
6893 },
6894
6895
6896 // Determines how many columns there should be in the table
6897 computeColCnt: function() {
6898 return this.daysPerRow;
6899 },
6900
6901
6902 // Computes the ambiguously-timed moment for the given cell
6903 getCellDate: function(row, col) {
6904 return this.dayDates[
6905 this.getCellDayIndex(row, col)
6906 ].clone();
6907 },
6908
6909
6910 // Computes the ambiguously-timed date range for the given cell
6911 getCellRange: function(row, col) {
6912 var start = this.getCellDate(row, col);
6913 var end = start.clone().add(1, 'days');
6914
6915 return { start: start, end: end };
6916 },
6917
6918
6919 // Returns the number of day cells, chronologically, from the first of the grid (0-based)
6920 getCellDayIndex: function(row, col) {
6921 return row * this.daysPerRow + this.getColDayIndex(col);
6922 },
6923
6924
6925 // Returns the numner of day cells, chronologically, from the first cell in *any given row*
6926 getColDayIndex: function(col) {
6927 if (this.isRTL) {
6928 return this.colCnt - 1 - col;
6929 }
6930 else {
6931 return col;
6932 }
6933 },
6934
6935
6936 // Given a date, returns its chronolocial cell-index from the first cell of the grid.
6937 // If the date lies between cells (because of hiddenDays), returns a floating-point value between offsets.
6938 // If before the first offset, returns a negative number.
6939 // If after the last offset, returns an offset past the last cell offset.
6940 // Only works for *start* dates of cells. Will not work for exclusive end dates for cells.
6941 getDateDayIndex: function(date) {
6942 var dayIndices = this.dayIndices;
6943 var dayOffset = date.diff(this.dayDates[0], 'days');
6944
6945 if (dayOffset < 0) {
6946 return dayIndices[0] - 1;
6947 }
6948 else if (dayOffset >= dayIndices.length) {
6949 return dayIndices[dayIndices.length - 1] + 1;
6950 }
6951 else {
6952 return dayIndices[dayOffset];
6953 }
6954 },
6955
6956
6957 /* Options
6958 ------------------------------------------------------------------------------------------------------------------*/
6959
6960
6961 // Computes a default column header formatting string if `colFormat` is not explicitly defined
6962 computeColHeadFormat: function() {
6963 // if more than one week row, or if there are a lot of columns with not much space,
6964 // put just the day numbers will be in each cell
6965 if (this.rowCnt > 1 || this.colCnt > 10) {
6966 return 'ddd'; // "Sat"
6967 }
6968 // multiple days, so full single date string WON'T be in title text
6969 else if (this.colCnt > 1) {
6970 return this.opt('dayOfMonthFormat'); // "Sat 12/10"
6971 }
6972 // single day, so full single date string will probably be in title text
6973 else {
6974 return 'dddd'; // "Saturday"
6975 }
6976 },
6977
6978
6979 /* Slicing
6980 ------------------------------------------------------------------------------------------------------------------*/
6981
6982
6983 // Slices up a date range into a segment for every week-row it intersects with
6984 sliceRangeByRow: function(unzonedRange) {
6985 var daysPerRow = this.daysPerRow;
6986 var normalRange = this.view.computeDayRange(unzonedRange); // make whole-day range, considering nextDayThreshold
6987 var rangeFirst = this.getDateDayIndex(normalRange.start); // inclusive first index
6988 var rangeLast = this.getDateDayIndex(normalRange.end.clone().subtract(1, 'days')); // inclusive last index
6989 var segs = [];
6990 var row;
6991 var rowFirst, rowLast; // inclusive day-index range for current row
6992 var segFirst, segLast; // inclusive day-index range for segment
6993
6994 for (row = 0; row < this.rowCnt; row++) {
6995 rowFirst = row * daysPerRow;
6996 rowLast = rowFirst + daysPerRow - 1;
6997
6998 // intersect segment's offset range with the row's
6999 segFirst = Math.max(rangeFirst, rowFirst);
7000 segLast = Math.min(rangeLast, rowLast);
7001
7002 // deal with in-between indices
7003 segFirst = Math.ceil(segFirst); // in-between starts round to next cell
7004 segLast = Math.floor(segLast); // in-between ends round to prev cell
7005
7006 if (segFirst <= segLast) { // was there any intersection with the current row?
7007 segs.push({
7008 row: row,
7009
7010 // normalize to start of row
7011 firstRowDayIndex: segFirst - rowFirst,
7012 lastRowDayIndex: segLast - rowFirst,
7013
7014 // must be matching integers to be the segment's start/end
7015 isStart: segFirst === rangeFirst,
7016 isEnd: segLast === rangeLast
7017 });
7018 }
7019 }
7020
7021 return segs;
7022 },
7023
7024
7025 // Slices up a date range into a segment for every day-cell it intersects with.
7026 // TODO: make more DRY with sliceRangeByRow somehow.
7027 sliceRangeByDay: function(unzonedRange) {
7028 var daysPerRow = this.daysPerRow;
7029 var normalRange = this.view.computeDayRange(unzonedRange); // make whole-day range, considering nextDayThreshold
7030 var rangeFirst = this.getDateDayIndex(normalRange.start); // inclusive first index
7031 var rangeLast = this.getDateDayIndex(normalRange.end.clone().subtract(1, 'days')); // inclusive last index
7032 var segs = [];
7033 var row;
7034 var rowFirst, rowLast; // inclusive day-index range for current row
7035 var i;
7036 var segFirst, segLast; // inclusive day-index range for segment
7037
7038 for (row = 0; row < this.rowCnt; row++) {
7039 rowFirst = row * daysPerRow;
7040 rowLast = rowFirst + daysPerRow - 1;
7041
7042 for (i = rowFirst; i <= rowLast; i++) {
7043
7044 // intersect segment's offset range with the row's
7045 segFirst = Math.max(rangeFirst, i);
7046 segLast = Math.min(rangeLast, i);
7047
7048 // deal with in-between indices
7049 segFirst = Math.ceil(segFirst); // in-between starts round to next cell
7050 segLast = Math.floor(segLast); // in-between ends round to prev cell
7051
7052 if (segFirst <= segLast) { // was there any intersection with the current row?
7053 segs.push({
7054 row: row,
7055
7056 // normalize to start of row
7057 firstRowDayIndex: segFirst - rowFirst,
7058 lastRowDayIndex: segLast - rowFirst,
7059
7060 // must be matching integers to be the segment's start/end
7061 isStart: segFirst === rangeFirst,
7062 isEnd: segLast === rangeLast
7063 });
7064 }
7065 }
7066 }
7067
7068 return segs;
7069 },
7070
7071
7072 /* Header Rendering
7073 ------------------------------------------------------------------------------------------------------------------*/
7074
7075
7076 renderHeadHtml: function() {
7077 var theme = this.view.calendar.theme;
7078
7079 return '' +
7080 '<div class="fc-row ' + theme.getClass('headerRow') + '">' +
7081 '<table class="' + theme.getClass('tableGrid') + '">' +
7082 '<thead>' +
7083 this.renderHeadTrHtml() +
7084 '</thead>' +
7085 '</table>' +
7086 '</div>';
7087 },
7088
7089
7090 renderHeadIntroHtml: function() {
7091 return this.renderIntroHtml(); // fall back to generic
7092 },
7093
7094
7095 renderHeadTrHtml: function() {
7096 return '' +
7097 '<tr>' +
7098 (this.isRTL ? '' : this.renderHeadIntroHtml()) +
7099 this.renderHeadDateCellsHtml() +
7100 (this.isRTL ? this.renderHeadIntroHtml() : '') +
7101 '</tr>';
7102 },
7103
7104
7105 renderHeadDateCellsHtml: function() {
7106 var htmls = [];
7107 var col, date;
7108
7109 for (col = 0; col < this.colCnt; col++) {
7110 date = this.getCellDate(0, col);
7111 htmls.push(this.renderHeadDateCellHtml(date));
7112 }
7113
7114 return htmls.join('');
7115 },
7116
7117
7118 // TODO: when internalApiVersion, accept an object for HTML attributes
7119 // (colspan should be no different)
7120 renderHeadDateCellHtml: function(date, colspan, otherAttrs) {
7121 var view = this.view;
7122 var isDateValid = view.activeUnzonedRange.containsDate(date); // TODO: called too frequently. cache somehow.
7123 var classNames = [
7124 'fc-day-header',
7125 view.calendar.theme.getClass('widgetHeader')
7126 ];
7127 var innerHtml = htmlEscape(date.format(this.colHeadFormat));
7128
7129 // if only one row of days, the classNames on the header can represent the specific days beneath
7130 if (this.rowCnt === 1) {
7131 classNames = classNames.concat(
7132 // includes the day-of-week class
7133 // noThemeHighlight=true (don't highlight the header)
7134 this.getDayClasses(date, true)
7135 );
7136 }
7137 else {
7138 classNames.push('fc-' + dayIDs[date.day()]); // only add the day-of-week class
7139 }
7140
7141 return '' +
7142 '<th class="' + classNames.join(' ') + '"' +
7143 ((isDateValid && this.rowCnt) === 1 ?
7144 ' data-date="' + date.format('YYYY-MM-DD') + '"' :
7145 '') +
7146 (colspan > 1 ?
7147 ' colspan="' + colspan + '"' :
7148 '') +
7149 (otherAttrs ?
7150 ' ' + otherAttrs :
7151 '') +
7152 '>' +
7153 (isDateValid ?
7154 // don't make a link if the heading could represent multiple days, or if there's only one day (forceOff)
7155 view.buildGotoAnchorHtml(
7156 { date: date, forceOff: this.rowCnt > 1 || this.colCnt === 1 },
7157 innerHtml
7158 ) :
7159 // if not valid, display text, but no link
7160 innerHtml
7161 ) +
7162 '</th>';
7163 },
7164
7165
7166 /* Background Rendering
7167 ------------------------------------------------------------------------------------------------------------------*/
7168
7169
7170 renderBgTrHtml: function(row) {
7171 return '' +
7172 '<tr>' +
7173 (this.isRTL ? '' : this.renderBgIntroHtml(row)) +
7174 this.renderBgCellsHtml(row) +
7175 (this.isRTL ? this.renderBgIntroHtml(row) : '') +
7176 '</tr>';
7177 },
7178
7179
7180 renderBgIntroHtml: function(row) {
7181 return this.renderIntroHtml(); // fall back to generic
7182 },
7183
7184
7185 renderBgCellsHtml: function(row) {
7186 var htmls = [];
7187 var col, date;
7188
7189 for (col = 0; col < this.colCnt; col++) {
7190 date = this.getCellDate(row, col);
7191 htmls.push(this.renderBgCellHtml(date));
7192 }
7193
7194 return htmls.join('');
7195 },
7196
7197
7198 renderBgCellHtml: function(date, otherAttrs) {
7199 var view = this.view;
7200 var isDateValid = view.activeUnzonedRange.containsDate(date); // TODO: called too frequently. cache somehow.
7201 var classes = this.getDayClasses(date);
7202
7203 classes.unshift('fc-day', view.calendar.theme.getClass('widgetContent'));
7204
7205 return '<td class="' + classes.join(' ') + '"' +
7206 (isDateValid ?
7207 ' data-date="' + date.format('YYYY-MM-DD') + '"' : // if date has a time, won't format it
7208 '') +
7209 (otherAttrs ?
7210 ' ' + otherAttrs :
7211 '') +
7212 '></td>';
7213 },
7214
7215
7216 /* Generic
7217 ------------------------------------------------------------------------------------------------------------------*/
7218
7219
7220 // Generates the default HTML intro for any row. User classes should override
7221 renderIntroHtml: function() {
7222 },
7223
7224
7225 // TODO: a generic method for dealing with <tr>, RTL, intro
7226 // when increment internalApiVersion
7227 // wrapTr (scheduler)
7228
7229
7230 /* Utils
7231 ------------------------------------------------------------------------------------------------------------------*/
7232
7233
7234 // Applies the generic "intro" and "outro" HTML to the given cells.
7235 // Intro means the leftmost cell when the calendar is LTR and the rightmost cell when RTL. Vice-versa for outro.
7236 bookendCells: function(trEl) {
7237 var introHtml = this.renderIntroHtml();
7238
7239 if (introHtml) {
7240 if (this.isRTL) {
7241 trEl.append(introHtml);
7242 }
7243 else {
7244 trEl.prepend(introHtml);
7245 }
7246 }
7247 }
7248
7249 };
7250
7251 ;;
7252
7253 /* A component that renders a grid of whole-days that runs horizontally. There can be multiple rows, one per week.
7254 ----------------------------------------------------------------------------------------------------------------------*/
7255
7256 var DayGrid = FC.DayGrid = Grid.extend(DayTableMixin, {
7257
7258 numbersVisible: false, // should render a row for day/week numbers? set by outside view. TODO: make internal
7259 bottomCoordPadding: 0, // hack for extending the hit area for the last row of the coordinate grid
7260
7261 rowEls: null, // set of fake row elements
7262 cellEls: null, // set of whole-day elements comprising the row's background
7263 helperEls: null, // set of cell skeleton elements for rendering the mock event "helper"
7264
7265 rowCoordCache: null,
7266 colCoordCache: null,
7267
7268
7269 // Renders the rows and columns into the component's `this.el`, which should already be assigned.
7270 // isRigid determins whether the individual rows should ignore the contents and be a constant height.
7271 // Relies on the view's colCnt and rowCnt. In the future, this component should probably be self-sufficient.
7272 renderDates: function(isRigid) {
7273 var view = this.view;
7274 var rowCnt = this.rowCnt;
7275 var colCnt = this.colCnt;
7276 var html = '';
7277 var row;
7278 var col;
7279
7280 for (row = 0; row < rowCnt; row++) {
7281 html += this.renderDayRowHtml(row, isRigid);
7282 }
7283 this.el.html(html);
7284
7285 this.rowEls = this.el.find('.fc-row');
7286 this.cellEls = this.el.find('.fc-day, .fc-disabled-day');
7287
7288 this.rowCoordCache = new CoordCache({
7289 els: this.rowEls,
7290 isVertical: true
7291 });
7292 this.colCoordCache = new CoordCache({
7293 els: this.cellEls.slice(0, this.colCnt), // only the first row
7294 isHorizontal: true
7295 });
7296
7297 // trigger dayRender with each cell's element
7298 for (row = 0; row < rowCnt; row++) {
7299 for (col = 0; col < colCnt; col++) {
7300 this.publiclyTrigger('dayRender', {
7301 context: view,
7302 args: [
7303 this.getCellDate(row, col),
7304 this.getCellEl(row, col),
7305 view
7306 ]
7307 });
7308 }
7309 }
7310 },
7311
7312
7313 unrenderDates: function() {
7314 this.removeSegPopover();
7315 },
7316
7317
7318 renderBusinessHours: function() {
7319 var segs = this.buildBusinessHourSegs(true); // wholeDay=true
7320 this.renderFill('businessHours', segs, 'bgevent');
7321 },
7322
7323
7324 unrenderBusinessHours: function() {
7325 this.unrenderFill('businessHours');
7326 },
7327
7328
7329 // Generates the HTML for a single row, which is a div that wraps a table.
7330 // `row` is the row number.
7331 renderDayRowHtml: function(row, isRigid) {
7332 var theme = this.view.calendar.theme;
7333 var classes = [ 'fc-row', 'fc-week', theme.getClass('dayRow') ];
7334
7335 if (isRigid) {
7336 classes.push('fc-rigid');
7337 }
7338
7339 return '' +
7340 '<div class="' + classes.join(' ') + '">' +
7341 '<div class="fc-bg">' +
7342 '<table class="' + theme.getClass('tableGrid') + '">' +
7343 this.renderBgTrHtml(row) +
7344 '</table>' +
7345 '</div>' +
7346 '<div class="fc-content-skeleton">' +
7347 '<table>' +
7348 (this.numbersVisible ?
7349 '<thead>' +
7350 this.renderNumberTrHtml(row) +
7351 '</thead>' :
7352 ''
7353 ) +
7354 '</table>' +
7355 '</div>' +
7356 '</div>';
7357 },
7358
7359
7360 /* Grid Number Rendering
7361 ------------------------------------------------------------------------------------------------------------------*/
7362
7363
7364 renderNumberTrHtml: function(row) {
7365 return '' +
7366 '<tr>' +
7367 (this.isRTL ? '' : this.renderNumberIntroHtml(row)) +
7368 this.renderNumberCellsHtml(row) +
7369 (this.isRTL ? this.renderNumberIntroHtml(row) : '') +
7370 '</tr>';
7371 },
7372
7373
7374 renderNumberIntroHtml: function(row) {
7375 return this.renderIntroHtml();
7376 },
7377
7378
7379 renderNumberCellsHtml: function(row) {
7380 var htmls = [];
7381 var col, date;
7382
7383 for (col = 0; col < this.colCnt; col++) {
7384 date = this.getCellDate(row, col);
7385 htmls.push(this.renderNumberCellHtml(date));
7386 }
7387
7388 return htmls.join('');
7389 },
7390
7391
7392 // Generates the HTML for the <td>s of the "number" row in the DayGrid's content skeleton.
7393 // The number row will only exist if either day numbers or week numbers are turned on.
7394 renderNumberCellHtml: function(date) {
7395 var view = this.view;
7396 var html = '';
7397 var isDateValid = view.activeUnzonedRange.containsDate(date); // TODO: called too frequently. cache somehow.
7398 var isDayNumberVisible = view.dayNumbersVisible && isDateValid;
7399 var classes;
7400 var weekCalcFirstDoW;
7401
7402 if (!isDayNumberVisible && !view.cellWeekNumbersVisible) {
7403 // no numbers in day cell (week number must be along the side)
7404 return '<td/>'; // will create an empty space above events :(
7405 }
7406
7407 classes = this.getDayClasses(date);
7408 classes.unshift('fc-day-top');
7409
7410 if (view.cellWeekNumbersVisible) {
7411 // To determine the day of week number change under ISO, we cannot
7412 // rely on moment.js methods such as firstDayOfWeek() or weekday(),
7413 // because they rely on the locale's dow (possibly overridden by
7414 // our firstDay option), which may not be Monday. We cannot change
7415 // dow, because that would affect the calendar start day as well.
7416 if (date._locale._fullCalendar_weekCalc === 'ISO') {
7417 weekCalcFirstDoW = 1; // Monday by ISO 8601 definition
7418 }
7419 else {
7420 weekCalcFirstDoW = date._locale.firstDayOfWeek();
7421 }
7422 }
7423
7424 html += '<td class="' + classes.join(' ') + '"' +
7425 (isDateValid ?
7426 ' data-date="' + date.format() + '"' :
7427 ''
7428 ) +
7429 '>';
7430
7431 if (view.cellWeekNumbersVisible && (date.day() == weekCalcFirstDoW)) {
7432 html += view.buildGotoAnchorHtml(
7433 { date: date, type: 'week' },
7434 { 'class': 'fc-week-number' },
7435 date.format('w') // inner HTML
7436 );
7437 }
7438
7439 if (isDayNumberVisible) {
7440 html += view.buildGotoAnchorHtml(
7441 date,
7442 { 'class': 'fc-day-number' },
7443 date.date() // inner HTML
7444 );
7445 }
7446
7447 html += '</td>';
7448
7449 return html;
7450 },
7451
7452
7453 /* Options
7454 ------------------------------------------------------------------------------------------------------------------*/
7455
7456
7457 // Computes a default event time formatting string if `timeFormat` is not explicitly defined
7458 computeEventTimeFormat: function() {
7459 return this.opt('extraSmallTimeFormat'); // like "6p" or "6:30p"
7460 },
7461
7462
7463 // Computes a default `displayEventEnd` value if one is not expliclty defined
7464 computeDisplayEventEnd: function() {
7465 return this.colCnt == 1; // we'll likely have space if there's only one day
7466 },
7467
7468
7469 /* Dates
7470 ------------------------------------------------------------------------------------------------------------------*/
7471
7472
7473 rangeUpdated: function() {
7474 this.updateDayTable();
7475 },
7476
7477
7478 // Slices up the given span (unzoned start/end with other misc data) into an array of segments
7479 componentFootprintToSegs: function(componentFootprint) {
7480 var segs = this.sliceRangeByRow(componentFootprint.unzonedRange);
7481 var i, seg;
7482
7483 for (i = 0; i < segs.length; i++) {
7484 seg = segs[i];
7485
7486 if (this.isRTL) {
7487 seg.leftCol = this.daysPerRow - 1 - seg.lastRowDayIndex;
7488 seg.rightCol = this.daysPerRow - 1 - seg.firstRowDayIndex;
7489 }
7490 else {
7491 seg.leftCol = seg.firstRowDayIndex;
7492 seg.rightCol = seg.lastRowDayIndex;
7493 }
7494 }
7495
7496 return segs;
7497 },
7498
7499
7500 /* Hit System
7501 ------------------------------------------------------------------------------------------------------------------*/
7502
7503
7504 prepareHits: function() {
7505 this.colCoordCache.build();
7506 this.rowCoordCache.build();
7507 this.rowCoordCache.bottoms[this.rowCnt - 1] += this.bottomCoordPadding; // hack
7508 },
7509
7510
7511 releaseHits: function() {
7512 this.colCoordCache.clear();
7513 this.rowCoordCache.clear();
7514 },
7515
7516
7517 queryHit: function(leftOffset, topOffset) {
7518 if (this.colCoordCache.isLeftInBounds(leftOffset) && this.rowCoordCache.isTopInBounds(topOffset)) {
7519 var col = this.colCoordCache.getHorizontalIndex(leftOffset);
7520 var row = this.rowCoordCache.getVerticalIndex(topOffset);
7521
7522 if (row != null && col != null) {
7523 return this.getCellHit(row, col);
7524 }
7525 }
7526 },
7527
7528
7529 getHitFootprint: function(hit) {
7530 var range = this.getCellRange(hit.row, hit.col);
7531
7532 return new ComponentFootprint(
7533 new UnzonedRange(range.start, range.end),
7534 true // all-day?
7535 );
7536 },
7537
7538
7539 getHitEl: function(hit) {
7540 return this.getCellEl(hit.row, hit.col);
7541 },
7542
7543
7544 /* Cell System
7545 ------------------------------------------------------------------------------------------------------------------*/
7546 // FYI: the first column is the leftmost column, regardless of date
7547
7548
7549 getCellHit: function(row, col) {
7550 return {
7551 row: row,
7552 col: col,
7553 component: this, // needed unfortunately :(
7554 left: this.colCoordCache.getLeftOffset(col),
7555 right: this.colCoordCache.getRightOffset(col),
7556 top: this.rowCoordCache.getTopOffset(row),
7557 bottom: this.rowCoordCache.getBottomOffset(row)
7558 };
7559 },
7560
7561
7562 getCellEl: function(row, col) {
7563 return this.cellEls.eq(row * this.colCnt + col);
7564 },
7565
7566
7567 /* Event Drag Visualization
7568 ------------------------------------------------------------------------------------------------------------------*/
7569 // TODO: move to DayGrid.event, similar to what we did with Grid's drag methods
7570
7571
7572 // Renders a visual indication of an event or external element being dragged.
7573 // `eventLocation` has zoned start and end (optional)
7574 renderDrag: function(eventFootprints, seg) {
7575 var i;
7576
7577 for (i = 0; i < eventFootprints.length; i++) {
7578 this.renderHighlight(eventFootprints[i].componentFootprint);
7579 }
7580
7581 // if a segment from the same calendar but another component is being dragged, render a helper event
7582 if (seg && seg.component !== this) {
7583 return this.renderHelperEventFootprints(eventFootprints, seg); // returns mock event elements
7584 }
7585 },
7586
7587
7588 // Unrenders any visual indication of a hovering event
7589 unrenderDrag: function() {
7590 this.unrenderHighlight();
7591 this.unrenderHelper();
7592 },
7593
7594
7595 /* Event Resize Visualization
7596 ------------------------------------------------------------------------------------------------------------------*/
7597
7598
7599 // Renders a visual indication of an event being resized
7600 renderEventResize: function(eventFootprints, seg) {
7601 var i;
7602
7603 for (i = 0; i < eventFootprints.length; i++) {
7604 this.renderHighlight(eventFootprints[i].componentFootprint);
7605 }
7606
7607 return this.renderHelperEventFootprints(eventFootprints, seg); // returns mock event elements
7608 },
7609
7610
7611 // Unrenders a visual indication of an event being resized
7612 unrenderEventResize: function() {
7613 this.unrenderHighlight();
7614 this.unrenderHelper();
7615 },
7616
7617
7618 /* Event Helper
7619 ------------------------------------------------------------------------------------------------------------------*/
7620
7621
7622 // Renders a mock "helper" event. `sourceSeg` is the associated internal segment object. It can be null.
7623 renderHelperEventFootprintEls: function(eventFootprints, sourceSeg) {
7624 var helperNodes = [];
7625 var segs = this.eventFootprintsToSegs(eventFootprints);
7626 var rowStructs;
7627
7628 segs = this.renderFgSegEls(segs); // assigns each seg's el and returns a subset of segs that were rendered
7629 rowStructs = this.renderSegRows(segs);
7630
7631 // inject each new event skeleton into each associated row
7632 this.rowEls.each(function(row, rowNode) {
7633 var rowEl = $(rowNode); // the .fc-row
7634 var skeletonEl = $('<div class="fc-helper-skeleton"><table/></div>'); // will be absolutely positioned
7635 var skeletonTop;
7636
7637 // If there is an original segment, match the top position. Otherwise, put it at the row's top level
7638 if (sourceSeg && sourceSeg.row === row) {
7639 skeletonTop = sourceSeg.el.position().top;
7640 }
7641 else {
7642 skeletonTop = rowEl.find('.fc-content-skeleton tbody').position().top;
7643 }
7644
7645 skeletonEl.css('top', skeletonTop)
7646 .find('table')
7647 .append(rowStructs[row].tbodyEl);
7648
7649 rowEl.append(skeletonEl);
7650 helperNodes.push(skeletonEl[0]);
7651 });
7652
7653 return ( // must return the elements rendered
7654 this.helperEls = $(helperNodes) // array -> jQuery set
7655 );
7656 },
7657
7658
7659 // Unrenders any visual indication of a mock helper event
7660 unrenderHelper: function() {
7661 if (this.helperEls) {
7662 this.helperEls.remove();
7663 this.helperEls = null;
7664 }
7665 },
7666
7667
7668 /* Fill System (highlight, background events, business hours)
7669 ------------------------------------------------------------------------------------------------------------------*/
7670
7671
7672 fillSegTag: 'td', // override the default tag name
7673
7674
7675 // Renders a set of rectangles over the given segments of days.
7676 // Only returns segments that successfully rendered.
7677 renderFill: function(type, segs, className) {
7678 var nodes = [];
7679 var i, seg;
7680 var skeletonEl;
7681
7682 segs = this.renderFillSegEls(type, segs); // assignes `.el` to each seg. returns successfully rendered segs
7683
7684 for (i = 0; i < segs.length; i++) {
7685 seg = segs[i];
7686 skeletonEl = this.renderFillRow(type, seg, className);
7687 this.rowEls.eq(seg.row).append(skeletonEl);
7688 nodes.push(skeletonEl[0]);
7689 }
7690
7691 if (this.elsByFill[type]) {
7692 this.elsByFill[type] = this.elsByFill[type].add(nodes);
7693 }
7694 else {
7695 this.elsByFill[type] = $(nodes);
7696 }
7697
7698 return segs;
7699 },
7700
7701
7702 // Generates the HTML needed for one row of a fill. Requires the seg's el to be rendered.
7703 renderFillRow: function(type, seg, className) {
7704 var colCnt = this.colCnt;
7705 var startCol = seg.leftCol;
7706 var endCol = seg.rightCol + 1;
7707 var skeletonEl;
7708 var trEl;
7709
7710 className = className || type.toLowerCase();
7711
7712 skeletonEl = $(
7713 '<div class="fc-' + className + '-skeleton">' +
7714 '<table><tr/></table>' +
7715 '</div>'
7716 );
7717 trEl = skeletonEl.find('tr');
7718
7719 if (startCol > 0) {
7720 trEl.append('<td colspan="' + startCol + '"/>');
7721 }
7722
7723 trEl.append(
7724 seg.el.attr('colspan', endCol - startCol)
7725 );
7726
7727 if (endCol < colCnt) {
7728 trEl.append('<td colspan="' + (colCnt - endCol) + '"/>');
7729 }
7730
7731 this.bookendCells(trEl);
7732
7733 return skeletonEl;
7734 }
7735
7736 });
7737
7738 ;;
7739
7740 /* Event-rendering methods for the DayGrid class
7741 ----------------------------------------------------------------------------------------------------------------------*/
7742
7743 DayGrid.mixin({
7744
7745 rowStructs: null, // an array of objects, each holding information about a row's foreground event-rendering
7746
7747
7748 // Unrenders all events currently rendered on the grid
7749 unrenderEvents: function() {
7750 this.removeSegPopover(); // removes the "more.." events popover
7751 Grid.prototype.unrenderEvents.apply(this, arguments); // calls the super-method
7752 },
7753
7754
7755 // Retrieves all rendered segment objects currently rendered on the grid
7756 getEventSegs: function() {
7757 return Grid.prototype.getEventSegs.call(this) // get the segments from the super-method
7758 .concat(this.popoverSegs || []); // append the segments from the "more..." popover
7759 },
7760
7761
7762 // Renders the given background event segments onto the grid
7763 renderBgSegs: function(segs) {
7764
7765 // don't render timed background events
7766 var allDaySegs = $.grep(segs, function(seg) {
7767 return seg.footprint.componentFootprint.isAllDay;
7768 });
7769
7770 return Grid.prototype.renderBgSegs.call(this, allDaySegs); // call the super-method
7771 },
7772
7773
7774 // Renders the given foreground event segments onto the grid
7775 renderFgSegs: function(segs) {
7776 var rowStructs;
7777
7778 // render an `.el` on each seg
7779 // returns a subset of the segs. segs that were actually rendered
7780 segs = this.renderFgSegEls(segs);
7781
7782 rowStructs = this.rowStructs = this.renderSegRows(segs);
7783
7784 // append to each row's content skeleton
7785 this.rowEls.each(function(i, rowNode) {
7786 $(rowNode).find('.fc-content-skeleton > table').append(
7787 rowStructs[i].tbodyEl
7788 );
7789 });
7790
7791 return segs; // return only the segs that were actually rendered
7792 },
7793
7794
7795 // Unrenders all currently rendered foreground event segments
7796 unrenderFgSegs: function() {
7797 var rowStructs = this.rowStructs || [];
7798 var rowStruct;
7799
7800 while ((rowStruct = rowStructs.pop())) {
7801 rowStruct.tbodyEl.remove();
7802 }
7803
7804 this.rowStructs = null;
7805 },
7806
7807
7808 // Uses the given events array to generate <tbody> elements that should be appended to each row's content skeleton.
7809 // Returns an array of rowStruct objects (see the bottom of `renderSegRow`).
7810 // PRECONDITION: each segment shoud already have a rendered and assigned `.el`
7811 renderSegRows: function(segs) {
7812 var rowStructs = [];
7813 var segRows;
7814 var row;
7815
7816 segRows = this.groupSegRows(segs); // group into nested arrays
7817
7818 // iterate each row of segment groupings
7819 for (row = 0; row < segRows.length; row++) {
7820 rowStructs.push(
7821 this.renderSegRow(row, segRows[row])
7822 );
7823 }
7824
7825 return rowStructs;
7826 },
7827
7828
7829 // Builds the HTML to be used for the default element for an individual segment
7830 fgSegHtml: function(seg, disableResizing) {
7831 var view = this.view;
7832 var eventDef = seg.footprint.eventDef;
7833 var isAllDay = seg.footprint.componentFootprint.isAllDay;
7834 var isDraggable = view.isEventDefDraggable(eventDef);
7835 var isResizableFromStart = !disableResizing && isAllDay &&
7836 seg.isStart && view.isEventDefResizableFromStart(eventDef);
7837 var isResizableFromEnd = !disableResizing && isAllDay &&
7838 seg.isEnd && view.isEventDefResizableFromEnd(eventDef);
7839 var classes = this.getSegClasses(seg, isDraggable, isResizableFromStart || isResizableFromEnd);
7840 var skinCss = cssToStr(this.getSegSkinCss(seg));
7841 var timeHtml = '';
7842 var timeText;
7843 var titleHtml;
7844
7845 classes.unshift('fc-day-grid-event', 'fc-h-event');
7846
7847 // Only display a timed events time if it is the starting segment
7848 if (seg.isStart) {
7849 timeText = this.getEventTimeText(seg.footprint);
7850 if (timeText) {
7851 timeHtml = '<span class="fc-time">' + htmlEscape(timeText) + '</span>';
7852 }
7853 }
7854
7855 titleHtml =
7856 '<span class="fc-title">' +
7857 (htmlEscape(eventDef.title || '') || '&nbsp;') + // we always want one line of height
7858 '</span>';
7859
7860 return '<a class="' + classes.join(' ') + '"' +
7861 (eventDef.url ?
7862 ' href="' + htmlEscape(eventDef.url) + '"' :
7863 ''
7864 ) +
7865 (skinCss ?
7866 ' style="' + skinCss + '"' :
7867 ''
7868 ) +
7869 '>' +
7870 '<div class="fc-content">' +
7871 (this.isRTL ?
7872 titleHtml + ' ' + timeHtml : // put a natural space in between
7873 timeHtml + ' ' + titleHtml //
7874 ) +
7875 '</div>' +
7876 (isResizableFromStart ?
7877 '<div class="fc-resizer fc-start-resizer" />' :
7878 ''
7879 ) +
7880 (isResizableFromEnd ?
7881 '<div class="fc-resizer fc-end-resizer" />' :
7882 ''
7883 ) +
7884 '</a>';
7885 },
7886
7887
7888 // Given a row # and an array of segments all in the same row, render a <tbody> element, a skeleton that contains
7889 // the segments. Returns object with a bunch of internal data about how the render was calculated.
7890 // NOTE: modifies rowSegs
7891 renderSegRow: function(row, rowSegs) {
7892 var colCnt = this.colCnt;
7893 var segLevels = this.buildSegLevels(rowSegs); // group into sub-arrays of levels
7894 var levelCnt = Math.max(1, segLevels.length); // ensure at least one level
7895 var tbody = $('<tbody/>');
7896 var segMatrix = []; // lookup for which segments are rendered into which level+col cells
7897 var cellMatrix = []; // lookup for all <td> elements of the level+col matrix
7898 var loneCellMatrix = []; // lookup for <td> elements that only take up a single column
7899 var i, levelSegs;
7900 var col;
7901 var tr;
7902 var j, seg;
7903 var td;
7904
7905 // populates empty cells from the current column (`col`) to `endCol`
7906 function emptyCellsUntil(endCol) {
7907 while (col < endCol) {
7908 // try to grab a cell from the level above and extend its rowspan. otherwise, create a fresh cell
7909 td = (loneCellMatrix[i - 1] || [])[col];
7910 if (td) {
7911 td.attr(
7912 'rowspan',
7913 parseInt(td.attr('rowspan') || 1, 10) + 1
7914 );
7915 }
7916 else {
7917 td = $('<td/>');
7918 tr.append(td);
7919 }
7920 cellMatrix[i][col] = td;
7921 loneCellMatrix[i][col] = td;
7922 col++;
7923 }
7924 }
7925
7926 for (i = 0; i < levelCnt; i++) { // iterate through all levels
7927 levelSegs = segLevels[i];
7928 col = 0;
7929 tr = $('<tr/>');
7930
7931 segMatrix.push([]);
7932 cellMatrix.push([]);
7933 loneCellMatrix.push([]);
7934
7935 // levelCnt might be 1 even though there are no actual levels. protect against this.
7936 // this single empty row is useful for styling.
7937 if (levelSegs) {
7938 for (j = 0; j < levelSegs.length; j++) { // iterate through segments in level
7939 seg = levelSegs[j];
7940
7941 emptyCellsUntil(seg.leftCol);
7942
7943 // create a container that occupies or more columns. append the event element.
7944 td = $('<td class="fc-event-container"/>').append(seg.el);
7945 if (seg.leftCol != seg.rightCol) {
7946 td.attr('colspan', seg.rightCol - seg.leftCol + 1);
7947 }
7948 else { // a single-column segment
7949 loneCellMatrix[i][col] = td;
7950 }
7951
7952 while (col <= seg.rightCol) {
7953 cellMatrix[i][col] = td;
7954 segMatrix[i][col] = seg;
7955 col++;
7956 }
7957
7958 tr.append(td);
7959 }
7960 }
7961
7962 emptyCellsUntil(colCnt); // finish off the row
7963 this.bookendCells(tr);
7964 tbody.append(tr);
7965 }
7966
7967 return { // a "rowStruct"
7968 row: row, // the row number
7969 tbodyEl: tbody,
7970 cellMatrix: cellMatrix,
7971 segMatrix: segMatrix,
7972 segLevels: segLevels,
7973 segs: rowSegs
7974 };
7975 },
7976
7977
7978 // Stacks a flat array of segments, which are all assumed to be in the same row, into subarrays of vertical levels.
7979 // NOTE: modifies segs
7980 buildSegLevels: function(segs) {
7981 var levels = [];
7982 var i, seg;
7983 var j;
7984
7985 // Give preference to elements with certain criteria, so they have
7986 // a chance to be closer to the top.
7987 this.sortEventSegs(segs);
7988
7989 for (i = 0; i < segs.length; i++) {
7990 seg = segs[i];
7991
7992 // loop through levels, starting with the topmost, until the segment doesn't collide with other segments
7993 for (j = 0; j < levels.length; j++) {
7994 if (!isDaySegCollision(seg, levels[j])) {
7995 break;
7996 }
7997 }
7998 // `j` now holds the desired subrow index
7999 seg.level = j;
8000
8001 // create new level array if needed and append segment
8002 (levels[j] || (levels[j] = [])).push(seg);
8003 }
8004
8005 // order segments left-to-right. very important if calendar is RTL
8006 for (j = 0; j < levels.length; j++) {
8007 levels[j].sort(compareDaySegCols);
8008 }
8009
8010 return levels;
8011 },
8012
8013
8014 // Given a flat array of segments, return an array of sub-arrays, grouped by each segment's row
8015 groupSegRows: function(segs) {
8016 var segRows = [];
8017 var i;
8018
8019 for (i = 0; i < this.rowCnt; i++) {
8020 segRows.push([]);
8021 }
8022
8023 for (i = 0; i < segs.length; i++) {
8024 segRows[segs[i].row].push(segs[i]);
8025 }
8026
8027 return segRows;
8028 }
8029
8030 });
8031
8032
8033 // Computes whether two segments' columns collide. They are assumed to be in the same row.
8034 function isDaySegCollision(seg, otherSegs) {
8035 var i, otherSeg;
8036
8037 for (i = 0; i < otherSegs.length; i++) {
8038 otherSeg = otherSegs[i];
8039
8040 if (
8041 otherSeg.leftCol <= seg.rightCol &&
8042 otherSeg.rightCol >= seg.leftCol
8043 ) {
8044 return true;
8045 }
8046 }
8047
8048 return false;
8049 }
8050
8051
8052 // A cmp function for determining the leftmost event
8053 function compareDaySegCols(a, b) {
8054 return a.leftCol - b.leftCol;
8055 }
8056
8057 ;;
8058
8059 /* Methods relate to limiting the number events for a given day on a DayGrid
8060 ----------------------------------------------------------------------------------------------------------------------*/
8061 // NOTE: all the segs being passed around in here are foreground segs
8062
8063 DayGrid.mixin({
8064
8065 segPopover: null, // the Popover that holds events that can't fit in a cell. null when not visible
8066 popoverSegs: null, // an array of segment objects that the segPopover holds. null when not visible
8067
8068
8069 removeSegPopover: function() {
8070 if (this.segPopover) {
8071 this.segPopover.hide(); // in handler, will call segPopover's removeElement
8072 }
8073 },
8074
8075
8076 // Limits the number of "levels" (vertically stacking layers of events) for each row of the grid.
8077 // `levelLimit` can be false (don't limit), a number, or true (should be computed).
8078 limitRows: function(levelLimit) {
8079 var rowStructs = this.rowStructs || [];
8080 var row; // row #
8081 var rowLevelLimit;
8082
8083 for (row = 0; row < rowStructs.length; row++) {
8084 this.unlimitRow(row);
8085
8086 if (!levelLimit) {
8087 rowLevelLimit = false;
8088 }
8089 else if (typeof levelLimit === 'number') {
8090 rowLevelLimit = levelLimit;
8091 }
8092 else {
8093 rowLevelLimit = this.computeRowLevelLimit(row);
8094 }
8095
8096 if (rowLevelLimit !== false) {
8097 this.limitRow(row, rowLevelLimit);
8098 }
8099 }
8100 },
8101
8102
8103 // Computes the number of levels a row will accomodate without going outside its bounds.
8104 // Assumes the row is "rigid" (maintains a constant height regardless of what is inside).
8105 // `row` is the row number.
8106 computeRowLevelLimit: function(row) {
8107 var rowEl = this.rowEls.eq(row); // the containing "fake" row div
8108 var rowHeight = rowEl.height(); // TODO: cache somehow?
8109 var trEls = this.rowStructs[row].tbodyEl.children();
8110 var i, trEl;
8111 var trHeight;
8112
8113 function iterInnerHeights(i, childNode) {
8114 trHeight = Math.max(trHeight, $(childNode).outerHeight());
8115 }
8116
8117 // Reveal one level <tr> at a time and stop when we find one out of bounds
8118 for (i = 0; i < trEls.length; i++) {
8119 trEl = trEls.eq(i).removeClass('fc-limited'); // reset to original state (reveal)
8120
8121 // with rowspans>1 and IE8, trEl.outerHeight() would return the height of the largest cell,
8122 // so instead, find the tallest inner content element.
8123 trHeight = 0;
8124 trEl.find('> td > :first-child').each(iterInnerHeights);
8125
8126 if (trEl.position().top + trHeight > rowHeight) {
8127 return i;
8128 }
8129 }
8130
8131 return false; // should not limit at all
8132 },
8133
8134
8135 // Limits the given grid row to the maximum number of levels and injects "more" links if necessary.
8136 // `row` is the row number.
8137 // `levelLimit` is a number for the maximum (inclusive) number of levels allowed.
8138 limitRow: function(row, levelLimit) {
8139 var _this = this;
8140 var rowStruct = this.rowStructs[row];
8141 var moreNodes = []; // array of "more" <a> links and <td> DOM nodes
8142 var col = 0; // col #, left-to-right (not chronologically)
8143 var levelSegs; // array of segment objects in the last allowable level, ordered left-to-right
8144 var cellMatrix; // a matrix (by level, then column) of all <td> jQuery elements in the row
8145 var limitedNodes; // array of temporarily hidden level <tr> and segment <td> DOM nodes
8146 var i, seg;
8147 var segsBelow; // array of segment objects below `seg` in the current `col`
8148 var totalSegsBelow; // total number of segments below `seg` in any of the columns `seg` occupies
8149 var colSegsBelow; // array of segment arrays, below seg, one for each column (offset from segs's first column)
8150 var td, rowspan;
8151 var segMoreNodes; // array of "more" <td> cells that will stand-in for the current seg's cell
8152 var j;
8153 var moreTd, moreWrap, moreLink;
8154
8155 // Iterates through empty level cells and places "more" links inside if need be
8156 function emptyCellsUntil(endCol) { // goes from current `col` to `endCol`
8157 while (col < endCol) {
8158 segsBelow = _this.getCellSegs(row, col, levelLimit);
8159 if (segsBelow.length) {
8160 td = cellMatrix[levelLimit - 1][col];
8161 moreLink = _this.renderMoreLink(row, col, segsBelow);
8162 moreWrap = $('<div/>').append(moreLink);
8163 td.append(moreWrap);
8164 moreNodes.push(moreWrap[0]);
8165 }
8166 col++;
8167 }
8168 }
8169
8170 if (levelLimit && levelLimit < rowStruct.segLevels.length) { // is it actually over the limit?
8171 levelSegs = rowStruct.segLevels[levelLimit - 1];
8172 cellMatrix = rowStruct.cellMatrix;
8173
8174 limitedNodes = rowStruct.tbodyEl.children().slice(levelLimit) // get level <tr> elements past the limit
8175 .addClass('fc-limited').get(); // hide elements and get a simple DOM-nodes array
8176
8177 // iterate though segments in the last allowable level
8178 for (i = 0; i < levelSegs.length; i++) {
8179 seg = levelSegs[i];
8180 emptyCellsUntil(seg.leftCol); // process empty cells before the segment
8181
8182 // determine *all* segments below `seg` that occupy the same columns
8183 colSegsBelow = [];
8184 totalSegsBelow = 0;
8185 while (col <= seg.rightCol) {
8186 segsBelow = this.getCellSegs(row, col, levelLimit);
8187 colSegsBelow.push(segsBelow);
8188 totalSegsBelow += segsBelow.length;
8189 col++;
8190 }
8191
8192 if (totalSegsBelow) { // do we need to replace this segment with one or many "more" links?
8193 td = cellMatrix[levelLimit - 1][seg.leftCol]; // the segment's parent cell
8194 rowspan = td.attr('rowspan') || 1;
8195 segMoreNodes = [];
8196
8197 // make a replacement <td> for each column the segment occupies. will be one for each colspan
8198 for (j = 0; j < colSegsBelow.length; j++) {
8199 moreTd = $('<td class="fc-more-cell"/>').attr('rowspan', rowspan);
8200 segsBelow = colSegsBelow[j];
8201 moreLink = this.renderMoreLink(
8202 row,
8203 seg.leftCol + j,
8204 [ seg ].concat(segsBelow) // count seg as hidden too
8205 );
8206 moreWrap = $('<div/>').append(moreLink);
8207 moreTd.append(moreWrap);
8208 segMoreNodes.push(moreTd[0]);
8209 moreNodes.push(moreTd[0]);
8210 }
8211
8212 td.addClass('fc-limited').after($(segMoreNodes)); // hide original <td> and inject replacements
8213 limitedNodes.push(td[0]);
8214 }
8215 }
8216
8217 emptyCellsUntil(this.colCnt); // finish off the level
8218 rowStruct.moreEls = $(moreNodes); // for easy undoing later
8219 rowStruct.limitedEls = $(limitedNodes); // for easy undoing later
8220 }
8221 },
8222
8223
8224 // Reveals all levels and removes all "more"-related elements for a grid's row.
8225 // `row` is a row number.
8226 unlimitRow: function(row) {
8227 var rowStruct = this.rowStructs[row];
8228
8229 if (rowStruct.moreEls) {
8230 rowStruct.moreEls.remove();
8231 rowStruct.moreEls = null;
8232 }
8233
8234 if (rowStruct.limitedEls) {
8235 rowStruct.limitedEls.removeClass('fc-limited');
8236 rowStruct.limitedEls = null;
8237 }
8238 },
8239
8240
8241 // Renders an <a> element that represents hidden event element for a cell.
8242 // Responsible for attaching click handler as well.
8243 renderMoreLink: function(row, col, hiddenSegs) {
8244 var _this = this;
8245 var view = this.view;
8246
8247 return $('<a class="fc-more"/>')
8248 .text(
8249 this.getMoreLinkText(hiddenSegs.length)
8250 )
8251 .on('click', function(ev) {
8252 var clickOption = _this.opt('eventLimitClick');
8253 var date = _this.getCellDate(row, col);
8254 var moreEl = $(this);
8255 var dayEl = _this.getCellEl(row, col);
8256 var allSegs = _this.getCellSegs(row, col);
8257
8258 // rescope the segments to be within the cell's date
8259 var reslicedAllSegs = _this.resliceDaySegs(allSegs, date);
8260 var reslicedHiddenSegs = _this.resliceDaySegs(hiddenSegs, date);
8261
8262 if (typeof clickOption === 'function') {
8263 // the returned value can be an atomic option
8264 clickOption = _this.publiclyTrigger('eventLimitClick', {
8265 context: view,
8266 args: [
8267 {
8268 date: date.clone(),
8269 dayEl: dayEl,
8270 moreEl: moreEl,
8271 segs: reslicedAllSegs,
8272 hiddenSegs: reslicedHiddenSegs
8273 },
8274 ev,
8275 view
8276 ]
8277 });
8278 }
8279
8280 if (clickOption === 'popover') {
8281 _this.showSegPopover(row, col, moreEl, reslicedAllSegs);
8282 }
8283 else if (typeof clickOption === 'string') { // a view name
8284 view.calendar.zoomTo(date, clickOption);
8285 }
8286 });
8287 },
8288
8289
8290 // Reveals the popover that displays all events within a cell
8291 showSegPopover: function(row, col, moreLink, segs) {
8292 var _this = this;
8293 var view = this.view;
8294 var moreWrap = moreLink.parent(); // the <div> wrapper around the <a>
8295 var topEl; // the element we want to match the top coordinate of
8296 var options;
8297
8298 if (this.rowCnt == 1) {
8299 topEl = view.el; // will cause the popover to cover any sort of header
8300 }
8301 else {
8302 topEl = this.rowEls.eq(row); // will align with top of row
8303 }
8304
8305 options = {
8306 className: 'fc-more-popover ' + view.calendar.theme.getClass('popover'),
8307 content: this.renderSegPopoverContent(row, col, segs),
8308 parentEl: view.el, // attach to root of view. guarantees outside of scrollbars.
8309 top: topEl.offset().top,
8310 autoHide: true, // when the user clicks elsewhere, hide the popover
8311 viewportConstrain: this.opt('popoverViewportConstrain'),
8312 hide: function() {
8313 // kill everything when the popover is hidden
8314 // notify events to be removed
8315 if (_this.popoverSegs) {
8316 var seg;
8317 var legacy;
8318 var i;
8319
8320 for (i = 0; i < _this.popoverSegs.length; ++i) {
8321 seg = _this.popoverSegs[i];
8322 legacy = seg.footprint.getEventLegacy();
8323
8324 _this.publiclyTrigger('eventDestroy', {
8325 context: legacy,
8326 args: [ legacy, seg.el, view ]
8327 });
8328 }
8329 }
8330 _this.segPopover.removeElement();
8331 _this.segPopover = null;
8332 _this.popoverSegs = null;
8333 }
8334 };
8335
8336 // Determine horizontal coordinate.
8337 // We use the moreWrap instead of the <td> to avoid border confusion.
8338 if (this.isRTL) {
8339 options.right = moreWrap.offset().left + moreWrap.outerWidth() + 1; // +1 to be over cell border
8340 }
8341 else {
8342 options.left = moreWrap.offset().left - 1; // -1 to be over cell border
8343 }
8344
8345 this.segPopover = new Popover(options);
8346 this.segPopover.show();
8347
8348 // the popover doesn't live within the grid's container element, and thus won't get the event
8349 // delegated-handlers for free. attach event-related handlers to the popover.
8350 this.bindSegHandlersToEl(this.segPopover.el);
8351 },
8352
8353
8354 // Builds the inner DOM contents of the segment popover
8355 renderSegPopoverContent: function(row, col, segs) {
8356 var view = this.view;
8357 var theme = view.calendar.theme;
8358 var title = this.getCellDate(row, col).format(this.opt('dayPopoverFormat'));
8359 var content = $(
8360 '<div class="fc-header ' + theme.getClass('popoverHeader') + '">' +
8361 '<span class="fc-close ' + theme.getIconClass('close') + '"></span>' +
8362 '<span class="fc-title">' +
8363 htmlEscape(title) +
8364 '</span>' +
8365 '<div class="fc-clear"/>' +
8366 '</div>' +
8367 '<div class="fc-body ' + theme.getClass('popoverContent') + '">' +
8368 '<div class="fc-event-container"></div>' +
8369 '</div>'
8370 );
8371 var segContainer = content.find('.fc-event-container');
8372 var i;
8373
8374 // render each seg's `el` and only return the visible segs
8375 segs = this.renderFgSegEls(segs, true); // disableResizing=true
8376 this.popoverSegs = segs;
8377
8378 for (i = 0; i < segs.length; i++) {
8379
8380 // because segments in the popover are not part of a grid coordinate system, provide a hint to any
8381 // grids that want to do drag-n-drop about which cell it came from
8382 this.hitsNeeded();
8383 segs[i].hit = this.getCellHit(row, col);
8384 this.hitsNotNeeded();
8385
8386 segContainer.append(segs[i].el);
8387 }
8388
8389 return content;
8390 },
8391
8392
8393 // Given the events within an array of segment objects, reslice them to be in a single day
8394 resliceDaySegs: function(segs, dayDate) {
8395 var dayStart = dayDate.clone();
8396 var dayEnd = dayStart.clone().add(1, 'days');
8397 var dayRange = new UnzonedRange(dayStart, dayEnd);
8398 var newSegs = [];
8399 var i;
8400
8401 for (i = 0; i < segs.length; i++) {
8402 newSegs.push.apply(newSegs, // append
8403 this.eventFootprintToSegs(segs[i].footprint, dayRange)
8404 );
8405 }
8406
8407 // force an order because eventsToSegs doesn't guarantee one
8408 // TODO: research if still needed
8409 this.sortEventSegs(newSegs);
8410
8411 return newSegs;
8412 },
8413
8414
8415 // Generates the text that should be inside a "more" link, given the number of events it represents
8416 getMoreLinkText: function(num) {
8417 var opt = this.opt('eventLimitText');
8418
8419 if (typeof opt === 'function') {
8420 return opt(num);
8421 }
8422 else {
8423 return '+' + num + ' ' + opt;
8424 }
8425 },
8426
8427
8428 // Returns segments within a given cell.
8429 // If `startLevel` is specified, returns only events including and below that level. Otherwise returns all segs.
8430 getCellSegs: function(row, col, startLevel) {
8431 var segMatrix = this.rowStructs[row].segMatrix;
8432 var level = startLevel || 0;
8433 var segs = [];
8434 var seg;
8435
8436 while (level < segMatrix.length) {
8437 seg = segMatrix[level][col];
8438 if (seg) {
8439 segs.push(seg);
8440 }
8441 level++;
8442 }
8443
8444 return segs;
8445 }
8446
8447 });
8448
8449 ;;
8450
8451 /* A component that renders one or more columns of vertical time slots
8452 ----------------------------------------------------------------------------------------------------------------------*/
8453 // We mixin DayTable, even though there is only a single row of days
8454
8455 var TimeGrid = FC.TimeGrid = Grid.extend(DayTableMixin, {
8456
8457 dayRanges: null, // UnzonedRange[], of start-end of each day
8458 slotDuration: null, // duration of a "slot", a distinct time segment on given day, visualized by lines
8459 snapDuration: null, // granularity of time for dragging and selecting
8460 snapsPerSlot: null,
8461 labelFormat: null, // formatting string for times running along vertical axis
8462 labelInterval: null, // duration of how often a label should be displayed for a slot
8463
8464 colEls: null, // cells elements in the day-row background
8465 slatContainerEl: null, // div that wraps all the slat rows
8466 slatEls: null, // elements running horizontally across all columns
8467 nowIndicatorEls: null,
8468
8469 colCoordCache: null,
8470 slatCoordCache: null,
8471
8472
8473 constructor: function() {
8474 Grid.apply(this, arguments); // call the super-constructor
8475
8476 this.processOptions();
8477 },
8478
8479
8480 // Renders the time grid into `this.el`, which should already be assigned.
8481 // Relies on the view's colCnt. In the future, this component should probably be self-sufficient.
8482 renderDates: function() {
8483 this.el.html(this.renderHtml());
8484 this.colEls = this.el.find('.fc-day, .fc-disabled-day');
8485 this.slatContainerEl = this.el.find('.fc-slats');
8486 this.slatEls = this.slatContainerEl.find('tr');
8487
8488 this.colCoordCache = new CoordCache({
8489 els: this.colEls,
8490 isHorizontal: true
8491 });
8492 this.slatCoordCache = new CoordCache({
8493 els: this.slatEls,
8494 isVertical: true
8495 });
8496
8497 this.renderContentSkeleton();
8498 },
8499
8500
8501 // Renders the basic HTML skeleton for the grid
8502 renderHtml: function() {
8503 var theme = this.view.calendar.theme;
8504
8505 return '' +
8506 '<div class="fc-bg">' +
8507 '<table class="' + theme.getClass('tableGrid') + '">' +
8508 this.renderBgTrHtml(0) + // row=0
8509 '</table>' +
8510 '</div>' +
8511 '<div class="fc-slats">' +
8512 '<table class="' + theme.getClass('tableGrid') + '">' +
8513 this.renderSlatRowHtml() +
8514 '</table>' +
8515 '</div>';
8516 },
8517
8518
8519 // Generates the HTML for the horizontal "slats" that run width-wise. Has a time axis on a side. Depends on RTL.
8520 renderSlatRowHtml: function() {
8521 var view = this.view;
8522 var calendar = view.calendar;
8523 var theme = calendar.theme;
8524 var isRTL = this.isRTL;
8525 var html = '';
8526 var slotTime = moment.duration(+this.view.minTime); // wish there was .clone() for durations
8527 var slotIterator = moment.duration(0);
8528 var slotDate; // will be on the view's first day, but we only care about its time
8529 var isLabeled;
8530 var axisHtml;
8531
8532 // Calculate the time for each slot
8533 while (slotTime < view.maxTime) {
8534 slotDate = calendar.msToUtcMoment(this.unzonedRange.startMs).time(slotTime);
8535 isLabeled = isInt(divideDurationByDuration(slotIterator, this.labelInterval));
8536
8537 axisHtml =
8538 '<td class="fc-axis fc-time ' + theme.getClass('widgetContent') + '" ' + view.axisStyleAttr() + '>' +
8539 (isLabeled ?
8540 '<span>' + // for matchCellWidths
8541 htmlEscape(slotDate.format(this.labelFormat)) +
8542 '</span>' :
8543 ''
8544 ) +
8545 '</td>';
8546
8547 html +=
8548 '<tr data-time="' + slotDate.format('HH:mm:ss') + '"' +
8549 (isLabeled ? '' : ' class="fc-minor"') +
8550 '>' +
8551 (!isRTL ? axisHtml : '') +
8552 '<td class="' + theme.getClass('widgetContent') + '"/>' +
8553 (isRTL ? axisHtml : '') +
8554 "</tr>";
8555
8556 slotTime.add(this.slotDuration);
8557 slotIterator.add(this.slotDuration);
8558 }
8559
8560 return html;
8561 },
8562
8563
8564 /* Options
8565 ------------------------------------------------------------------------------------------------------------------*/
8566
8567
8568 // Parses various options into properties of this object
8569 processOptions: function() {
8570 var slotDuration = this.opt('slotDuration');
8571 var snapDuration = this.opt('snapDuration');
8572 var input;
8573
8574 slotDuration = moment.duration(slotDuration);
8575 snapDuration = snapDuration ? moment.duration(snapDuration) : slotDuration;
8576
8577 this.slotDuration = slotDuration;
8578 this.snapDuration = snapDuration;
8579 this.snapsPerSlot = slotDuration / snapDuration; // TODO: ensure an integer multiple?
8580
8581 // might be an array value (for TimelineView).
8582 // if so, getting the most granular entry (the last one probably).
8583 input = this.opt('slotLabelFormat');
8584 if ($.isArray(input)) {
8585 input = input[input.length - 1];
8586 }
8587
8588 this.labelFormat = input ||
8589 this.opt('smallTimeFormat'); // the computed default
8590
8591 input = this.opt('slotLabelInterval');
8592 this.labelInterval = input ?
8593 moment.duration(input) :
8594 this.computeLabelInterval(slotDuration);
8595 },
8596
8597
8598 // Computes an automatic value for slotLabelInterval
8599 computeLabelInterval: function(slotDuration) {
8600 var i;
8601 var labelInterval;
8602 var slotsPerLabel;
8603
8604 // find the smallest stock label interval that results in more than one slots-per-label
8605 for (i = AGENDA_STOCK_SUB_DURATIONS.length - 1; i >= 0; i--) {
8606 labelInterval = moment.duration(AGENDA_STOCK_SUB_DURATIONS[i]);
8607 slotsPerLabel = divideDurationByDuration(labelInterval, slotDuration);
8608 if (isInt(slotsPerLabel) && slotsPerLabel > 1) {
8609 return labelInterval;
8610 }
8611 }
8612
8613 return moment.duration(slotDuration); // fall back. clone
8614 },
8615
8616
8617 // Computes a default event time formatting string if `timeFormat` is not explicitly defined
8618 computeEventTimeFormat: function() {
8619 return this.opt('noMeridiemTimeFormat'); // like "6:30" (no AM/PM)
8620 },
8621
8622
8623 // Computes a default `displayEventEnd` value if one is not expliclty defined
8624 computeDisplayEventEnd: function() {
8625 return true;
8626 },
8627
8628
8629 /* Hit System
8630 ------------------------------------------------------------------------------------------------------------------*/
8631
8632
8633 prepareHits: function() {
8634 this.colCoordCache.build();
8635 this.slatCoordCache.build();
8636 },
8637
8638
8639 releaseHits: function() {
8640 this.colCoordCache.clear();
8641 // NOTE: don't clear slatCoordCache because we rely on it for computeTimeTop
8642 },
8643
8644
8645 queryHit: function(leftOffset, topOffset) {
8646 var snapsPerSlot = this.snapsPerSlot;
8647 var colCoordCache = this.colCoordCache;
8648 var slatCoordCache = this.slatCoordCache;
8649
8650 if (colCoordCache.isLeftInBounds(leftOffset) && slatCoordCache.isTopInBounds(topOffset)) {
8651 var colIndex = colCoordCache.getHorizontalIndex(leftOffset);
8652 var slatIndex = slatCoordCache.getVerticalIndex(topOffset);
8653
8654 if (colIndex != null && slatIndex != null) {
8655 var slatTop = slatCoordCache.getTopOffset(slatIndex);
8656 var slatHeight = slatCoordCache.getHeight(slatIndex);
8657 var partial = (topOffset - slatTop) / slatHeight; // floating point number between 0 and 1
8658 var localSnapIndex = Math.floor(partial * snapsPerSlot); // the snap # relative to start of slat
8659 var snapIndex = slatIndex * snapsPerSlot + localSnapIndex;
8660 var snapTop = slatTop + (localSnapIndex / snapsPerSlot) * slatHeight;
8661 var snapBottom = slatTop + ((localSnapIndex + 1) / snapsPerSlot) * slatHeight;
8662
8663 return {
8664 col: colIndex,
8665 snap: snapIndex,
8666 component: this, // needed unfortunately :(
8667 left: colCoordCache.getLeftOffset(colIndex),
8668 right: colCoordCache.getRightOffset(colIndex),
8669 top: snapTop,
8670 bottom: snapBottom
8671 };
8672 }
8673 }
8674 },
8675
8676
8677 getHitFootprint: function(hit) {
8678 var start = this.getCellDate(0, hit.col); // row=0
8679 var time = this.computeSnapTime(hit.snap); // pass in the snap-index
8680 var end;
8681
8682 start.time(time);
8683 end = start.clone().add(this.snapDuration);
8684
8685 return new ComponentFootprint(
8686 new UnzonedRange(start, end),
8687 false // all-day?
8688 );
8689 },
8690
8691
8692 getHitEl: function(hit) {
8693 return this.colEls.eq(hit.col);
8694 },
8695
8696
8697 /* Dates
8698 ------------------------------------------------------------------------------------------------------------------*/
8699
8700
8701 rangeUpdated: function() {
8702 var view = this.view;
8703
8704 this.updateDayTable();
8705
8706 this.dayRanges = this.dayDates.map(function(dayDate) {
8707 return new UnzonedRange(
8708 dayDate.clone().add(view.minTime),
8709 dayDate.clone().add(view.maxTime)
8710 );
8711 });
8712 },
8713
8714
8715 // Given a row number of the grid, representing a "snap", returns a time (Duration) from its start-of-day
8716 computeSnapTime: function(snapIndex) {
8717 return moment.duration(this.view.minTime + this.snapDuration * snapIndex);
8718 },
8719
8720
8721 // Slices up the given span (unzoned start/end with other misc data) into an array of segments
8722 componentFootprintToSegs: function(componentFootprint) {
8723 var segs = this.sliceRangeByTimes(componentFootprint.unzonedRange);
8724 var i;
8725
8726 for (i = 0; i < segs.length; i++) {
8727 if (this.isRTL) {
8728 segs[i].col = this.daysPerRow - 1 - segs[i].dayIndex;
8729 }
8730 else {
8731 segs[i].col = segs[i].dayIndex;
8732 }
8733 }
8734
8735 return segs;
8736 },
8737
8738
8739 sliceRangeByTimes: function(unzonedRange) {
8740 var segs = [];
8741 var segRange;
8742 var dayIndex;
8743
8744 for (dayIndex = 0; dayIndex < this.daysPerRow; dayIndex++) {
8745
8746 segRange = unzonedRange.intersect(this.dayRanges[dayIndex]);
8747
8748 if (segRange) {
8749 segs.push({
8750 startMs: segRange.startMs,
8751 endMs: segRange.endMs,
8752 isStart: segRange.isStart,
8753 isEnd: segRange.isEnd,
8754 dayIndex: dayIndex
8755 });
8756 }
8757 }
8758
8759 return segs;
8760 },
8761
8762
8763 /* Coordinates
8764 ------------------------------------------------------------------------------------------------------------------*/
8765
8766
8767 updateSize: function(isResize) { // NOT a standard Grid method
8768 this.slatCoordCache.build();
8769
8770 if (isResize) {
8771 this.updateSegVerticals(
8772 [].concat(this.fgSegs || [], this.bgSegs || [], this.businessSegs || [])
8773 );
8774 }
8775 },
8776
8777
8778 getTotalSlatHeight: function() {
8779 return this.slatContainerEl.outerHeight();
8780 },
8781
8782
8783 // Computes the top coordinate, relative to the bounds of the grid, of the given date.
8784 // `ms` can be a millisecond UTC time OR a UTC moment.
8785 // A `startOfDayDate` must be given for avoiding ambiguity over how to treat midnight.
8786 computeDateTop: function(ms, startOfDayDate) {
8787 return this.computeTimeTop(
8788 moment.duration(
8789 ms - startOfDayDate.clone().stripTime()
8790 )
8791 );
8792 },
8793
8794
8795 // Computes the top coordinate, relative to the bounds of the grid, of the given time (a Duration).
8796 computeTimeTop: function(time) {
8797 var len = this.slatEls.length;
8798 var slatCoverage = (time - this.view.minTime) / this.slotDuration; // floating-point value of # of slots covered
8799 var slatIndex;
8800 var slatRemainder;
8801
8802 // compute a floating-point number for how many slats should be progressed through.
8803 // from 0 to number of slats (inclusive)
8804 // constrained because minTime/maxTime might be customized.
8805 slatCoverage = Math.max(0, slatCoverage);
8806 slatCoverage = Math.min(len, slatCoverage);
8807
8808 // an integer index of the furthest whole slat
8809 // from 0 to number slats (*exclusive*, so len-1)
8810 slatIndex = Math.floor(slatCoverage);
8811 slatIndex = Math.min(slatIndex, len - 1);
8812
8813 // how much further through the slatIndex slat (from 0.0-1.0) must be covered in addition.
8814 // could be 1.0 if slatCoverage is covering *all* the slots
8815 slatRemainder = slatCoverage - slatIndex;
8816
8817 return this.slatCoordCache.getTopPosition(slatIndex) +
8818 this.slatCoordCache.getHeight(slatIndex) * slatRemainder;
8819 },
8820
8821
8822
8823 /* Event Drag Visualization
8824 ------------------------------------------------------------------------------------------------------------------*/
8825
8826
8827 // Renders a visual indication of an event being dragged over the specified date(s).
8828 // A returned value of `true` signals that a mock "helper" event has been rendered.
8829 renderDrag: function(eventFootprints, seg) {
8830 var i;
8831
8832 if (seg) { // if there is event information for this drag, render a helper event
8833
8834 // returns mock event elements
8835 // signal that a helper has been rendered
8836 return this.renderHelperEventFootprints(eventFootprints);
8837 }
8838 else { // otherwise, just render a highlight
8839
8840 for (i = 0; i < eventFootprints.length; i++) {
8841 this.renderHighlight(eventFootprints[i].componentFootprint);
8842 }
8843 }
8844 },
8845
8846
8847 // Unrenders any visual indication of an event being dragged
8848 unrenderDrag: function() {
8849 this.unrenderHelper();
8850 this.unrenderHighlight();
8851 },
8852
8853
8854 /* Event Resize Visualization
8855 ------------------------------------------------------------------------------------------------------------------*/
8856
8857
8858 // Renders a visual indication of an event being resized
8859 renderEventResize: function(eventFootprints, seg) {
8860 return this.renderHelperEventFootprints(eventFootprints, seg); // returns mock event elements
8861 },
8862
8863
8864 // Unrenders any visual indication of an event being resized
8865 unrenderEventResize: function() {
8866 this.unrenderHelper();
8867 },
8868
8869
8870 /* Event Helper
8871 ------------------------------------------------------------------------------------------------------------------*/
8872
8873
8874 // Renders a mock "helper" event. `sourceSeg` is the original segment object and might be null (an external drag)
8875 renderHelperEventFootprintEls: function(eventFootprints, sourceSeg) {
8876 var segs = this.eventFootprintsToSegs(eventFootprints);
8877
8878 return this.renderHelperSegs( // returns mock event elements
8879 segs,
8880 sourceSeg
8881 );
8882 },
8883
8884
8885 // Unrenders any mock helper event
8886 unrenderHelper: function() {
8887 this.unrenderHelperSegs();
8888 },
8889
8890
8891 /* Business Hours
8892 ------------------------------------------------------------------------------------------------------------------*/
8893
8894
8895 renderBusinessHours: function() {
8896 this.renderBusinessSegs(
8897 this.buildBusinessHourSegs()
8898 );
8899 },
8900
8901
8902 unrenderBusinessHours: function() {
8903 this.unrenderBusinessSegs();
8904 },
8905
8906
8907 /* Now Indicator
8908 ------------------------------------------------------------------------------------------------------------------*/
8909
8910
8911 getNowIndicatorUnit: function() {
8912 return 'minute'; // will refresh on the minute
8913 },
8914
8915
8916 renderNowIndicator: function(date) {
8917 // seg system might be overkill, but it handles scenario where line needs to be rendered
8918 // more than once because of columns with the same date (resources columns for example)
8919 var segs = this.componentFootprintToSegs(
8920 new ComponentFootprint(
8921 new UnzonedRange(date, date.valueOf() + 1), // protect against null range
8922 false // all-day
8923 )
8924 );
8925 var top = this.computeDateTop(date, date);
8926 var nodes = [];
8927 var i;
8928
8929 // render lines within the columns
8930 for (i = 0; i < segs.length; i++) {
8931 nodes.push($('<div class="fc-now-indicator fc-now-indicator-line"></div>')
8932 .css('top', top)
8933 .appendTo(this.colContainerEls.eq(segs[i].col))[0]);
8934 }
8935
8936 // render an arrow over the axis
8937 if (segs.length > 0) { // is the current time in view?
8938 nodes.push($('<div class="fc-now-indicator fc-now-indicator-arrow"></div>')
8939 .css('top', top)
8940 .appendTo(this.el.find('.fc-content-skeleton'))[0]);
8941 }
8942
8943 this.nowIndicatorEls = $(nodes);
8944 },
8945
8946
8947 unrenderNowIndicator: function() {
8948 if (this.nowIndicatorEls) {
8949 this.nowIndicatorEls.remove();
8950 this.nowIndicatorEls = null;
8951 }
8952 },
8953
8954
8955 /* Selection
8956 ------------------------------------------------------------------------------------------------------------------*/
8957
8958
8959 // Renders a visual indication of a selection. Overrides the default, which was to simply render a highlight.
8960 renderSelectionFootprint: function(componentFootprint) {
8961 if (this.opt('selectHelper')) { // this setting signals that a mock helper event should be rendered
8962 this.renderHelperEventFootprints([
8963 this.fabricateEventFootprint(componentFootprint)
8964 ]);
8965 }
8966 else {
8967 this.renderHighlight(componentFootprint);
8968 }
8969 },
8970
8971
8972 // Unrenders any visual indication of a selection
8973 unrenderSelection: function() {
8974 this.unrenderHelper();
8975 this.unrenderHighlight();
8976 },
8977
8978
8979 /* Highlight
8980 ------------------------------------------------------------------------------------------------------------------*/
8981
8982
8983 renderHighlight: function(componentFootprint) {
8984 this.renderHighlightSegs(
8985 this.componentFootprintToSegs(componentFootprint)
8986 );
8987 },
8988
8989
8990 unrenderHighlight: function() {
8991 this.unrenderHighlightSegs();
8992 }
8993
8994 });
8995
8996 ;;
8997
8998 /* Methods for rendering SEGMENTS, pieces of content that live on the view
8999 ( this file is no longer just for events )
9000 ----------------------------------------------------------------------------------------------------------------------*/
9001
9002 TimeGrid.mixin({
9003
9004 colContainerEls: null, // containers for each column
9005
9006 // inner-containers for each column where different types of segs live
9007 fgContainerEls: null,
9008 bgContainerEls: null,
9009 helperContainerEls: null,
9010 highlightContainerEls: null,
9011 businessContainerEls: null,
9012
9013 // arrays of different types of displayed segments
9014 fgSegs: null,
9015 bgSegs: null,
9016 helperSegs: null,
9017 highlightSegs: null,
9018 businessSegs: null,
9019
9020
9021 // Renders the DOM that the view's content will live in
9022 renderContentSkeleton: function() {
9023 var cellHtml = '';
9024 var i;
9025 var skeletonEl;
9026
9027 for (i = 0; i < this.colCnt; i++) {
9028 cellHtml +=
9029 '<td>' +
9030 '<div class="fc-content-col">' +
9031 '<div class="fc-event-container fc-helper-container"></div>' +
9032 '<div class="fc-event-container"></div>' +
9033 '<div class="fc-highlight-container"></div>' +
9034 '<div class="fc-bgevent-container"></div>' +
9035 '<div class="fc-business-container"></div>' +
9036 '</div>' +
9037 '</td>';
9038 }
9039
9040 skeletonEl = $(
9041 '<div class="fc-content-skeleton">' +
9042 '<table>' +
9043 '<tr>' + cellHtml + '</tr>' +
9044 '</table>' +
9045 '</div>'
9046 );
9047
9048 this.colContainerEls = skeletonEl.find('.fc-content-col');
9049 this.helperContainerEls = skeletonEl.find('.fc-helper-container');
9050 this.fgContainerEls = skeletonEl.find('.fc-event-container:not(.fc-helper-container)');
9051 this.bgContainerEls = skeletonEl.find('.fc-bgevent-container');
9052 this.highlightContainerEls = skeletonEl.find('.fc-highlight-container');
9053 this.businessContainerEls = skeletonEl.find('.fc-business-container');
9054
9055 this.bookendCells(skeletonEl.find('tr')); // TODO: do this on string level
9056 this.el.append(skeletonEl);
9057 },
9058
9059
9060 /* Foreground Events
9061 ------------------------------------------------------------------------------------------------------------------*/
9062
9063
9064 renderFgSegs: function(segs) {
9065 segs = this.renderFgSegsIntoContainers(segs, this.fgContainerEls);
9066 this.fgSegs = segs;
9067 return segs; // needed for Grid::renderEvents
9068 },
9069
9070
9071 unrenderFgSegs: function() {
9072 this.unrenderNamedSegs('fgSegs');
9073 },
9074
9075
9076 /* Foreground Helper Events
9077 ------------------------------------------------------------------------------------------------------------------*/
9078
9079
9080 renderHelperSegs: function(segs, sourceSeg) {
9081 var helperEls = [];
9082 var i, seg;
9083 var sourceEl;
9084
9085 segs = this.renderFgSegsIntoContainers(segs, this.helperContainerEls);
9086
9087 // Try to make the segment that is in the same row as sourceSeg look the same
9088 for (i = 0; i < segs.length; i++) {
9089 seg = segs[i];
9090 if (sourceSeg && sourceSeg.col === seg.col) {
9091 sourceEl = sourceSeg.el;
9092 seg.el.css({
9093 left: sourceEl.css('left'),
9094 right: sourceEl.css('right'),
9095 'margin-left': sourceEl.css('margin-left'),
9096 'margin-right': sourceEl.css('margin-right')
9097 });
9098 }
9099 helperEls.push(seg.el[0]);
9100 }
9101
9102 this.helperSegs = segs;
9103
9104 return $(helperEls); // must return rendered helpers
9105 },
9106
9107
9108 unrenderHelperSegs: function() {
9109 this.unrenderNamedSegs('helperSegs');
9110 },
9111
9112
9113 /* Background Events
9114 ------------------------------------------------------------------------------------------------------------------*/
9115
9116
9117 renderBgSegs: function(segs) {
9118 segs = this.renderFillSegEls('bgEvent', segs); // TODO: old fill system
9119 this.updateSegVerticals(segs);
9120 this.attachSegsByCol(this.groupSegsByCol(segs), this.bgContainerEls);
9121 this.bgSegs = segs;
9122 return segs; // needed for Grid::renderEvents
9123 },
9124
9125
9126 unrenderBgSegs: function() {
9127 this.unrenderNamedSegs('bgSegs');
9128 },
9129
9130
9131 /* Highlight
9132 ------------------------------------------------------------------------------------------------------------------*/
9133
9134
9135 renderHighlightSegs: function(segs) {
9136 segs = this.renderFillSegEls('highlight', segs); // TODO: instead of calling renderFill directly
9137 this.updateSegVerticals(segs);
9138 this.attachSegsByCol(this.groupSegsByCol(segs), this.highlightContainerEls);
9139 this.highlightSegs = segs;
9140 },
9141
9142
9143 unrenderHighlightSegs: function() {
9144 this.unrenderNamedSegs('highlightSegs');
9145 },
9146
9147
9148 /* Business Hours
9149 ------------------------------------------------------------------------------------------------------------------*/
9150
9151
9152 renderBusinessSegs: function(segs) {
9153 segs = this.renderFillSegEls('businessHours', segs); // TODO: instead of calling renderFill directly
9154 this.updateSegVerticals(segs);
9155 this.attachSegsByCol(this.groupSegsByCol(segs), this.businessContainerEls);
9156 this.businessSegs = segs;
9157 },
9158
9159
9160 unrenderBusinessSegs: function() {
9161 this.unrenderNamedSegs('businessSegs');
9162 },
9163
9164
9165 /* Seg Rendering Utils
9166 ------------------------------------------------------------------------------------------------------------------*/
9167
9168
9169 // Given a flat array of segments, return an array of sub-arrays, grouped by each segment's col
9170 groupSegsByCol: function(segs) {
9171 var segsByCol = [];
9172 var i;
9173
9174 for (i = 0; i < this.colCnt; i++) {
9175 segsByCol.push([]);
9176 }
9177
9178 for (i = 0; i < segs.length; i++) {
9179 segsByCol[segs[i].col].push(segs[i]);
9180 }
9181
9182 return segsByCol;
9183 },
9184
9185
9186 // Given segments grouped by column, insert the segments' elements into a parallel array of container
9187 // elements, each living within a column.
9188 attachSegsByCol: function(segsByCol, containerEls) {
9189 var col;
9190 var segs;
9191 var i;
9192
9193 for (col = 0; col < this.colCnt; col++) { // iterate each column grouping
9194 segs = segsByCol[col];
9195
9196 for (i = 0; i < segs.length; i++) {
9197 containerEls.eq(col).append(segs[i].el);
9198 }
9199 }
9200 },
9201
9202
9203 // Given the name of a property of `this` object, assumed to be an array of segments,
9204 // loops through each segment and removes from DOM. Will null-out the property afterwards.
9205 unrenderNamedSegs: function(propName) {
9206 var segs = this[propName];
9207 var i;
9208
9209 if (segs) {
9210 for (i = 0; i < segs.length; i++) {
9211 segs[i].el.remove();
9212 }
9213 this[propName] = null;
9214 }
9215 },
9216
9217
9218
9219 /* Foreground Event Rendering Utils
9220 ------------------------------------------------------------------------------------------------------------------*/
9221
9222
9223 // Given an array of foreground segments, render a DOM element for each, computes position,
9224 // and attaches to the column inner-container elements.
9225 renderFgSegsIntoContainers: function(segs, containerEls) {
9226 var segsByCol;
9227 var col;
9228
9229 segs = this.renderFgSegEls(segs); // will call fgSegHtml
9230 segsByCol = this.groupSegsByCol(segs);
9231
9232 for (col = 0; col < this.colCnt; col++) {
9233 this.updateFgSegCoords(segsByCol[col]);
9234 }
9235
9236 this.attachSegsByCol(segsByCol, containerEls);
9237
9238 return segs;
9239 },
9240
9241
9242 // Renders the HTML for a single event segment's default rendering
9243 fgSegHtml: function(seg, disableResizing) {
9244 var view = this.view;
9245 var calendar = view.calendar;
9246 var componentFootprint = seg.footprint.componentFootprint;
9247 var isAllDay = componentFootprint.isAllDay;
9248 var eventDef = seg.footprint.eventDef;
9249 var isDraggable = view.isEventDefDraggable(eventDef);
9250 var isResizableFromStart = !disableResizing && seg.isStart && view.isEventDefResizableFromStart(eventDef);
9251 var isResizableFromEnd = !disableResizing && seg.isEnd && view.isEventDefResizableFromEnd(eventDef);
9252 var classes = this.getSegClasses(seg, isDraggable, isResizableFromStart || isResizableFromEnd);
9253 var skinCss = cssToStr(this.getSegSkinCss(seg));
9254 var timeText;
9255 var fullTimeText; // more verbose time text. for the print stylesheet
9256 var startTimeText; // just the start time text
9257
9258 classes.unshift('fc-time-grid-event', 'fc-v-event');
9259
9260 // if the event appears to span more than one day...
9261 if (view.isMultiDayRange(componentFootprint.unzonedRange)) {
9262 // Don't display time text on segments that run entirely through a day.
9263 // That would appear as midnight-midnight and would look dumb.
9264 // Otherwise, display the time text for the *segment's* times (like 6pm-midnight or midnight-10am)
9265 if (seg.isStart || seg.isEnd) {
9266 var zonedStart = calendar.msToMoment(seg.startMs);
9267 var zonedEnd = calendar.msToMoment(seg.endMs);
9268 timeText = this._getEventTimeText(zonedStart, zonedEnd, isAllDay);
9269 fullTimeText = this._getEventTimeText(zonedStart, zonedEnd, isAllDay, 'LT');
9270 startTimeText = this._getEventTimeText(zonedStart, zonedEnd, isAllDay, null, false); // displayEnd=false
9271 }
9272 }
9273 else {
9274 // Display the normal time text for the *event's* times
9275 timeText = this.getEventTimeText(seg.footprint);
9276 fullTimeText = this.getEventTimeText(seg.footprint, 'LT');
9277 startTimeText = this.getEventTimeText(seg.footprint, null, false); // displayEnd=false
9278 }
9279
9280 return '<a class="' + classes.join(' ') + '"' +
9281 (eventDef.url ?
9282 ' href="' + htmlEscape(eventDef.url) + '"' :
9283 ''
9284 ) +
9285 (skinCss ?
9286 ' style="' + skinCss + '"' :
9287 ''
9288 ) +
9289 '>' +
9290 '<div class="fc-content">' +
9291 (timeText ?
9292 '<div class="fc-time"' +
9293 ' data-start="' + htmlEscape(startTimeText) + '"' +
9294 ' data-full="' + htmlEscape(fullTimeText) + '"' +
9295 '>' +
9296 '<span>' + htmlEscape(timeText) + '</span>' +
9297 '</div>' :
9298 ''
9299 ) +
9300 (eventDef.title ?
9301 '<div class="fc-title">' +
9302 htmlEscape(eventDef.title) +
9303 '</div>' :
9304 ''
9305 ) +
9306 '</div>' +
9307 '<div class="fc-bg"/>' +
9308 /* TODO: write CSS for this
9309 (isResizableFromStart ?
9310 '<div class="fc-resizer fc-start-resizer" />' :
9311 ''
9312 ) +
9313 */
9314 (isResizableFromEnd ?
9315 '<div class="fc-resizer fc-end-resizer" />' :
9316 ''
9317 ) +
9318 '</a>';
9319 },
9320
9321
9322 /* Seg Position Utils
9323 ------------------------------------------------------------------------------------------------------------------*/
9324
9325
9326 // Refreshes the CSS top/bottom coordinates for each segment element.
9327 // Works when called after initial render, after a window resize/zoom for example.
9328 updateSegVerticals: function(segs) {
9329 this.computeSegVerticals(segs);
9330 this.assignSegVerticals(segs);
9331 },
9332
9333
9334 // For each segment in an array, computes and assigns its top and bottom properties
9335 computeSegVerticals: function(segs) {
9336 var i, seg;
9337 var dayDate;
9338
9339 for (i = 0; i < segs.length; i++) {
9340 seg = segs[i];
9341 dayDate = this.dayDates[seg.dayIndex];
9342
9343 seg.top = this.computeDateTop(seg.startMs, dayDate);
9344 seg.bottom = this.computeDateTop(seg.endMs, dayDate);
9345 }
9346 },
9347
9348
9349 // Given segments that already have their top/bottom properties computed, applies those values to
9350 // the segments' elements.
9351 assignSegVerticals: function(segs) {
9352 var i, seg;
9353
9354 for (i = 0; i < segs.length; i++) {
9355 seg = segs[i];
9356 seg.el.css(this.generateSegVerticalCss(seg));
9357 }
9358 },
9359
9360
9361 // Generates an object with CSS properties for the top/bottom coordinates of a segment element
9362 generateSegVerticalCss: function(seg) {
9363 return {
9364 top: seg.top,
9365 bottom: -seg.bottom // flipped because needs to be space beyond bottom edge of event container
9366 };
9367 },
9368
9369
9370 /* Foreground Event Positioning Utils
9371 ------------------------------------------------------------------------------------------------------------------*/
9372
9373
9374 // Given segments that are assumed to all live in the *same column*,
9375 // compute their verical/horizontal coordinates and assign to their elements.
9376 updateFgSegCoords: function(segs) {
9377 this.computeSegVerticals(segs); // horizontals relies on this
9378 this.computeFgSegHorizontals(segs); // compute horizontal coordinates, z-index's, and reorder the array
9379 this.assignSegVerticals(segs);
9380 this.assignFgSegHorizontals(segs);
9381 },
9382
9383
9384 // Given an array of segments that are all in the same column, sets the backwardCoord and forwardCoord on each.
9385 // NOTE: Also reorders the given array by date!
9386 computeFgSegHorizontals: function(segs) {
9387 var levels;
9388 var level0;
9389 var i;
9390
9391 this.sortEventSegs(segs); // order by certain criteria
9392 levels = buildSlotSegLevels(segs);
9393 computeForwardSlotSegs(levels);
9394
9395 if ((level0 = levels[0])) {
9396
9397 for (i = 0; i < level0.length; i++) {
9398 computeSlotSegPressures(level0[i]);
9399 }
9400
9401 for (i = 0; i < level0.length; i++) {
9402 this.computeFgSegForwardBack(level0[i], 0, 0);
9403 }
9404 }
9405 },
9406
9407
9408 // Calculate seg.forwardCoord and seg.backwardCoord for the segment, where both values range
9409 // from 0 to 1. If the calendar is left-to-right, the seg.backwardCoord maps to "left" and
9410 // seg.forwardCoord maps to "right" (via percentage). Vice-versa if the calendar is right-to-left.
9411 //
9412 // The segment might be part of a "series", which means consecutive segments with the same pressure
9413 // who's width is unknown until an edge has been hit. `seriesBackwardPressure` is the number of
9414 // segments behind this one in the current series, and `seriesBackwardCoord` is the starting
9415 // coordinate of the first segment in the series.
9416 computeFgSegForwardBack: function(seg, seriesBackwardPressure, seriesBackwardCoord) {
9417 var forwardSegs = seg.forwardSegs;
9418 var i;
9419
9420 if (seg.forwardCoord === undefined) { // not already computed
9421
9422 if (!forwardSegs.length) {
9423
9424 // if there are no forward segments, this segment should butt up against the edge
9425 seg.forwardCoord = 1;
9426 }
9427 else {
9428
9429 // sort highest pressure first
9430 this.sortForwardSegs(forwardSegs);
9431
9432 // this segment's forwardCoord will be calculated from the backwardCoord of the
9433 // highest-pressure forward segment.
9434 this.computeFgSegForwardBack(forwardSegs[0], seriesBackwardPressure + 1, seriesBackwardCoord);
9435 seg.forwardCoord = forwardSegs[0].backwardCoord;
9436 }
9437
9438 // calculate the backwardCoord from the forwardCoord. consider the series
9439 seg.backwardCoord = seg.forwardCoord -
9440 (seg.forwardCoord - seriesBackwardCoord) / // available width for series
9441 (seriesBackwardPressure + 1); // # of segments in the series
9442
9443 // use this segment's coordinates to computed the coordinates of the less-pressurized
9444 // forward segments
9445 for (i=0; i<forwardSegs.length; i++) {
9446 this.computeFgSegForwardBack(forwardSegs[i], 0, seg.forwardCoord);
9447 }
9448 }
9449 },
9450
9451
9452 sortForwardSegs: function(forwardSegs) {
9453 forwardSegs.sort(proxy(this, 'compareForwardSegs'));
9454 },
9455
9456
9457 // A cmp function for determining which forward segment to rely on more when computing coordinates.
9458 compareForwardSegs: function(seg1, seg2) {
9459 // put higher-pressure first
9460 return seg2.forwardPressure - seg1.forwardPressure ||
9461 // put segments that are closer to initial edge first (and favor ones with no coords yet)
9462 (seg1.backwardCoord || 0) - (seg2.backwardCoord || 0) ||
9463 // do normal sorting...
9464 this.compareEventSegs(seg1, seg2);
9465 },
9466
9467
9468 // Given foreground event segments that have already had their position coordinates computed,
9469 // assigns position-related CSS values to their elements.
9470 assignFgSegHorizontals: function(segs) {
9471 var i, seg;
9472
9473 for (i = 0; i < segs.length; i++) {
9474 seg = segs[i];
9475 seg.el.css(this.generateFgSegHorizontalCss(seg));
9476
9477 // if the height is short, add a className for alternate styling
9478 if (seg.bottom - seg.top < 30) {
9479 seg.el.addClass('fc-short');
9480 }
9481 }
9482 },
9483
9484
9485 // Generates an object with CSS properties/values that should be applied to an event segment element.
9486 // Contains important positioning-related properties that should be applied to any event element, customized or not.
9487 generateFgSegHorizontalCss: function(seg) {
9488 var shouldOverlap = this.opt('slotEventOverlap');
9489 var backwardCoord = seg.backwardCoord; // the left side if LTR. the right side if RTL. floating-point
9490 var forwardCoord = seg.forwardCoord; // the right side if LTR. the left side if RTL. floating-point
9491 var props = this.generateSegVerticalCss(seg); // get top/bottom first
9492 var left; // amount of space from left edge, a fraction of the total width
9493 var right; // amount of space from right edge, a fraction of the total width
9494
9495 if (shouldOverlap) {
9496 // double the width, but don't go beyond the maximum forward coordinate (1.0)
9497 forwardCoord = Math.min(1, backwardCoord + (forwardCoord - backwardCoord) * 2);
9498 }
9499
9500 if (this.isRTL) {
9501 left = 1 - forwardCoord;
9502 right = backwardCoord;
9503 }
9504 else {
9505 left = backwardCoord;
9506 right = 1 - forwardCoord;
9507 }
9508
9509 props.zIndex = seg.level + 1; // convert from 0-base to 1-based
9510 props.left = left * 100 + '%';
9511 props.right = right * 100 + '%';
9512
9513 if (shouldOverlap && seg.forwardPressure) {
9514 // add padding to the edge so that forward stacked events don't cover the resizer's icon
9515 props[this.isRTL ? 'marginLeft' : 'marginRight'] = 10 * 2; // 10 is a guesstimate of the icon's width
9516 }
9517
9518 return props;
9519 }
9520
9521 });
9522
9523
9524 // Builds an array of segments "levels". The first level will be the leftmost tier of segments if the calendar is
9525 // left-to-right, or the rightmost if the calendar is right-to-left. Assumes the segments are already ordered by date.
9526 function buildSlotSegLevels(segs) {
9527 var levels = [];
9528 var i, seg;
9529 var j;
9530
9531 for (i=0; i<segs.length; i++) {
9532 seg = segs[i];
9533
9534 // go through all the levels and stop on the first level where there are no collisions
9535 for (j=0; j<levels.length; j++) {
9536 if (!computeSlotSegCollisions(seg, levels[j]).length) {
9537 break;
9538 }
9539 }
9540
9541 seg.level = j;
9542
9543 (levels[j] || (levels[j] = [])).push(seg);
9544 }
9545
9546 return levels;
9547 }
9548
9549
9550 // For every segment, figure out the other segments that are in subsequent
9551 // levels that also occupy the same vertical space. Accumulate in seg.forwardSegs
9552 function computeForwardSlotSegs(levels) {
9553 var i, level;
9554 var j, seg;
9555 var k;
9556
9557 for (i=0; i<levels.length; i++) {
9558 level = levels[i];
9559
9560 for (j=0; j<level.length; j++) {
9561 seg = level[j];
9562
9563 seg.forwardSegs = [];
9564 for (k=i+1; k<levels.length; k++) {
9565 computeSlotSegCollisions(seg, levels[k], seg.forwardSegs);
9566 }
9567 }
9568 }
9569 }
9570
9571
9572 // Figure out which path forward (via seg.forwardSegs) results in the longest path until
9573 // the furthest edge is reached. The number of segments in this path will be seg.forwardPressure
9574 function computeSlotSegPressures(seg) {
9575 var forwardSegs = seg.forwardSegs;
9576 var forwardPressure = 0;
9577 var i, forwardSeg;
9578
9579 if (seg.forwardPressure === undefined) { // not already computed
9580
9581 for (i=0; i<forwardSegs.length; i++) {
9582 forwardSeg = forwardSegs[i];
9583
9584 // figure out the child's maximum forward path
9585 computeSlotSegPressures(forwardSeg);
9586
9587 // either use the existing maximum, or use the child's forward pressure
9588 // plus one (for the forwardSeg itself)
9589 forwardPressure = Math.max(
9590 forwardPressure,
9591 1 + forwardSeg.forwardPressure
9592 );
9593 }
9594
9595 seg.forwardPressure = forwardPressure;
9596 }
9597 }
9598
9599
9600 // Find all the segments in `otherSegs` that vertically collide with `seg`.
9601 // Append into an optionally-supplied `results` array and return.
9602 function computeSlotSegCollisions(seg, otherSegs, results) {
9603 results = results || [];
9604
9605 for (var i=0; i<otherSegs.length; i++) {
9606 if (isSlotSegCollision(seg, otherSegs[i])) {
9607 results.push(otherSegs[i]);
9608 }
9609 }
9610
9611 return results;
9612 }
9613
9614
9615 // Do these segments occupy the same vertical space?
9616 function isSlotSegCollision(seg1, seg2) {
9617 return seg1.bottom > seg2.top && seg1.top < seg2.bottom;
9618 }
9619
9620 ;;
9621
9622 /* An abstract class from which other views inherit from
9623 ----------------------------------------------------------------------------------------------------------------------*/
9624
9625 var View = FC.View = ChronoComponent.extend({
9626
9627 type: null, // subclass' view name (string)
9628 name: null, // deprecated. use `type` instead
9629 title: null, // the text that will be displayed in the header's title
9630
9631 calendar: null, // owner Calendar object
9632 viewSpec: null,
9633 options: null, // hash containing all options. already merged with view-specific-options
9634
9635 renderQueue: null,
9636 batchRenderDepth: 0,
9637 isDatesRendered: false,
9638 isEventsRendered: false,
9639 isBaseRendered: false, // related to viewRender/viewDestroy triggers
9640
9641 queuedScroll: null,
9642
9643 isSelected: false, // boolean whether a range of time is user-selected or not
9644 selectedEventInstance: null,
9645
9646 eventOrderSpecs: null, // criteria for ordering events when they have same date/time
9647
9648 // for date utils, computed from options
9649 isHiddenDayHash: null,
9650
9651 // now indicator
9652 isNowIndicatorRendered: null,
9653 initialNowDate: null, // result first getNow call
9654 initialNowQueriedMs: null, // ms time the getNow was called
9655 nowIndicatorTimeoutID: null, // for refresh timing of now indicator
9656 nowIndicatorIntervalID: null, // "
9657
9658
9659 constructor: function(calendar, viewSpec) {
9660 this.calendar = calendar;
9661 this.viewSpec = viewSpec;
9662
9663 // shortcuts
9664 this.type = viewSpec.type;
9665 this.options = viewSpec.options;
9666
9667 // .name is deprecated
9668 this.name = this.type;
9669
9670 ChronoComponent.call(this);
9671
9672 this.initHiddenDays();
9673 this.eventOrderSpecs = parseFieldSpecs(this.opt('eventOrder'));
9674
9675 this.renderQueue = this.buildRenderQueue();
9676 this.initAutoBatchRender();
9677
9678 this.initialize();
9679 },
9680
9681
9682 buildRenderQueue: function() {
9683 var _this = this;
9684 var renderQueue = new RenderQueue({
9685 event: this.opt('eventRenderWait')
9686 });
9687
9688 renderQueue.on('start', function() {
9689 _this.freezeHeight();
9690 _this.addScroll(_this.queryScroll());
9691 });
9692
9693 renderQueue.on('stop', function() {
9694 _this.thawHeight();
9695 _this.popScroll();
9696 });
9697
9698 return renderQueue;
9699 },
9700
9701
9702 initAutoBatchRender: function() {
9703 var _this = this;
9704
9705 this.on('before:change', function() {
9706 _this.startBatchRender();
9707 });
9708
9709 this.on('change', function() {
9710 _this.stopBatchRender();
9711 });
9712 },
9713
9714
9715 startBatchRender: function() {
9716 if (!(this.batchRenderDepth++)) {
9717 this.renderQueue.pause();
9718 }
9719 },
9720
9721
9722 stopBatchRender: function() {
9723 if (!(--this.batchRenderDepth)) {
9724 this.renderQueue.resume();
9725 }
9726 },
9727
9728
9729 // A good place for subclasses to initialize member variables
9730 initialize: function() {
9731 // subclasses can implement
9732 },
9733
9734
9735 // Retrieves an option with the given name
9736 opt: function(name) {
9737 return this.options[name];
9738 },
9739
9740
9741 /* Title and Date Formatting
9742 ------------------------------------------------------------------------------------------------------------------*/
9743
9744
9745 // Computes what the title at the top of the calendar should be for this view
9746 computeTitle: function() {
9747 var unzonedRange;
9748
9749 // for views that span a large unit of time, show the proper interval, ignoring stray days before and after
9750 if (/^(year|month)$/.test(this.currentRangeUnit)) {
9751 unzonedRange = this.currentUnzonedRange;
9752 }
9753 else { // for day units or smaller, use the actual day range
9754 unzonedRange = this.activeUnzonedRange;
9755 }
9756
9757 return this.formatRange(
9758 {
9759 start: this.calendar.msToMoment(unzonedRange.startMs, this.isRangeAllDay),
9760 end: this.calendar.msToMoment(unzonedRange.endMs, this.isRangeAllDay)
9761 },
9762 this.isRangeAllDay,
9763 this.opt('titleFormat') || this.computeTitleFormat(),
9764 this.opt('titleRangeSeparator')
9765 );
9766 },
9767
9768
9769 // Generates the format string that should be used to generate the title for the current date range.
9770 // Attempts to compute the most appropriate format if not explicitly specified with `titleFormat`.
9771 computeTitleFormat: function() {
9772 if (this.currentRangeUnit == 'year') {
9773 return 'YYYY';
9774 }
9775 else if (this.currentRangeUnit == 'month') {
9776 return this.opt('monthYearFormat'); // like "September 2014"
9777 }
9778 else if (this.currentRangeAs('days') > 1) {
9779 return 'll'; // multi-day range. shorter, like "Sep 9 - 10 2014"
9780 }
9781 else {
9782 return 'LL'; // one day. longer, like "September 9 2014"
9783 }
9784 },
9785
9786
9787 // Element
9788 // -----------------------------------------------------------------------------------------------------------------
9789
9790
9791 setElement: function(el) {
9792 ChronoComponent.prototype.setElement.apply(this, arguments);
9793
9794 this.bindBaseRenderHandlers();
9795 },
9796
9797
9798 removeElement: function() {
9799 this.unsetDate();
9800 this.unbindBaseRenderHandlers();
9801
9802 ChronoComponent.prototype.removeElement.apply(this, arguments);
9803 },
9804
9805
9806 // Date Setting/Unsetting
9807 // -----------------------------------------------------------------------------------------------------------------
9808
9809
9810 setDate: function(date) {
9811 var currentDateProfile = this.get('dateProfile');
9812 var newDateProfile = this.buildDateProfile(date, null, true); // forceToValid=true
9813
9814 if (
9815 !currentDateProfile ||
9816 !currentDateProfile.activeUnzonedRange.equals(newDateProfile.activeUnzonedRange)
9817 ) {
9818 this.set('dateProfile', newDateProfile);
9819 }
9820
9821 return newDateProfile.date;
9822 },
9823
9824
9825 unsetDate: function() {
9826 this.unset('dateProfile');
9827 },
9828
9829
9830 // Date Rendering
9831 // -----------------------------------------------------------------------------------------------------------------
9832
9833
9834 requestDateRender: function(dateProfile) {
9835 var _this = this;
9836
9837 this.renderQueue.queue(function() {
9838 _this.executeDateRender(dateProfile);
9839 }, 'date', 'init');
9840 },
9841
9842
9843 requestDateUnrender: function() {
9844 var _this = this;
9845
9846 this.renderQueue.queue(function() {
9847 _this.executeDateUnrender();
9848 }, 'date', 'destroy');
9849 },
9850
9851
9852 // Event Data
9853 // -----------------------------------------------------------------------------------------------------------------
9854
9855
9856 fetchInitialEvents: function(dateProfile) {
9857 var calendar = this.calendar;
9858 var forceAllDay = dateProfile.isRangeAllDay && !this.usesMinMaxTime;
9859
9860 return calendar.requestEvents(
9861 calendar.msToMoment(dateProfile.activeUnzonedRange.startMs, forceAllDay),
9862 calendar.msToMoment(dateProfile.activeUnzonedRange.endMs, forceAllDay)
9863 );
9864 },
9865
9866
9867 bindEventChanges: function() {
9868 this.listenTo(this.calendar, 'eventsReset', this.resetEvents);
9869 },
9870
9871
9872 unbindEventChanges: function() {
9873 this.stopListeningTo(this.calendar, 'eventsReset');
9874 },
9875
9876
9877 setEvents: function(eventsPayload) {
9878 this.set('currentEvents', eventsPayload);
9879 this.set('hasEvents', true);
9880 },
9881
9882
9883 unsetEvents: function() {
9884 this.unset('currentEvents');
9885 this.unset('hasEvents');
9886 },
9887
9888
9889 resetEvents: function(eventsPayload) {
9890 this.startBatchRender();
9891 this.unsetEvents();
9892 this.setEvents(eventsPayload);
9893 this.stopBatchRender();
9894 },
9895
9896
9897 // Event Rendering
9898 // -----------------------------------------------------------------------------------------------------------------
9899
9900
9901 requestEventsRender: function(eventsPayload) {
9902 var _this = this;
9903
9904 this.renderQueue.queue(function() {
9905 _this.executeEventsRender(eventsPayload);
9906 }, 'event', 'init');
9907 },
9908
9909
9910 requestEventsUnrender: function() {
9911 var _this = this;
9912
9913 this.renderQueue.queue(function() {
9914 _this.executeEventsUnrender();
9915 }, 'event', 'destroy');
9916 },
9917
9918
9919 // Date High-level Rendering
9920 // -----------------------------------------------------------------------------------------------------------------
9921
9922
9923 // if dateProfile not specified, uses current
9924 executeDateRender: function(dateProfile, skipScroll) {
9925
9926 this.setDateProfileForRendering(dateProfile);
9927
9928 if (this.render) {
9929 this.render(); // TODO: deprecate
9930 }
9931
9932 this.renderDates();
9933 this.updateSize();
9934 this.renderBusinessHours(); // might need coordinates, so should go after updateSize()
9935 this.startNowIndicator();
9936
9937 if (!skipScroll) {
9938 this.addScroll(this.computeInitialDateScroll());
9939 }
9940
9941 this.isDatesRendered = true;
9942 this.trigger('datesRendered');
9943 },
9944
9945
9946 executeDateUnrender: function() {
9947
9948 this.unselect();
9949 this.stopNowIndicator();
9950
9951 this.trigger('before:datesUnrendered');
9952
9953 this.unrenderBusinessHours();
9954 this.unrenderDates();
9955
9956 if (this.destroy) {
9957 this.destroy(); // TODO: deprecate
9958 }
9959
9960 this.isDatesRendered = false;
9961 },
9962
9963
9964 // Determing when the "meat" of the view is rendered (aka the base)
9965 // -----------------------------------------------------------------------------------------------------------------
9966
9967
9968 bindBaseRenderHandlers: function() {
9969 var _this = this;
9970
9971 this.on('datesRendered.baseHandler', function() {
9972 _this.onBaseRender();
9973 });
9974
9975 this.on('before:datesUnrendered.baseHandler', function() {
9976 _this.onBeforeBaseUnrender();
9977 });
9978 },
9979
9980
9981 unbindBaseRenderHandlers: function() {
9982 this.off('.baseHandler');
9983 },
9984
9985
9986 onBaseRender: function() {
9987 this.applyScreenState();
9988 this.publiclyTrigger('viewRender', {
9989 context: this,
9990 args: [ this, this.el ]
9991 });
9992 },
9993
9994
9995 onBeforeBaseUnrender: function() {
9996 this.applyScreenState();
9997 this.publiclyTrigger('viewDestroy', {
9998 context: this,
9999 args: [ this, this.el ]
10000 });
10001 },
10002
10003
10004 // Misc view rendering utils
10005 // -----------------------------------------------------------------------------------------------------------------
10006
10007
10008 // Binds DOM handlers to elements that reside outside the view container, such as the document
10009 bindGlobalHandlers: function() {
10010 this.listenTo(GlobalEmitter.get(), {
10011 touchstart: this.processUnselect,
10012 mousedown: this.handleDocumentMousedown
10013 });
10014 },
10015
10016
10017 // Unbinds DOM handlers from elements that reside outside the view container
10018 unbindGlobalHandlers: function() {
10019 this.stopListeningTo(GlobalEmitter.get());
10020 },
10021
10022
10023 /* Now Indicator
10024 ------------------------------------------------------------------------------------------------------------------*/
10025
10026
10027 // Immediately render the current time indicator and begins re-rendering it at an interval,
10028 // which is defined by this.getNowIndicatorUnit().
10029 // TODO: somehow do this for the current whole day's background too
10030 startNowIndicator: function() {
10031 var _this = this;
10032 var unit;
10033 var update;
10034 var delay; // ms wait value
10035
10036 if (this.opt('nowIndicator')) {
10037 unit = this.getNowIndicatorUnit();
10038 if (unit) {
10039 update = proxy(this, 'updateNowIndicator'); // bind to `this`
10040
10041 this.initialNowDate = this.calendar.getNow();
10042 this.initialNowQueriedMs = +new Date();
10043 this.renderNowIndicator(this.initialNowDate);
10044 this.isNowIndicatorRendered = true;
10045
10046 // wait until the beginning of the next interval
10047 delay = this.initialNowDate.clone().startOf(unit).add(1, unit) - this.initialNowDate;
10048 this.nowIndicatorTimeoutID = setTimeout(function() {
10049 _this.nowIndicatorTimeoutID = null;
10050 update();
10051 delay = +moment.duration(1, unit);
10052 delay = Math.max(100, delay); // prevent too frequent
10053 _this.nowIndicatorIntervalID = setInterval(update, delay); // update every interval
10054 }, delay);
10055 }
10056 }
10057 },
10058
10059
10060 // rerenders the now indicator, computing the new current time from the amount of time that has passed
10061 // since the initial getNow call.
10062 updateNowIndicator: function() {
10063 if (this.isNowIndicatorRendered) {
10064 this.unrenderNowIndicator();
10065 this.renderNowIndicator(
10066 this.initialNowDate.clone().add(new Date() - this.initialNowQueriedMs) // add ms
10067 );
10068 }
10069 },
10070
10071
10072 // Immediately unrenders the view's current time indicator and stops any re-rendering timers.
10073 // Won't cause side effects if indicator isn't rendered.
10074 stopNowIndicator: function() {
10075 if (this.isNowIndicatorRendered) {
10076
10077 if (this.nowIndicatorTimeoutID) {
10078 clearTimeout(this.nowIndicatorTimeoutID);
10079 this.nowIndicatorTimeoutID = null;
10080 }
10081 if (this.nowIndicatorIntervalID) {
10082 clearTimeout(this.nowIndicatorIntervalID);
10083 this.nowIndicatorIntervalID = null;
10084 }
10085
10086 this.unrenderNowIndicator();
10087 this.isNowIndicatorRendered = false;
10088 }
10089 },
10090
10091
10092 /* Dimensions
10093 ------------------------------------------------------------------------------------------------------------------*/
10094 // TODO: move some of these to ChronoComponent
10095
10096
10097 // Refreshes anything dependant upon sizing of the container element of the grid
10098 updateSize: function(isResize) {
10099 var scroll;
10100
10101 if (isResize) {
10102 scroll = this.queryScroll();
10103 }
10104
10105 this.updateHeight(isResize);
10106 this.updateWidth(isResize);
10107 this.updateNowIndicator();
10108
10109 if (isResize) {
10110 this.applyScroll(scroll);
10111 }
10112 },
10113
10114
10115 // Refreshes the horizontal dimensions of the calendar
10116 updateWidth: function(isResize) {
10117 // subclasses should implement
10118 },
10119
10120
10121 // Refreshes the vertical dimensions of the calendar
10122 updateHeight: function(isResize) {
10123 var calendar = this.calendar; // we poll the calendar for height information
10124
10125 this.setHeight(
10126 calendar.getSuggestedViewHeight(),
10127 calendar.isHeightAuto()
10128 );
10129 },
10130
10131
10132 // Updates the vertical dimensions of the calendar to the specified height.
10133 // if `isAuto` is set to true, height becomes merely a suggestion and the view should use its "natural" height.
10134 setHeight: function(height, isAuto) {
10135 // subclasses should implement
10136 },
10137
10138
10139 /* Scroller
10140 ------------------------------------------------------------------------------------------------------------------*/
10141
10142
10143 addForcedScroll: function(scroll) {
10144 this.addScroll(
10145 $.extend(scroll, { isForced: true })
10146 );
10147 },
10148
10149
10150 addScroll: function(scroll) {
10151 var queuedScroll = this.queuedScroll || (this.queuedScroll = {});
10152
10153 if (!queuedScroll.isForced) {
10154 $.extend(queuedScroll, scroll);
10155 }
10156 },
10157
10158
10159 popScroll: function() {
10160 this.applyQueuedScroll();
10161 this.queuedScroll = null;
10162 },
10163
10164
10165 applyQueuedScroll: function() {
10166 if (this.queuedScroll) {
10167 this.applyScroll(this.queuedScroll);
10168 }
10169 },
10170
10171
10172 queryScroll: function() {
10173 var scroll = {};
10174
10175 if (this.isDatesRendered) {
10176 $.extend(scroll, this.queryDateScroll());
10177 }
10178
10179 return scroll;
10180 },
10181
10182
10183 applyScroll: function(scroll) {
10184 if (this.isDatesRendered) {
10185 this.applyDateScroll(scroll);
10186 }
10187 },
10188
10189
10190 computeInitialDateScroll: function() {
10191 return {}; // subclasses must implement
10192 },
10193
10194
10195 queryDateScroll: function() {
10196 return {}; // subclasses must implement
10197 },
10198
10199
10200 applyDateScroll: function(scroll) {
10201 ; // subclasses must implement
10202 },
10203
10204
10205 /* Height Freezing
10206 ------------------------------------------------------------------------------------------------------------------*/
10207
10208
10209 freezeHeight: function() {
10210 this.calendar.freezeContentHeight();
10211 },
10212
10213
10214 thawHeight: function() {
10215 this.calendar.thawContentHeight();
10216 },
10217
10218
10219 // Event High-level Rendering
10220 // -----------------------------------------------------------------------------------------------------------------
10221
10222
10223 executeEventsRender: function(eventsPayload) {
10224
10225 if (this.renderEvents) { // for legacy custom views
10226 this.renderEvents(convertEventsPayloadToLegacyArray(eventsPayload));
10227 }
10228 else {
10229 this.renderEventsPayload(eventsPayload);
10230 }
10231
10232 this.isEventsRendered = true;
10233
10234 this.onEventsRender();
10235 },
10236
10237
10238 executeEventsUnrender: function() {
10239 this.onBeforeEventsUnrender();
10240
10241 if (this.destroyEvents) {
10242 this.destroyEvents(); // TODO: deprecate
10243 }
10244
10245 this.unrenderEvents();
10246 this.isEventsRendered = false;
10247 },
10248
10249
10250 // Event Rendering Triggers
10251 // -----------------------------------------------------------------------------------------------------------------
10252
10253
10254 // Signals that all events have been rendered
10255 onEventsRender: function() {
10256 var _this = this;
10257 var hasSingleHandlers = this.hasPublicHandlers('eventAfterRender');
10258
10259 if (hasSingleHandlers || this.hasPublicHandlers('eventAfterAllRender')) {
10260 this.applyScreenState();
10261 }
10262
10263 if (hasSingleHandlers) {
10264 this.getEventSegs().forEach(function(seg) {
10265 var legacy;
10266
10267 if (seg.el) { // necessary?
10268 legacy = seg.footprint.getEventLegacy();
10269
10270 _this.publiclyTrigger('eventAfterRender', {
10271 context: legacy,
10272 args: [ legacy, seg.el, _this ]
10273 });
10274 }
10275 });
10276 }
10277
10278 this.publiclyTrigger('eventAfterAllRender', {
10279 context: this,
10280 args: [ this ]
10281 });
10282 },
10283
10284
10285 // Signals that all event elements are about to be removed
10286 onBeforeEventsUnrender: function() {
10287 var _this = this;
10288
10289 if (this.hasPublicHandlers('eventDestroy')) {
10290
10291 this.applyScreenState();
10292
10293 this.getEventSegs().forEach(function(seg) {
10294 var legacy;
10295
10296 if (seg.el) { // necessary?
10297 legacy = seg.footprint.getEventLegacy();
10298
10299 _this.publiclyTrigger('eventDestroy', {
10300 context: legacy,
10301 args: [ legacy, seg.el, _this ]
10302 });
10303 }
10304 });
10305 }
10306 },
10307
10308
10309 applyScreenState: function() {
10310 this.thawHeight();
10311 this.freezeHeight();
10312 this.applyQueuedScroll();
10313 },
10314
10315
10316 // Event Rendering Utils
10317 // -----------------------------------------------------------------------------------------------------------------
10318 // TODO: move this to ChronoComponent
10319
10320
10321 // Hides all rendered event segments linked to the given event
10322 showEventsWithId: function(eventDefId) {
10323 this.getEventSegs().forEach(function(seg) {
10324 if (
10325 seg.footprint.eventDef.id === eventDefId &&
10326 seg.el // necessary?
10327 ) {
10328 seg.el.css('visibility', '');
10329 }
10330 });
10331 },
10332
10333
10334 // Shows all rendered event segments linked to the given event
10335 hideEventsWithId: function(eventDefId) {
10336 this.getEventSegs().forEach(function(seg) {
10337 if (
10338 seg.footprint.eventDef.id === eventDefId &&
10339 seg.el // necessary?
10340 ) {
10341 seg.el.css('visibility', 'hidden');
10342 }
10343 });
10344 },
10345
10346
10347 /* Event Drag-n-Drop
10348 ------------------------------------------------------------------------------------------------------------------*/
10349
10350
10351 reportEventDrop: function(eventInstance, eventMutation, el, ev) {
10352 var eventManager = this.calendar.eventManager;
10353 var undoFunc = eventManager.mutateEventsWithId(
10354 eventInstance.def.id,
10355 eventMutation,
10356 this.calendar
10357 );
10358 var dateMutation = eventMutation.dateMutation;
10359
10360 // update the EventInstance, for handlers
10361 if (dateMutation) {
10362 eventInstance.dateProfile = dateMutation.buildNewDateProfile(
10363 eventInstance.dateProfile,
10364 this.calendar
10365 );
10366 }
10367
10368 this.triggerEventDrop(
10369 eventInstance,
10370 // a drop doesn't necessarily mean a date mutation (ex: resource change)
10371 (dateMutation && dateMutation.dateDelta) || moment.duration(),
10372 undoFunc,
10373 el, ev
10374 );
10375 },
10376
10377
10378 // Triggers event-drop handlers that have subscribed via the API
10379 triggerEventDrop: function(eventInstance, dateDelta, undoFunc, el, ev) {
10380 this.publiclyTrigger('eventDrop', {
10381 context: el[0],
10382 args: [
10383 eventInstance.toLegacy(),
10384 dateDelta,
10385 undoFunc,
10386 ev,
10387 {}, // {} = jqui dummy
10388 this
10389 ]
10390 });
10391 },
10392
10393
10394 /* External Element Drag-n-Drop
10395 ------------------------------------------------------------------------------------------------------------------*/
10396
10397
10398 // Must be called when an external element, via jQuery UI, has been dropped onto the calendar.
10399 // `meta` is the parsed data that has been embedded into the dragging event.
10400 // `dropLocation` is an object that contains the new zoned start/end/allDay values for the event.
10401 reportExternalDrop: function(singleEventDef, isEvent, isSticky, el, ev, ui) {
10402
10403 if (isEvent) {
10404 this.calendar.eventManager.addEventDef(singleEventDef, isSticky);
10405 }
10406
10407 this.triggerExternalDrop(singleEventDef, isEvent, el, ev, ui);
10408 },
10409
10410
10411 // Triggers external-drop handlers that have subscribed via the API
10412 triggerExternalDrop: function(singleEventDef, isEvent, el, ev, ui) {
10413
10414 // trigger 'drop' regardless of whether element represents an event
10415 this.publiclyTrigger('drop', {
10416 context: el[0],
10417 args: [
10418 singleEventDef.dateProfile.start.clone(),
10419 ev,
10420 ui,
10421 this
10422 ]
10423 });
10424
10425 if (isEvent) {
10426 // signal an external event landed
10427 this.publiclyTrigger('eventReceive', {
10428 context: this,
10429 args: [
10430 singleEventDef.buildInstance().toLegacy(),
10431 this
10432 ]
10433 });
10434 }
10435 },
10436
10437
10438 /* Event Resizing
10439 ------------------------------------------------------------------------------------------------------------------*/
10440
10441
10442 // Must be called when an event in the view has been resized to a new length
10443 reportEventResize: function(eventInstance, eventMutation, el, ev) {
10444 var eventManager = this.calendar.eventManager;
10445 var undoFunc = eventManager.mutateEventsWithId(
10446 eventInstance.def.id,
10447 eventMutation,
10448 this.calendar
10449 );
10450
10451 // update the EventInstance, for handlers
10452 eventInstance.dateProfile = eventMutation.dateMutation.buildNewDateProfile(
10453 eventInstance.dateProfile,
10454 this.calendar
10455 );
10456
10457 this.triggerEventResize(
10458 eventInstance,
10459 eventMutation.dateMutation.endDelta,
10460 undoFunc,
10461 el, ev
10462 );
10463 },
10464
10465
10466 // Triggers event-resize handlers that have subscribed via the API
10467 triggerEventResize: function(eventInstance, durationDelta, undoFunc, el, ev) {
10468 this.publiclyTrigger('eventResize', {
10469 context: el[0],
10470 args: [
10471 eventInstance.toLegacy(),
10472 durationDelta,
10473 undoFunc,
10474 ev,
10475 {}, // {} = jqui dummy
10476 this
10477 ]
10478 });
10479 },
10480
10481
10482 /* Selection (time range)
10483 ------------------------------------------------------------------------------------------------------------------*/
10484
10485
10486 // Selects a date span on the view. `start` and `end` are both Moments.
10487 // `ev` is the native mouse event that begin the interaction.
10488 select: function(footprint, ev) {
10489 this.unselect(ev);
10490 this.renderSelectionFootprint(footprint);
10491 this.reportSelection(footprint, ev);
10492 },
10493
10494
10495 renderSelectionFootprint: function(footprint, ev) {
10496 if (this.renderSelection) { // legacy method in custom view classes
10497 this.renderSelection(
10498 footprint.toLegacy(this.calendar)
10499 );
10500 }
10501 else {
10502 ChronoComponent.prototype.renderSelectionFootprint.apply(this, arguments);
10503 }
10504 },
10505
10506
10507 // Called when a new selection is made. Updates internal state and triggers handlers.
10508 reportSelection: function(footprint, ev) {
10509 this.isSelected = true;
10510 this.triggerSelect(footprint, ev);
10511 },
10512
10513
10514 // Triggers handlers to 'select'
10515 triggerSelect: function(footprint, ev) {
10516 var dateProfile = this.calendar.footprintToDateProfile(footprint); // abuse of "Event"DateProfile?
10517
10518 this.publiclyTrigger('select', {
10519 context: this,
10520 args: [
10521 dateProfile.start,
10522 dateProfile.end,
10523 ev,
10524 this
10525 ]
10526 });
10527 },
10528
10529
10530 // Undoes a selection. updates in the internal state and triggers handlers.
10531 // `ev` is the native mouse event that began the interaction.
10532 unselect: function(ev) {
10533 if (this.isSelected) {
10534 this.isSelected = false;
10535 if (this.destroySelection) {
10536 this.destroySelection(); // TODO: deprecate
10537 }
10538 this.unrenderSelection();
10539 this.publiclyTrigger('unselect', {
10540 context: this,
10541 args: [ ev, this ]
10542 });
10543 }
10544 },
10545
10546
10547 /* Event Selection
10548 ------------------------------------------------------------------------------------------------------------------*/
10549
10550
10551 selectEventInstance: function(eventInstance) {
10552 if (
10553 !this.selectedEventInstance ||
10554 this.selectedEventInstance !== eventInstance
10555 ) {
10556 this.unselectEventInstance();
10557
10558 this.getEventSegs().forEach(function(seg) {
10559 if (
10560 seg.footprint.eventInstance === eventInstance &&
10561 seg.el // necessary?
10562 ) {
10563 seg.el.addClass('fc-selected');
10564 }
10565 });
10566
10567 this.selectedEventInstance = eventInstance;
10568 }
10569 },
10570
10571
10572 unselectEventInstance: function() {
10573 if (this.selectedEventInstance) {
10574
10575 this.getEventSegs().forEach(function(seg) {
10576 if (seg.el) { // necessary?
10577 seg.el.removeClass('fc-selected');
10578 }
10579 });
10580
10581 this.selectedEventInstance = null;
10582 }
10583 },
10584
10585
10586 isEventDefSelected: function(eventDef) {
10587 // event references might change on refetchEvents(), while selectedEventInstance doesn't,
10588 // so compare IDs
10589 return this.selectedEventInstance && this.selectedEventInstance.def.id === eventDef.id;
10590 },
10591
10592
10593 /* Mouse / Touch Unselecting (time range & event unselection)
10594 ------------------------------------------------------------------------------------------------------------------*/
10595 // TODO: move consistently to down/start or up/end?
10596 // TODO: don't kill previous selection if touch scrolling
10597
10598
10599 handleDocumentMousedown: function(ev) {
10600 if (isPrimaryMouseButton(ev)) {
10601 this.processUnselect(ev);
10602 }
10603 },
10604
10605
10606 processUnselect: function(ev) {
10607 this.processRangeUnselect(ev);
10608 this.processEventUnselect(ev);
10609 },
10610
10611
10612 processRangeUnselect: function(ev) {
10613 var ignore;
10614
10615 // is there a time-range selection?
10616 if (this.isSelected && this.opt('unselectAuto')) {
10617 // only unselect if the clicked element is not identical to or inside of an 'unselectCancel' element
10618 ignore = this.opt('unselectCancel');
10619 if (!ignore || !$(ev.target).closest(ignore).length) {
10620 this.unselect(ev);
10621 }
10622 }
10623 },
10624
10625
10626 processEventUnselect: function(ev) {
10627 if (this.selectedEventInstance) {
10628 if (!$(ev.target).closest('.fc-selected').length) {
10629 this.unselectEventInstance();
10630 }
10631 }
10632 },
10633
10634
10635 /* Day Click
10636 ------------------------------------------------------------------------------------------------------------------*/
10637
10638
10639 // Triggers handlers to 'dayClick'
10640 // Span has start/end of the clicked area. Only the start is useful.
10641 triggerDayClick: function(footprint, dayEl, ev) {
10642 var dateProfile = this.calendar.footprintToDateProfile(footprint); // abuse of "Event"DateProfile?
10643
10644 this.publiclyTrigger('dayClick', {
10645 context: dayEl,
10646 args: [ dateProfile.start, ev, this ]
10647 });
10648 }
10649
10650 });
10651
10652
10653 View.watch('displayingDates', [ 'dateProfile' ], function(deps) {
10654 this.requestDateRender(deps.dateProfile);
10655 }, function() {
10656 this.requestDateUnrender();
10657 });
10658
10659
10660 View.watch('initialEvents', [ 'dateProfile' ], function(deps) {
10661 return this.fetchInitialEvents(deps.dateProfile);
10662 });
10663
10664
10665 View.watch('bindingEvents', [ 'initialEvents' ], function(deps) {
10666 this.setEvents(deps.initialEvents);
10667 this.bindEventChanges();
10668 }, function() {
10669 this.unbindEventChanges();
10670 this.unsetEvents();
10671 });
10672
10673
10674 View.watch('displayingEvents', [ 'displayingDates', 'hasEvents' ], function() {
10675 this.requestEventsRender(this.get('currentEvents')); // if there were event mutations after initialEvents
10676 }, function() {
10677 this.requestEventsUnrender();
10678 });
10679
10680
10681 function convertEventsPayloadToLegacyArray(eventsPayload) {
10682 var legacyEvents = [];
10683 var id;
10684 var eventInstances;
10685 var i;
10686
10687 for (id in eventsPayload) {
10688
10689 eventInstances = eventsPayload[id].eventInstances;
10690
10691 for (i = 0; i < eventInstances.length; i++) {
10692 legacyEvents.push(
10693 eventInstances[i].toLegacy()
10694 );
10695 }
10696 }
10697
10698 return legacyEvents;
10699 }
10700
10701 ;;
10702
10703 View.mixin({
10704
10705 // range the view is formally responsible for.
10706 // for example, a month view might have 1st-31st, excluding padded dates
10707 currentUnzonedRange: null,
10708 currentRangeUnit: null, // name of largest unit being displayed, like "month" or "week"
10709
10710 isRangeAllDay: false,
10711
10712 // date range with a rendered skeleton
10713 // includes not-active days that need some sort of DOM
10714 renderUnzonedRange: null,
10715
10716 // dates that display events and accept drag-n-drop
10717 activeUnzonedRange: null,
10718
10719 // constraint for where prev/next operations can go and where events can be dragged/resized to.
10720 // an object with optional start and end properties.
10721 validUnzonedRange: null,
10722
10723 // how far the current date will move for a prev/next operation
10724 dateIncrement: null,
10725
10726 minTime: null, // Duration object that denotes the first visible time of any given day
10727 maxTime: null, // Duration object that denotes the exclusive visible end time of any given day
10728 usesMinMaxTime: false, // whether minTime/maxTime will affect the activeUnzonedRange. Views must opt-in.
10729
10730 // DEPRECATED
10731 start: null, // use activeUnzonedRange
10732 end: null, // use activeUnzonedRange
10733 intervalStart: null, // use currentUnzonedRange
10734 intervalEnd: null, // use currentUnzonedRange
10735
10736
10737 /* Date Range Computation
10738 ------------------------------------------------------------------------------------------------------------------*/
10739
10740
10741 setDateProfileForRendering: function(dateProfile) {
10742 var calendar = this.calendar;
10743
10744 this.currentUnzonedRange = dateProfile.currentUnzonedRange;
10745 this.currentRangeUnit = dateProfile.currentRangeUnit;
10746 this.isRangeAllDay = dateProfile.isRangeAllDay;
10747 this.renderUnzonedRange = dateProfile.renderUnzonedRange;
10748 this.activeUnzonedRange = dateProfile.activeUnzonedRange;
10749 this.validUnzonedRange = dateProfile.validUnzonedRange;
10750 this.dateIncrement = dateProfile.dateIncrement;
10751 this.minTime = dateProfile.minTime;
10752 this.maxTime = dateProfile.maxTime;
10753
10754 // DEPRECATED, but we need to keep it updated...
10755 this.start = calendar.msToMoment(dateProfile.activeUnzonedRange.startMs, this.isRangeAllDay);
10756 this.end = calendar.msToMoment(dateProfile.activeUnzonedRange.endMs, this.isRangeAllDay);
10757 this.intervalStart = calendar.msToMoment(dateProfile.currentUnzonedRange.startMs, this.isRangeAllDay);
10758 this.intervalEnd = calendar.msToMoment(dateProfile.currentUnzonedRange.endMs, this.isRangeAllDay);
10759
10760 this.title = this.computeTitle();
10761 this.calendar.reportViewDatesChanged(this, dateProfile);
10762 },
10763
10764
10765 // Builds a structure with info about what the dates/ranges will be for the "prev" view.
10766 buildPrevDateProfile: function(date) {
10767 var prevDate = date.clone().startOf(this.currentRangeUnit).subtract(this.dateIncrement);
10768
10769 return this.buildDateProfile(prevDate, -1);
10770 },
10771
10772
10773 // Builds a structure with info about what the dates/ranges will be for the "next" view.
10774 buildNextDateProfile: function(date) {
10775 var nextDate = date.clone().startOf(this.currentRangeUnit).add(this.dateIncrement);
10776
10777 return this.buildDateProfile(nextDate, 1);
10778 },
10779
10780
10781 // Builds a structure holding dates/ranges for rendering around the given date.
10782 // Optional direction param indicates whether the date is being incremented/decremented
10783 // from its previous value. decremented = -1, incremented = 1 (default).
10784 buildDateProfile: function(date, direction, forceToValid) {
10785 var isDateAllDay = !date.hasTime();
10786 var validUnzonedRange = this.buildValidRange();
10787 var minTime = null;
10788 var maxTime = null;
10789 var currentInfo;
10790 var renderUnzonedRange;
10791 var activeUnzonedRange;
10792 var isValid;
10793
10794 if (forceToValid) {
10795 date = this.calendar.msToUtcMoment(
10796 validUnzonedRange.constrainDate(date), // returns MS
10797 isDateAllDay
10798 );
10799 }
10800
10801 currentInfo = this.buildCurrentRangeInfo(date, direction);
10802 renderUnzonedRange = this.buildRenderRange(currentInfo.unzonedRange, currentInfo.unit);
10803 activeUnzonedRange = renderUnzonedRange.clone();
10804
10805 if (!this.opt('showNonCurrentDates')) {
10806 activeUnzonedRange = activeUnzonedRange.intersect(currentInfo.unzonedRange);
10807 }
10808
10809 minTime = moment.duration(this.opt('minTime'));
10810 maxTime = moment.duration(this.opt('maxTime'));
10811 activeUnzonedRange = this.adjustActiveRange(activeUnzonedRange, minTime, maxTime);
10812
10813 activeUnzonedRange = activeUnzonedRange.intersect(validUnzonedRange);
10814
10815 if (activeUnzonedRange) {
10816 date = this.calendar.msToUtcMoment(
10817 activeUnzonedRange.constrainDate(date), // returns MS
10818 isDateAllDay
10819 );
10820 }
10821
10822 // it's invalid if the originally requested date is not contained,
10823 // or if the range is completely outside of the valid range.
10824 isValid = currentInfo.unzonedRange.intersectsWith(validUnzonedRange);
10825
10826 return {
10827 validUnzonedRange: validUnzonedRange,
10828 currentUnzonedRange: currentInfo.unzonedRange,
10829 currentRangeUnit: currentInfo.unit,
10830 isRangeAllDay: /^(year|month|week|day)$/.test(currentInfo.unit),
10831 activeUnzonedRange: activeUnzonedRange,
10832 renderUnzonedRange: renderUnzonedRange,
10833 minTime: minTime,
10834 maxTime: maxTime,
10835 isValid: isValid,
10836 date: date,
10837 dateIncrement: this.buildDateIncrement(currentInfo.duration)
10838 // pass a fallback (might be null) ^
10839 };
10840 },
10841
10842
10843 // Builds an object with optional start/end properties.
10844 // Indicates the minimum/maximum dates to display.
10845 buildValidRange: function() {
10846 return this.getUnzonedRangeOption('validRange', this.calendar.getNow()) ||
10847 new UnzonedRange(); // completely open-ended
10848 },
10849
10850
10851 // Builds a structure with info about the "current" range, the range that is
10852 // highlighted as being the current month for example.
10853 // See buildDateProfile for a description of `direction`.
10854 // Guaranteed to have `range` and `unit` properties. `duration` is optional.
10855 // TODO: accept a MS-time instead of a moment `date`?
10856 buildCurrentRangeInfo: function(date, direction) {
10857 var duration = null;
10858 var unit = null;
10859 var unzonedRange = null;
10860 var dayCount;
10861
10862 if (this.viewSpec.duration) {
10863 duration = this.viewSpec.duration;
10864 unit = this.viewSpec.durationUnit;
10865 unzonedRange = this.buildRangeFromDuration(date, direction, duration, unit);
10866 }
10867 else if ((dayCount = this.opt('dayCount'))) {
10868 unit = 'day';
10869 unzonedRange = this.buildRangeFromDayCount(date, direction, dayCount);
10870 }
10871 else if ((unzonedRange = this.buildCustomVisibleRange(date))) {
10872 unit = computeGreatestUnit(unzonedRange.getStart(), unzonedRange.getEnd());
10873 }
10874 else {
10875 duration = this.getFallbackDuration();
10876 unit = computeGreatestUnit(duration);
10877 unzonedRange = this.buildRangeFromDuration(date, direction, duration, unit);
10878 }
10879
10880 return { duration: duration, unit: unit, unzonedRange: unzonedRange };
10881 },
10882
10883
10884 getFallbackDuration: function() {
10885 return moment.duration({ days: 1 });
10886 },
10887
10888
10889 // Returns a new activeUnzonedRange to have time values (un-ambiguate)
10890 // minTime or maxTime causes the range to expand.
10891 adjustActiveRange: function(unzonedRange, minTime, maxTime) {
10892 var start = unzonedRange.getStart();
10893 var end = unzonedRange.getEnd();
10894
10895 if (this.usesMinMaxTime) {
10896
10897 if (minTime < 0) {
10898 start.time(0).add(minTime);
10899 }
10900
10901 if (maxTime > 24 * 60 * 60 * 1000) { // beyond 24 hours?
10902 end.time(maxTime - (24 * 60 * 60 * 1000));
10903 }
10904 }
10905
10906 return new UnzonedRange(start, end);
10907 },
10908
10909
10910 // Builds the "current" range when it is specified as an explicit duration.
10911 // `unit` is the already-computed computeGreatestUnit value of duration.
10912 // TODO: accept a MS-time instead of a moment `date`?
10913 buildRangeFromDuration: function(date, direction, duration, unit) {
10914 var alignment = this.opt('dateAlignment');
10915 var start = date.clone();
10916 var end;
10917 var dateIncrementInput;
10918 var dateIncrementDuration;
10919
10920 // if the view displays a single day or smaller
10921 if (duration.as('days') <= 1) {
10922 if (this.isHiddenDay(start)) {
10923 start = this.skipHiddenDays(start, direction);
10924 start.startOf('day');
10925 }
10926 }
10927
10928 // compute what the alignment should be
10929 if (!alignment) {
10930 dateIncrementInput = this.opt('dateIncrement');
10931
10932 if (dateIncrementInput) {
10933 dateIncrementDuration = moment.duration(dateIncrementInput);
10934
10935 // use the smaller of the two units
10936 if (dateIncrementDuration < duration) {
10937 alignment = computeDurationGreatestUnit(dateIncrementDuration, dateIncrementInput);
10938 }
10939 else {
10940 alignment = unit;
10941 }
10942 }
10943 else {
10944 alignment = unit;
10945 }
10946 }
10947
10948 start.startOf(alignment);
10949 end = start.clone().add(duration);
10950
10951 return new UnzonedRange(start, end);
10952 },
10953
10954
10955 // Builds the "current" range when a dayCount is specified.
10956 // TODO: accept a MS-time instead of a moment `date`?
10957 buildRangeFromDayCount: function(date, direction, dayCount) {
10958 var customAlignment = this.opt('dateAlignment');
10959 var runningCount = 0;
10960 var start = date.clone();
10961 var end;
10962
10963 if (customAlignment) {
10964 start.startOf(customAlignment);
10965 }
10966
10967 start.startOf('day');
10968 start = this.skipHiddenDays(start, direction);
10969
10970 end = start.clone();
10971 do {
10972 end.add(1, 'day');
10973 if (!this.isHiddenDay(end)) {
10974 runningCount++;
10975 }
10976 } while (runningCount < dayCount);
10977
10978 return new UnzonedRange(start, end);
10979 },
10980
10981
10982 // Builds a normalized range object for the "visible" range,
10983 // which is a way to define the currentUnzonedRange and activeUnzonedRange at the same time.
10984 // TODO: accept a MS-time instead of a moment `date`?
10985 buildCustomVisibleRange: function(date) {
10986 var visibleUnzonedRange = this.getUnzonedRangeOption(
10987 'visibleRange',
10988 this.calendar.applyTimezone(date) // correct zone. also generates new obj that avoids mutations
10989 );
10990
10991 if (visibleUnzonedRange && (visibleUnzonedRange.startMs === null || visibleUnzonedRange.endMs === null)) {
10992 return null;
10993 }
10994
10995 return visibleUnzonedRange;
10996 },
10997
10998
10999 // Computes the range that will represent the element/cells for *rendering*,
11000 // but which may have voided days/times.
11001 buildRenderRange: function(currentUnzonedRange, currentRangeUnit) {
11002 // cut off days in the currentUnzonedRange that are hidden
11003 return this.trimHiddenDays(currentUnzonedRange);
11004 },
11005
11006
11007 // Compute the duration value that should be added/substracted to the current date
11008 // when a prev/next operation happens.
11009 buildDateIncrement: function(fallback) {
11010 var dateIncrementInput = this.opt('dateIncrement');
11011 var customAlignment;
11012
11013 if (dateIncrementInput) {
11014 return moment.duration(dateIncrementInput);
11015 }
11016 else if ((customAlignment = this.opt('dateAlignment'))) {
11017 return moment.duration(1, customAlignment);
11018 }
11019 else if (fallback) {
11020 return fallback;
11021 }
11022 else {
11023 return moment.duration({ days: 1 });
11024 }
11025 },
11026
11027
11028 // Remove days from the beginning and end of the range that are computed as hidden.
11029 trimHiddenDays: function(inputUnzonedRange) {
11030 var start = inputUnzonedRange.getStart();
11031 var end = inputUnzonedRange.getEnd();
11032
11033 start = this.skipHiddenDays(start);
11034 end = this.skipHiddenDays(end, -1, true);
11035
11036 return new UnzonedRange(start, end);
11037 },
11038
11039
11040 // Compute the number of the give units in the "current" range.
11041 // Will return a floating-point number. Won't round.
11042 currentRangeAs: function(unit) {
11043 var currentUnzonedRange = this.currentUnzonedRange;
11044
11045 return moment.utc(currentUnzonedRange.endMs).diff(
11046 moment.utc(currentUnzonedRange.startMs),
11047 unit,
11048 true
11049 );
11050 },
11051
11052
11053 // For ChronoComponent::getDayClasses
11054 isDateInOtherMonth: function(date) {
11055 return false;
11056 },
11057
11058
11059 // Arguments after name will be forwarded to a hypothetical function value
11060 // WARNING: passed-in arguments will be given to generator functions as-is and can cause side-effects.
11061 // Always clone your objects if you fear mutation.
11062 getUnzonedRangeOption: function(name) {
11063 var val = this.opt(name);
11064
11065 if (typeof val === 'function') {
11066 val = val.apply(
11067 null,
11068 Array.prototype.slice.call(arguments, 1)
11069 );
11070 }
11071
11072 if (val) {
11073 return this.calendar.parseUnzonedRange(val);
11074 }
11075 },
11076
11077
11078 /* Hidden Days
11079 ------------------------------------------------------------------------------------------------------------------*/
11080
11081
11082 // Initializes internal variables related to calculating hidden days-of-week
11083 initHiddenDays: function() {
11084 var hiddenDays = this.opt('hiddenDays') || []; // array of day-of-week indices that are hidden
11085 var isHiddenDayHash = []; // is the day-of-week hidden? (hash with day-of-week-index -> bool)
11086 var dayCnt = 0;
11087 var i;
11088
11089 if (this.opt('weekends') === false) {
11090 hiddenDays.push(0, 6); // 0=sunday, 6=saturday
11091 }
11092
11093 for (i = 0; i < 7; i++) {
11094 if (
11095 !(isHiddenDayHash[i] = $.inArray(i, hiddenDays) !== -1)
11096 ) {
11097 dayCnt++;
11098 }
11099 }
11100
11101 if (!dayCnt) {
11102 throw 'invalid hiddenDays'; // all days were hidden? bad.
11103 }
11104
11105 this.isHiddenDayHash = isHiddenDayHash;
11106 },
11107
11108
11109 // Is the current day hidden?
11110 // `day` is a day-of-week index (0-6), or a Moment
11111 isHiddenDay: function(day) {
11112 if (moment.isMoment(day)) {
11113 day = day.day();
11114 }
11115 return this.isHiddenDayHash[day];
11116 },
11117
11118
11119 // Incrementing the current day until it is no longer a hidden day, returning a copy.
11120 // DOES NOT CONSIDER validUnzonedRange!
11121 // If the initial value of `date` is not a hidden day, don't do anything.
11122 // Pass `isExclusive` as `true` if you are dealing with an end date.
11123 // `inc` defaults to `1` (increment one day forward each time)
11124 skipHiddenDays: function(date, inc, isExclusive) {
11125 var out = date.clone();
11126 inc = inc || 1;
11127 while (
11128 this.isHiddenDayHash[(out.day() + (isExclusive ? inc : 0) + 7) % 7]
11129 ) {
11130 out.add(inc, 'days');
11131 }
11132 return out;
11133 }
11134
11135 });
11136
11137 ;;
11138
11139 /*
11140 Embodies a div that has potential scrollbars
11141 */
11142 var Scroller = FC.Scroller = Class.extend({
11143
11144 el: null, // the guaranteed outer element
11145 scrollEl: null, // the element with the scrollbars
11146 overflowX: null,
11147 overflowY: null,
11148
11149
11150 constructor: function(options) {
11151 options = options || {};
11152 this.overflowX = options.overflowX || options.overflow || 'auto';
11153 this.overflowY = options.overflowY || options.overflow || 'auto';
11154 },
11155
11156
11157 render: function() {
11158 this.el = this.renderEl();
11159 this.applyOverflow();
11160 },
11161
11162
11163 renderEl: function() {
11164 return (this.scrollEl = $('<div class="fc-scroller"></div>'));
11165 },
11166
11167
11168 // sets to natural height, unlocks overflow
11169 clear: function() {
11170 this.setHeight('auto');
11171 this.applyOverflow();
11172 },
11173
11174
11175 destroy: function() {
11176 this.el.remove();
11177 },
11178
11179
11180 // Overflow
11181 // -----------------------------------------------------------------------------------------------------------------
11182
11183
11184 applyOverflow: function() {
11185 this.scrollEl.css({
11186 'overflow-x': this.overflowX,
11187 'overflow-y': this.overflowY
11188 });
11189 },
11190
11191
11192 // Causes any 'auto' overflow values to resolves to 'scroll' or 'hidden'.
11193 // Useful for preserving scrollbar widths regardless of future resizes.
11194 // Can pass in scrollbarWidths for optimization.
11195 lockOverflow: function(scrollbarWidths) {
11196 var overflowX = this.overflowX;
11197 var overflowY = this.overflowY;
11198
11199 scrollbarWidths = scrollbarWidths || this.getScrollbarWidths();
11200
11201 if (overflowX === 'auto') {
11202 overflowX = (
11203 scrollbarWidths.top || scrollbarWidths.bottom || // horizontal scrollbars?
11204 // OR scrolling pane with massless scrollbars?
11205 this.scrollEl[0].scrollWidth - 1 > this.scrollEl[0].clientWidth
11206 // subtract 1 because of IE off-by-one issue
11207 ) ? 'scroll' : 'hidden';
11208 }
11209
11210 if (overflowY === 'auto') {
11211 overflowY = (
11212 scrollbarWidths.left || scrollbarWidths.right || // vertical scrollbars?
11213 // OR scrolling pane with massless scrollbars?
11214 this.scrollEl[0].scrollHeight - 1 > this.scrollEl[0].clientHeight
11215 // subtract 1 because of IE off-by-one issue
11216 ) ? 'scroll' : 'hidden';
11217 }
11218
11219 this.scrollEl.css({ 'overflow-x': overflowX, 'overflow-y': overflowY });
11220 },
11221
11222
11223 // Getters / Setters
11224 // -----------------------------------------------------------------------------------------------------------------
11225
11226
11227 setHeight: function(height) {
11228 this.scrollEl.height(height);
11229 },
11230
11231
11232 getScrollTop: function() {
11233 return this.scrollEl.scrollTop();
11234 },
11235
11236
11237 setScrollTop: function(top) {
11238 this.scrollEl.scrollTop(top);
11239 },
11240
11241
11242 getClientWidth: function() {
11243 return this.scrollEl[0].clientWidth;
11244 },
11245
11246
11247 getClientHeight: function() {
11248 return this.scrollEl[0].clientHeight;
11249 },
11250
11251
11252 getScrollbarWidths: function() {
11253 return getScrollbarWidths(this.scrollEl);
11254 }
11255
11256 });
11257
11258 ;;
11259 function Iterator(items) {
11260 this.items = items || [];
11261 }
11262
11263
11264 /* Calls a method on every item passing the arguments through */
11265 Iterator.prototype.proxyCall = function(methodName) {
11266 var args = Array.prototype.slice.call(arguments, 1);
11267 var results = [];
11268
11269 this.items.forEach(function(item) {
11270 results.push(item[methodName].apply(item, args));
11271 });
11272
11273 return results;
11274 };
11275
11276 ;;
11277
11278 /* Toolbar with buttons and title
11279 ----------------------------------------------------------------------------------------------------------------------*/
11280
11281 function Toolbar(calendar, toolbarOptions) {
11282 var t = this;
11283
11284 // exports
11285 t.setToolbarOptions = setToolbarOptions;
11286 t.render = render;
11287 t.removeElement = removeElement;
11288 t.updateTitle = updateTitle;
11289 t.activateButton = activateButton;
11290 t.deactivateButton = deactivateButton;
11291 t.disableButton = disableButton;
11292 t.enableButton = enableButton;
11293 t.getViewsWithButtons = getViewsWithButtons;
11294 t.el = null; // mirrors local `el`
11295
11296 // locals
11297 var el;
11298 var viewsWithButtons = [];
11299
11300 // method to update toolbar-specific options, not calendar-wide options
11301 function setToolbarOptions(newToolbarOptions) {
11302 toolbarOptions = newToolbarOptions;
11303 }
11304
11305 // can be called repeatedly and will rerender
11306 function render() {
11307 var sections = toolbarOptions.layout;
11308
11309 if (sections) {
11310 if (!el) {
11311 el = this.el = $("<div class='fc-toolbar "+ toolbarOptions.extraClasses + "'/>");
11312 }
11313 else {
11314 el.empty();
11315 }
11316 el.append(renderSection('left'))
11317 .append(renderSection('right'))
11318 .append(renderSection('center'))
11319 .append('<div class="fc-clear"/>');
11320 }
11321 else {
11322 removeElement();
11323 }
11324 }
11325
11326
11327 function removeElement() {
11328 if (el) {
11329 el.remove();
11330 el = t.el = null;
11331 }
11332 }
11333
11334
11335 function renderSection(position) {
11336 var theme = calendar.theme;
11337 var sectionEl = $('<div class="fc-' + position + '"/>');
11338 var buttonStr = toolbarOptions.layout[position];
11339 var calendarCustomButtons = calendar.opt('customButtons') || {};
11340 var calendarButtonTextOverrides = calendar.overrides.buttonText || {};
11341 var calendarButtonText = calendar.opt('buttonText') || {};
11342
11343 if (buttonStr) {
11344 $.each(buttonStr.split(' '), function(i) {
11345 var groupChildren = $();
11346 var isOnlyButtons = true;
11347 var groupEl;
11348
11349 $.each(this.split(','), function(j, buttonName) {
11350 var customButtonProps;
11351 var viewSpec;
11352 var buttonClick;
11353 var buttonIcon; // only one of these will be set
11354 var buttonText; // "
11355 var buttonInnerHtml;
11356 var buttonClasses;
11357 var buttonEl;
11358
11359 if (buttonName == 'title') {
11360 groupChildren = groupChildren.add($('<h2>&nbsp;</h2>')); // we always want it to take up height
11361 isOnlyButtons = false;
11362 }
11363 else {
11364
11365 if ((customButtonProps = calendarCustomButtons[buttonName])) {
11366 buttonClick = function(ev) {
11367 if (customButtonProps.click) {
11368 customButtonProps.click.call(buttonEl[0], ev);
11369 }
11370 };
11371 (buttonIcon = theme.getCustomButtonIconClass(customButtonProps)) ||
11372 (buttonIcon = theme.getIconClass(buttonName)) ||
11373 (buttonText = customButtonProps.text); // jshint ignore:line
11374 }
11375 else if ((viewSpec = calendar.getViewSpec(buttonName))) {
11376 viewsWithButtons.push(buttonName);
11377 buttonClick = function() {
11378 calendar.changeView(buttonName);
11379 };
11380 (buttonText = viewSpec.buttonTextOverride) ||
11381 (buttonIcon = theme.getIconClass(buttonName)) ||
11382 (buttonText = viewSpec.buttonTextDefault); // jshint ignore:line
11383 }
11384 else if (calendar[buttonName]) { // a calendar method
11385 buttonClick = function() {
11386 calendar[buttonName]();
11387 };
11388 (buttonText = calendarButtonTextOverrides[buttonName]) ||
11389 (buttonIcon = theme.getIconClass(buttonName)) ||
11390 (buttonText = calendarButtonText[buttonName]); // jshint ignore:line
11391 // ^ everything else is considered default
11392 }
11393
11394 if (buttonClick) {
11395
11396 buttonClasses = [
11397 'fc-' + buttonName + '-button',
11398 theme.getClass('button'),
11399 theme.getClass('stateDefault')
11400 ];
11401
11402 if (buttonText) {
11403 buttonInnerHtml = htmlEscape(buttonText);
11404 }
11405 else if (buttonIcon) {
11406 buttonInnerHtml = "<span class='" + buttonIcon + "'></span>";
11407 }
11408
11409 buttonEl = $( // type="button" so that it doesn't submit a form
11410 '<button type="button" class="' + buttonClasses.join(' ') + '">' +
11411 buttonInnerHtml +
11412 '</button>'
11413 )
11414 .click(function(ev) {
11415 // don't process clicks for disabled buttons
11416 if (!buttonEl.hasClass(theme.getClass('stateDisabled'))) {
11417
11418 buttonClick(ev);
11419
11420 // after the click action, if the button becomes the "active" tab, or disabled,
11421 // it should never have a hover class, so remove it now.
11422 if (
11423 buttonEl.hasClass(theme.getClass('stateActive')) ||
11424 buttonEl.hasClass(theme.getClass('stateDisabled'))
11425 ) {
11426 buttonEl.removeClass(theme.getClass('stateHover'));
11427 }
11428 }
11429 })
11430 .mousedown(function() {
11431 // the *down* effect (mouse pressed in).
11432 // only on buttons that are not the "active" tab, or disabled
11433 buttonEl
11434 .not('.' + theme.getClass('stateActive'))
11435 .not('.' + theme.getClass('stateDisabled'))
11436 .addClass(theme.getClass('stateDown'));
11437 })
11438 .mouseup(function() {
11439 // undo the *down* effect
11440 buttonEl.removeClass(theme.getClass('stateDown'));
11441 })
11442 .hover(
11443 function() {
11444 // the *hover* effect.
11445 // only on buttons that are not the "active" tab, or disabled
11446 buttonEl
11447 .not('.' + theme.getClass('stateActive'))
11448 .not('.' + theme.getClass('stateDisabled'))
11449 .addClass(theme.getClass('stateHover'));
11450 },
11451 function() {
11452 // undo the *hover* effect
11453 buttonEl
11454 .removeClass(theme.getClass('stateHover'))
11455 .removeClass(theme.getClass('stateDown')); // if mouseleave happens before mouseup
11456 }
11457 );
11458
11459 groupChildren = groupChildren.add(buttonEl);
11460 }
11461 }
11462 });
11463
11464 if (isOnlyButtons) {
11465 groupChildren
11466 .first().addClass(theme.getClass('cornerLeft')).end()
11467 .last().addClass(theme.getClass('cornerRight')).end();
11468 }
11469
11470 if (groupChildren.length > 1) {
11471 groupEl = $('<div/>');
11472 if (isOnlyButtons) {
11473 groupEl.addClass(theme.getClass('buttonGroup'));
11474 }
11475 groupEl.append(groupChildren);
11476 sectionEl.append(groupEl);
11477 }
11478 else {
11479 sectionEl.append(groupChildren); // 1 or 0 children
11480 }
11481 });
11482 }
11483
11484 return sectionEl;
11485 }
11486
11487
11488 function updateTitle(text) {
11489 if (el) {
11490 el.find('h2').text(text);
11491 }
11492 }
11493
11494
11495 function activateButton(buttonName) {
11496 if (el) {
11497 el.find('.fc-' + buttonName + '-button')
11498 .addClass(calendar.theme.getClass('stateActive'));
11499 }
11500 }
11501
11502
11503 function deactivateButton(buttonName) {
11504 if (el) {
11505 el.find('.fc-' + buttonName + '-button')
11506 .removeClass(calendar.theme.getClass('stateActive'));
11507 }
11508 }
11509
11510
11511 function disableButton(buttonName) {
11512 if (el) {
11513 el.find('.fc-' + buttonName + '-button')
11514 .prop('disabled', true)
11515 .addClass(calendar.theme.getClass('stateDisabled'));
11516 }
11517 }
11518
11519
11520 function enableButton(buttonName) {
11521 if (el) {
11522 el.find('.fc-' + buttonName + '-button')
11523 .prop('disabled', false)
11524 .removeClass(calendar.theme.getClass('stateDisabled'));
11525 }
11526 }
11527
11528
11529 function getViewsWithButtons() {
11530 return viewsWithButtons;
11531 }
11532
11533 }
11534
11535 ;;
11536
11537 var Calendar = FC.Calendar = Class.extend(EmitterMixin, {
11538
11539 view: null, // current View object
11540 viewsByType: null, // holds all instantiated view instances, current or not
11541 currentDate: null, // unzoned moment. private (public API should use getDate instead)
11542 theme: null,
11543 loadingLevel: 0, // number of simultaneous loading tasks
11544
11545
11546 constructor: function(el, overrides) {
11547
11548 // declare the current calendar instance relies on GlobalEmitter. needed for garbage collection.
11549 // unneeded() is called in destroy.
11550 GlobalEmitter.needed();
11551
11552 this.el = el;
11553 this.viewsByType = {};
11554 this.viewSpecCache = {};
11555
11556 this.initOptionsInternals(overrides);
11557 this.initMomentInternals(); // needs to happen after options hash initialized
11558 this.initCurrentDate();
11559 this.initEventManager();
11560
11561 EventManager.call(this); // needs options immediately
11562 this.initialize();
11563 },
11564
11565
11566 // Subclasses can override this for initialization logic after the constructor has been called
11567 initialize: function() {
11568 },
11569
11570
11571 // Public API
11572 // -----------------------------------------------------------------------------------------------------------------
11573
11574
11575 getView: function() {
11576 return this.view;
11577 },
11578
11579
11580 publiclyTrigger: function(name, triggerInfo) {
11581 var optHandler = this.opt(name);
11582 var context;
11583 var args;
11584
11585 if ($.isPlainObject(triggerInfo)) {
11586 context = triggerInfo.context;
11587 args = triggerInfo.args;
11588 }
11589 else if ($.isArray(triggerInfo)) {
11590 args = triggerInfo;
11591 }
11592
11593 if (context == null) {
11594 context = this.el[0]; // fallback context
11595 }
11596
11597 if (!args) {
11598 args = [];
11599 }
11600
11601 this.triggerWith(name, context, args); // Emitter's method
11602
11603 if (optHandler) {
11604 return optHandler.apply(context, args);
11605 }
11606 },
11607
11608
11609 hasPublicHandlers: function(name) {
11610 return this.hasHandlers(name) ||
11611 this.opt(name); // handler specified in options
11612 },
11613
11614
11615 // View
11616 // -----------------------------------------------------------------------------------------------------------------
11617
11618
11619 // Given a view name for a custom view or a standard view, creates a ready-to-go View object
11620 instantiateView: function(viewType) {
11621 var spec = this.getViewSpec(viewType);
11622
11623 return new spec['class'](this, spec);
11624 },
11625
11626
11627 // Returns a boolean about whether the view is okay to instantiate at some point
11628 isValidViewType: function(viewType) {
11629 return Boolean(this.getViewSpec(viewType));
11630 },
11631
11632
11633 changeView: function(viewName, dateOrRange) {
11634
11635 if (dateOrRange) {
11636
11637 if (dateOrRange.start && dateOrRange.end) { // a range
11638 this.recordOptionOverrides({ // will not rerender
11639 visibleRange: dateOrRange
11640 });
11641 }
11642 else { // a date
11643 this.currentDate = this.moment(dateOrRange).stripZone(); // just like gotoDate
11644 }
11645 }
11646
11647 this.renderView(viewName);
11648 },
11649
11650
11651 // Forces navigation to a view for the given date.
11652 // `viewType` can be a specific view name or a generic one like "week" or "day".
11653 zoomTo: function(newDate, viewType) {
11654 var spec;
11655
11656 viewType = viewType || 'day'; // day is default zoom
11657 spec = this.getViewSpec(viewType) || this.getUnitViewSpec(viewType);
11658
11659 this.currentDate = newDate.clone();
11660 this.renderView(spec ? spec.type : null);
11661 },
11662
11663
11664 // Current Date
11665 // -----------------------------------------------------------------------------------------------------------------
11666
11667
11668 initCurrentDate: function() {
11669 var defaultDateInput = this.opt('defaultDate');
11670
11671 // compute the initial ambig-timezone date
11672 if (defaultDateInput != null) {
11673 this.currentDate = this.moment(defaultDateInput).stripZone();
11674 }
11675 else {
11676 this.currentDate = this.getNow(); // getNow already returns unzoned
11677 }
11678 },
11679
11680
11681 reportViewDatesChanged: function(view, dateProfile) {
11682 this.currentDate = dateProfile.date; // might have been constrained by view dates
11683 this.setToolbarsTitle(view.title);
11684 this.updateToolbarButtons();
11685 },
11686
11687
11688 prev: function() {
11689 var prevInfo = this.view.buildPrevDateProfile(this.currentDate);
11690
11691 if (prevInfo.isValid) {
11692 this.currentDate = prevInfo.date;
11693 this.renderView();
11694 }
11695 },
11696
11697
11698 next: function() {
11699 var nextInfo = this.view.buildNextDateProfile(this.currentDate);
11700
11701 if (nextInfo.isValid) {
11702 this.currentDate = nextInfo.date;
11703 this.renderView();
11704 }
11705 },
11706
11707
11708 prevYear: function() {
11709 this.currentDate.add(-1, 'years');
11710 this.renderView();
11711 },
11712
11713
11714 nextYear: function() {
11715 this.currentDate.add(1, 'years');
11716 this.renderView();
11717 },
11718
11719
11720 today: function() {
11721 this.currentDate = this.getNow(); // should deny like prev/next?
11722 this.renderView();
11723 },
11724
11725
11726 gotoDate: function(zonedDateInput) {
11727 this.currentDate = this.moment(zonedDateInput).stripZone();
11728 this.renderView();
11729 },
11730
11731
11732 incrementDate: function(delta) {
11733 this.currentDate.add(moment.duration(delta));
11734 this.renderView();
11735 },
11736
11737
11738 // for external API
11739 getDate: function() {
11740 return this.applyTimezone(this.currentDate); // infuse the calendar's timezone
11741 },
11742
11743
11744 // Loading Triggering
11745 // -----------------------------------------------------------------------------------------------------------------
11746
11747
11748 // Should be called when any type of async data fetching begins
11749 pushLoading: function() {
11750 if (!(this.loadingLevel++)) {
11751 this.publiclyTrigger('loading', [ true, this.view ]);
11752 }
11753 },
11754
11755
11756 // Should be called when any type of async data fetching completes
11757 popLoading: function() {
11758 if (!(--this.loadingLevel)) {
11759 this.publiclyTrigger('loading', [ false, this.view ]);
11760 }
11761 },
11762
11763
11764 // Selection
11765 // -----------------------------------------------------------------------------------------------------------------
11766
11767
11768 // this public method receives start/end dates in any format, with any timezone
11769 select: function(zonedStartInput, zonedEndInput) {
11770 this.view.select(
11771 this.buildSelectFootprint.apply(this, arguments)
11772 );
11773 },
11774
11775
11776 unselect: function() { // safe to be called before renderView
11777 if (this.view) {
11778 this.view.unselect();
11779 }
11780 },
11781
11782
11783 // Given arguments to the select method in the API, returns a span (unzoned start/end and other info)
11784 buildSelectFootprint: function(zonedStartInput, zonedEndInput) {
11785 var start = this.moment(zonedStartInput).stripZone();
11786 var end;
11787
11788 if (zonedEndInput) {
11789 end = this.moment(zonedEndInput).stripZone();
11790 }
11791 else if (start.hasTime()) {
11792 end = start.clone().add(this.defaultTimedEventDuration);
11793 }
11794 else {
11795 end = start.clone().add(this.defaultAllDayEventDuration);
11796 }
11797
11798 return new ComponentFootprint(
11799 new UnzonedRange(start, end),
11800 !start.hasTime()
11801 );
11802 },
11803
11804
11805 // Misc
11806 // -----------------------------------------------------------------------------------------------------------------
11807
11808
11809 // will return `null` if invalid range
11810 parseUnzonedRange: function(rangeInput) {
11811 var start = null;
11812 var end = null;
11813
11814 if (rangeInput.start) {
11815 start = this.moment(rangeInput.start).stripZone();
11816 }
11817
11818 if (rangeInput.end) {
11819 end = this.moment(rangeInput.end).stripZone();
11820 }
11821
11822 if (!start && !end) {
11823 return null;
11824 }
11825
11826 if (start && end && end.isBefore(start)) {
11827 return null;
11828 }
11829
11830 return new UnzonedRange(start, end);
11831 },
11832
11833
11834 rerenderEvents: function() { // API method. destroys old events if previously rendered.
11835 if (this.elementVisible()) {
11836 this.view.flash('displayingEvents');
11837 }
11838 },
11839
11840
11841 initEventManager: function() {
11842 var _this = this;
11843 var eventManager = new EventManager(this);
11844 var rawSources = this.opt('eventSources') || [];
11845 var singleRawSource = this.opt('events');
11846
11847 this.eventManager = eventManager;
11848
11849 if (singleRawSource) {
11850 rawSources.unshift(singleRawSource);
11851 }
11852
11853 eventManager.on('release', function(eventsPayload) {
11854 _this.trigger('eventsReset', eventsPayload);
11855 });
11856
11857 eventManager.freeze();
11858
11859 rawSources.forEach(function(rawSource) {
11860 var source = EventSourceParser.parse(rawSource, _this);
11861
11862 if (source) {
11863 eventManager.addSource(source);
11864 }
11865 });
11866
11867 eventManager.thaw();
11868 },
11869
11870
11871 requestEvents: function(start, end) {
11872 return this.eventManager.requestEvents(
11873 start,
11874 end,
11875 this.opt('timezone'),
11876 !this.opt('lazyFetching')
11877 );
11878 }
11879
11880 });
11881
11882 ;;
11883 /*
11884 Options binding/triggering system.
11885 */
11886 Calendar.mixin({
11887
11888 dirDefaults: null, // option defaults related to LTR or RTL
11889 localeDefaults: null, // option defaults related to current locale
11890 overrides: null, // option overrides given to the fullCalendar constructor
11891 dynamicOverrides: null, // options set with dynamic setter method. higher precedence than view overrides.
11892 optionsModel: null, // all defaults combined with overrides
11893
11894
11895 initOptionsInternals: function(overrides) {
11896 this.overrides = $.extend({}, overrides); // make a copy
11897 this.dynamicOverrides = {};
11898 this.optionsModel = new Model();
11899
11900 this.populateOptionsHash();
11901 },
11902
11903
11904 // public getter/setter
11905 option: function(name, value) {
11906 var newOptionHash;
11907
11908 if (typeof name === 'string') {
11909 if (value === undefined) { // getter
11910 return this.optionsModel.get(name);
11911 }
11912 else { // setter for individual option
11913 newOptionHash = {};
11914 newOptionHash[name] = value;
11915 this.setOptions(newOptionHash);
11916 }
11917 }
11918 else if (typeof name === 'object') { // compound setter with object input
11919 this.setOptions(name);
11920 }
11921 },
11922
11923
11924 // private getter
11925 opt: function(name) {
11926 return this.optionsModel.get(name);
11927 },
11928
11929
11930 setOptions: function(newOptionHash) {
11931 var optionCnt = 0;
11932 var optionName;
11933
11934 this.recordOptionOverrides(newOptionHash); // will trigger optionsModel watchers
11935
11936 for (optionName in newOptionHash) {
11937 optionCnt++;
11938 }
11939
11940 // special-case handling of single option change.
11941 // if only one option change, `optionName` will be its name.
11942 if (optionCnt === 1) {
11943 if (optionName === 'height' || optionName === 'contentHeight' || optionName === 'aspectRatio') {
11944 this.updateSize(true); // true = allow recalculation of height
11945 return;
11946 }
11947 else if (optionName === 'defaultDate') {
11948 return; // can't change date this way. use gotoDate instead
11949 }
11950 else if (optionName === 'businessHours') {
11951 if (this.view) {
11952 this.view.unrenderBusinessHours();
11953 this.view.renderBusinessHours();
11954 }
11955 return;
11956 }
11957 else if (optionName === 'timezone') {
11958 this.view.flash('initialEvents');
11959 return;
11960 }
11961 }
11962
11963 // catch-all. rerender the header and footer and rebuild/rerender the current view
11964 this.renderHeader();
11965 this.renderFooter();
11966
11967 // even non-current views will be affected by this option change. do before rerender
11968 // TODO: detangle
11969 this.viewsByType = {};
11970
11971 this.reinitView();
11972 },
11973
11974
11975 // Computes the flattened options hash for the calendar and assigns to `this.options`.
11976 // Assumes this.overrides and this.dynamicOverrides have already been initialized.
11977 populateOptionsHash: function() {
11978 var locale, localeDefaults;
11979 var isRTL, dirDefaults;
11980 var rawOptions;
11981
11982 locale = firstDefined( // explicit locale option given?
11983 this.dynamicOverrides.locale,
11984 this.overrides.locale
11985 );
11986 localeDefaults = localeOptionHash[locale];
11987 if (!localeDefaults) { // explicit locale option not given or invalid?
11988 locale = Calendar.defaults.locale;
11989 localeDefaults = localeOptionHash[locale] || {};
11990 }
11991
11992 isRTL = firstDefined( // based on options computed so far, is direction RTL?
11993 this.dynamicOverrides.isRTL,
11994 this.overrides.isRTL,
11995 localeDefaults.isRTL,
11996 Calendar.defaults.isRTL
11997 );
11998 dirDefaults = isRTL ? Calendar.rtlDefaults : {};
11999
12000 this.dirDefaults = dirDefaults;
12001 this.localeDefaults = localeDefaults;
12002
12003 rawOptions = mergeOptions([ // merge defaults and overrides. lowest to highest precedence
12004 Calendar.defaults, // global defaults
12005 dirDefaults,
12006 localeDefaults,
12007 this.overrides,
12008 this.dynamicOverrides
12009 ]);
12010 populateInstanceComputableOptions(rawOptions); // fill in gaps with computed options
12011
12012 this.optionsModel.reset(rawOptions);
12013 },
12014
12015
12016 // stores the new options internally, but does not rerender anything.
12017 recordOptionOverrides: function(newOptionHash) {
12018 var optionName;
12019
12020 for (optionName in newOptionHash) {
12021 this.dynamicOverrides[optionName] = newOptionHash[optionName];
12022 }
12023
12024 this.viewSpecCache = {}; // the dynamic override invalidates the options in this cache, so just clear it
12025 this.populateOptionsHash(); // this.options needs to be recomputed after the dynamic override
12026 }
12027
12028 });
12029
12030 ;;
12031
12032 Calendar.mixin({
12033
12034 defaultAllDayEventDuration: null,
12035 defaultTimedEventDuration: null,
12036 localeData: null,
12037
12038
12039 initMomentInternals: function() {
12040 var _this = this;
12041
12042 this.defaultAllDayEventDuration = moment.duration(this.opt('defaultAllDayEventDuration'));
12043 this.defaultTimedEventDuration = moment.duration(this.opt('defaultTimedEventDuration'));
12044
12045 // Called immediately, and when any of the options change.
12046 // Happens before any internal objects rebuild or rerender, because this is very core.
12047 this.optionsModel.watch('buildingMomentLocale', [
12048 '?locale', '?monthNames', '?monthNamesShort', '?dayNames', '?dayNamesShort',
12049 '?firstDay', '?weekNumberCalculation'
12050 ], function(opts) {
12051 var weekNumberCalculation = opts.weekNumberCalculation;
12052 var firstDay = opts.firstDay;
12053 var _week;
12054
12055 // normalize
12056 if (weekNumberCalculation === 'iso') {
12057 weekNumberCalculation = 'ISO'; // normalize
12058 }
12059
12060 var localeData = Object.create( // make a cheap copy
12061 getMomentLocaleData(opts.locale) // will fall back to en
12062 );
12063
12064 if (opts.monthNames) {
12065 localeData._months = opts.monthNames;
12066 }
12067 if (opts.monthNamesShort) {
12068 localeData._monthsShort = opts.monthNamesShort;
12069 }
12070 if (opts.dayNames) {
12071 localeData._weekdays = opts.dayNames;
12072 }
12073 if (opts.dayNamesShort) {
12074 localeData._weekdaysShort = opts.dayNamesShort;
12075 }
12076
12077 if (firstDay == null && weekNumberCalculation === 'ISO') {
12078 firstDay = 1;
12079 }
12080 if (firstDay != null) {
12081 _week = Object.create(localeData._week); // _week: { dow: # }
12082 _week.dow = firstDay;
12083 localeData._week = _week;
12084 }
12085
12086 if ( // whitelist certain kinds of input
12087 weekNumberCalculation === 'ISO' ||
12088 weekNumberCalculation === 'local' ||
12089 typeof weekNumberCalculation === 'function'
12090 ) {
12091 localeData._fullCalendar_weekCalc = weekNumberCalculation; // moment-ext will know what to do with it
12092 }
12093
12094 _this.localeData = localeData;
12095
12096 // If the internal current date object already exists, move to new locale.
12097 // We do NOT need to do this technique for event dates, because this happens when converting to "segments".
12098 if (_this.currentDate) {
12099 _this.localizeMoment(_this.currentDate); // sets to localeData
12100 }
12101 });
12102 },
12103
12104
12105 // Builds a moment using the settings of the current calendar: timezone and locale.
12106 // Accepts anything the vanilla moment() constructor accepts.
12107 moment: function() {
12108 var mom;
12109
12110 if (this.opt('timezone') === 'local') {
12111 mom = FC.moment.apply(null, arguments);
12112
12113 // Force the moment to be local, because FC.moment doesn't guarantee it.
12114 if (mom.hasTime()) { // don't give ambiguously-timed moments a local zone
12115 mom.local();
12116 }
12117 }
12118 else if (this.opt('timezone') === 'UTC') {
12119 mom = FC.moment.utc.apply(null, arguments); // process as UTC
12120 }
12121 else {
12122 mom = FC.moment.parseZone.apply(null, arguments); // let the input decide the zone
12123 }
12124
12125 this.localizeMoment(mom); // TODO
12126
12127 return mom;
12128 },
12129
12130
12131 msToMoment: function(ms, forceAllDay) {
12132 var mom = FC.moment.utc(ms); // TODO: optimize by using Date.UTC
12133
12134 if (forceAllDay) {
12135 mom.stripTime();
12136 }
12137 else {
12138 mom = this.applyTimezone(mom); // may or may not apply locale
12139 }
12140
12141 this.localizeMoment(mom);
12142
12143 return mom;
12144 },
12145
12146
12147 msToUtcMoment: function(ms, forceAllDay) {
12148 var mom = FC.moment.utc(ms); // TODO: optimize by using Date.UTC
12149
12150 if (forceAllDay) {
12151 mom.stripTime();
12152 }
12153
12154 this.localizeMoment(mom);
12155
12156 return mom;
12157 },
12158
12159
12160 // Updates the given moment's locale settings to the current calendar locale settings.
12161 localizeMoment: function(mom) {
12162 mom._locale = this.localeData;
12163 },
12164
12165
12166 // Returns a boolean about whether or not the calendar knows how to calculate
12167 // the timezone offset of arbitrary dates in the current timezone.
12168 getIsAmbigTimezone: function() {
12169 return this.opt('timezone') !== 'local' && this.opt('timezone') !== 'UTC';
12170 },
12171
12172
12173 // Returns a copy of the given date in the current timezone. Has no effect on dates without times.
12174 applyTimezone: function(date) {
12175 if (!date.hasTime()) {
12176 return date.clone();
12177 }
12178
12179 var zonedDate = this.moment(date.toArray());
12180 var timeAdjust = date.time() - zonedDate.time();
12181 var adjustedZonedDate;
12182
12183 // Safari sometimes has problems with this coersion when near DST. Adjust if necessary. (bug #2396)
12184 if (timeAdjust) { // is the time result different than expected?
12185 adjustedZonedDate = zonedDate.clone().add(timeAdjust); // add milliseconds
12186 if (date.time() - adjustedZonedDate.time() === 0) { // does it match perfectly now?
12187 zonedDate = adjustedZonedDate;
12188 }
12189 }
12190
12191 return zonedDate;
12192 },
12193
12194
12195 /*
12196 Assumes the footprint is non-open-ended.
12197 */
12198 footprintToDateProfile: function(componentFootprint, ignoreEnd) {
12199 var start = FC.moment.utc(componentFootprint.unzonedRange.startMs);
12200 var end;
12201
12202 if (!ignoreEnd) {
12203 end = FC.moment.utc(componentFootprint.unzonedRange.endMs);
12204 }
12205
12206 if (componentFootprint.isAllDay) {
12207 start.stripTime();
12208
12209 if (end) {
12210 end.stripTime();
12211 }
12212 }
12213 else {
12214 start = this.applyTimezone(start);
12215
12216 if (end) {
12217 end = this.applyTimezone(end);
12218 }
12219 }
12220
12221 return new EventDateProfile(start, end, this);
12222 },
12223
12224
12225 // Returns a moment for the current date, as defined by the client's computer or from the `now` option.
12226 // Will return an moment with an ambiguous timezone.
12227 getNow: function() {
12228 var now = this.opt('now');
12229 if (typeof now === 'function') {
12230 now = now();
12231 }
12232 return this.moment(now).stripZone();
12233 },
12234
12235
12236 // Produces a human-readable string for the given duration.
12237 // Side-effect: changes the locale of the given duration.
12238 humanizeDuration: function(duration) {
12239 return duration.locale(this.opt('locale')).humanize();
12240 },
12241
12242
12243
12244 // Event-Specific Date Utilities. TODO: move
12245 // -----------------------------------------------------------------------------------------------------------------
12246
12247
12248 // Get an event's normalized end date. If not present, calculate it from the defaults.
12249 getEventEnd: function(event) {
12250 if (event.end) {
12251 return event.end.clone();
12252 }
12253 else {
12254 return this.getDefaultEventEnd(event.allDay, event.start);
12255 }
12256 },
12257
12258
12259 // Given an event's allDay status and start date, return what its fallback end date should be.
12260 // TODO: rename to computeDefaultEventEnd
12261 getDefaultEventEnd: function(allDay, zonedStart) {
12262 var end = zonedStart.clone();
12263
12264 if (allDay) {
12265 end.stripTime().add(this.defaultAllDayEventDuration);
12266 }
12267 else {
12268 end.add(this.defaultTimedEventDuration);
12269 }
12270
12271 if (this.getIsAmbigTimezone()) {
12272 end.stripZone(); // we don't know what the tzo should be
12273 }
12274
12275 return end;
12276 }
12277
12278 });
12279
12280 ;;
12281
12282 Calendar.mixin({
12283
12284 viewSpecCache: null, // cache of view definitions (initialized in Calendar.js)
12285
12286
12287 // Gets information about how to create a view. Will use a cache.
12288 getViewSpec: function(viewType) {
12289 var cache = this.viewSpecCache;
12290
12291 return cache[viewType] || (cache[viewType] = this.buildViewSpec(viewType));
12292 },
12293
12294
12295 // Given a duration singular unit, like "week" or "day", finds a matching view spec.
12296 // Preference is given to views that have corresponding buttons.
12297 getUnitViewSpec: function(unit) {
12298 var viewTypes;
12299 var i;
12300 var spec;
12301
12302 if ($.inArray(unit, unitsDesc) != -1) {
12303
12304 // put views that have buttons first. there will be duplicates, but oh well
12305 viewTypes = this.header.getViewsWithButtons(); // TODO: include footer as well?
12306 $.each(FC.views, function(viewType) { // all views
12307 viewTypes.push(viewType);
12308 });
12309
12310 for (i = 0; i < viewTypes.length; i++) {
12311 spec = this.getViewSpec(viewTypes[i]);
12312 if (spec) {
12313 if (spec.singleUnit == unit) {
12314 return spec;
12315 }
12316 }
12317 }
12318 }
12319 },
12320
12321
12322 // Builds an object with information on how to create a given view
12323 buildViewSpec: function(requestedViewType) {
12324 var viewOverrides = this.overrides.views || {};
12325 var specChain = []; // for the view. lowest to highest priority
12326 var defaultsChain = []; // for the view. lowest to highest priority
12327 var overridesChain = []; // for the view. lowest to highest priority
12328 var viewType = requestedViewType;
12329 var spec; // for the view
12330 var overrides; // for the view
12331 var durationInput;
12332 var duration;
12333 var unit;
12334
12335 // iterate from the specific view definition to a more general one until we hit an actual View class
12336 while (viewType) {
12337 spec = fcViews[viewType];
12338 overrides = viewOverrides[viewType];
12339 viewType = null; // clear. might repopulate for another iteration
12340
12341 if (typeof spec === 'function') { // TODO: deprecate
12342 spec = { 'class': spec };
12343 }
12344
12345 if (spec) {
12346 specChain.unshift(spec);
12347 defaultsChain.unshift(spec.defaults || {});
12348 durationInput = durationInput || spec.duration;
12349 viewType = viewType || spec.type;
12350 }
12351
12352 if (overrides) {
12353 overridesChain.unshift(overrides); // view-specific option hashes have options at zero-level
12354 durationInput = durationInput || overrides.duration;
12355 viewType = viewType || overrides.type;
12356 }
12357 }
12358
12359 spec = mergeProps(specChain);
12360 spec.type = requestedViewType;
12361 if (!spec['class']) {
12362 return false;
12363 }
12364
12365 // fall back to top-level `duration` option
12366 durationInput = durationInput ||
12367 this.dynamicOverrides.duration ||
12368 this.overrides.duration;
12369
12370 if (durationInput) {
12371 duration = moment.duration(durationInput);
12372
12373 if (duration.valueOf()) { // valid?
12374
12375 unit = computeDurationGreatestUnit(duration, durationInput);
12376
12377 spec.duration = duration;
12378 spec.durationUnit = unit;
12379
12380 // view is a single-unit duration, like "week" or "day"
12381 // incorporate options for this. lowest priority
12382 if (duration.as(unit) === 1) {
12383 spec.singleUnit = unit;
12384 overridesChain.unshift(viewOverrides[unit] || {});
12385 }
12386 }
12387 }
12388
12389 spec.defaults = mergeOptions(defaultsChain);
12390 spec.overrides = mergeOptions(overridesChain);
12391
12392 this.buildViewSpecOptions(spec);
12393 this.buildViewSpecButtonText(spec, requestedViewType);
12394
12395 return spec;
12396 },
12397
12398
12399 // Builds and assigns a view spec's options object from its already-assigned defaults and overrides
12400 buildViewSpecOptions: function(spec) {
12401 spec.options = mergeOptions([ // lowest to highest priority
12402 Calendar.defaults, // global defaults
12403 spec.defaults, // view's defaults (from ViewSubclass.defaults)
12404 this.dirDefaults,
12405 this.localeDefaults, // locale and dir take precedence over view's defaults!
12406 this.overrides, // calendar's overrides (options given to constructor)
12407 spec.overrides, // view's overrides (view-specific options)
12408 this.dynamicOverrides // dynamically set via setter. highest precedence
12409 ]);
12410 populateInstanceComputableOptions(spec.options);
12411 },
12412
12413
12414 // Computes and assigns a view spec's buttonText-related options
12415 buildViewSpecButtonText: function(spec, requestedViewType) {
12416
12417 // given an options object with a possible `buttonText` hash, lookup the buttonText for the
12418 // requested view, falling back to a generic unit entry like "week" or "day"
12419 function queryButtonText(options) {
12420 var buttonText = options.buttonText || {};
12421 return buttonText[requestedViewType] ||
12422 // view can decide to look up a certain key
12423 (spec.buttonTextKey ? buttonText[spec.buttonTextKey] : null) ||
12424 // a key like "month"
12425 (spec.singleUnit ? buttonText[spec.singleUnit] : null);
12426 }
12427
12428 // highest to lowest priority
12429 spec.buttonTextOverride =
12430 queryButtonText(this.dynamicOverrides) ||
12431 queryButtonText(this.overrides) || // constructor-specified buttonText lookup hash takes precedence
12432 spec.overrides.buttonText; // `buttonText` for view-specific options is a string
12433
12434 // highest to lowest priority. mirrors buildViewSpecOptions
12435 spec.buttonTextDefault =
12436 queryButtonText(this.localeDefaults) ||
12437 queryButtonText(this.dirDefaults) ||
12438 spec.defaults.buttonText || // a single string. from ViewSubclass.defaults
12439 queryButtonText(Calendar.defaults) ||
12440 (spec.duration ? this.humanizeDuration(spec.duration) : null) || // like "3 days"
12441 requestedViewType; // fall back to given view name
12442 }
12443
12444 });
12445
12446 ;;
12447
12448 Calendar.mixin({
12449
12450 el: null,
12451 contentEl: null,
12452 suggestedViewHeight: null,
12453 windowResizeProxy: null,
12454 ignoreWindowResize: 0,
12455
12456
12457 render: function() {
12458 if (!this.contentEl) {
12459 this.initialRender();
12460 }
12461 else if (this.elementVisible()) {
12462 // mainly for the public API
12463 this.calcSize();
12464 this.renderView();
12465 }
12466 },
12467
12468
12469 initialRender: function() {
12470 var _this = this;
12471 var el = this.el;
12472
12473 el.addClass('fc');
12474
12475 // event delegation for nav links
12476 el.on('click.fc', 'a[data-goto]', function(ev) {
12477 var anchorEl = $(this);
12478 var gotoOptions = anchorEl.data('goto'); // will automatically parse JSON
12479 var date = _this.moment(gotoOptions.date);
12480 var viewType = gotoOptions.type;
12481
12482 // property like "navLinkDayClick". might be a string or a function
12483 var customAction = _this.view.opt('navLink' + capitaliseFirstLetter(viewType) + 'Click');
12484
12485 if (typeof customAction === 'function') {
12486 customAction(date, ev);
12487 }
12488 else {
12489 if (typeof customAction === 'string') {
12490 viewType = customAction;
12491 }
12492 _this.zoomTo(date, viewType);
12493 }
12494 });
12495
12496 // called immediately, and upon option change
12497 this.optionsModel.watch('settingTheme', [ '?theme', '?themeSystem' ], function(opts) {
12498 var themeClass = ThemeRegistry.getThemeClass(opts.themeSystem || opts.theme);
12499 var theme = new themeClass(_this.optionsModel);
12500 var widgetClass = theme.getClass('widget');
12501
12502 _this.theme = theme;
12503
12504 if (widgetClass) {
12505 el.addClass(widgetClass);
12506 }
12507 }, function() {
12508 var widgetClass = _this.theme.getClass('widget');
12509
12510 _this.theme = null;
12511
12512 if (widgetClass) {
12513 el.removeClass(widgetClass);
12514 }
12515 });
12516
12517 // called immediately, and upon option change.
12518 // HACK: locale often affects isRTL, so we explicitly listen to that too.
12519 this.optionsModel.watch('applyingDirClasses', [ '?isRTL', '?locale' ], function(opts) {
12520 el.toggleClass('fc-ltr', !opts.isRTL);
12521 el.toggleClass('fc-rtl', opts.isRTL);
12522 });
12523
12524 this.contentEl = $("<div class='fc-view-container'/>").prependTo(el);
12525
12526 this.initToolbars();
12527 this.renderHeader();
12528 this.renderFooter();
12529 this.renderView(this.opt('defaultView'));
12530
12531 if (this.opt('handleWindowResize')) {
12532 $(window).resize(
12533 this.windowResizeProxy = debounce( // prevents rapid calls
12534 this.windowResize.bind(this),
12535 this.opt('windowResizeDelay')
12536 )
12537 );
12538 }
12539 },
12540
12541
12542 destroy: function() {
12543
12544 if (this.view) {
12545 this.view.removeElement();
12546
12547 // NOTE: don't null-out this.view in case API methods are called after destroy.
12548 // It is still the "current" view, just not rendered.
12549 }
12550
12551 this.toolbarsManager.proxyCall('removeElement');
12552 this.contentEl.remove();
12553 this.el.removeClass('fc fc-ltr fc-rtl');
12554
12555 // removes theme-related root className
12556 this.optionsModel.unwatch('settingTheme');
12557
12558 this.el.off('.fc'); // unbind nav link handlers
12559
12560 if (this.windowResizeProxy) {
12561 $(window).unbind('resize', this.windowResizeProxy);
12562 this.windowResizeProxy = null;
12563 }
12564
12565 GlobalEmitter.unneeded();
12566 },
12567
12568
12569 elementVisible: function() {
12570 return this.el.is(':visible');
12571 },
12572
12573
12574
12575 // View Rendering
12576 // -----------------------------------------------------------------------------------
12577
12578
12579 // Renders a view because of a date change, view-type change, or for the first time.
12580 // If not given a viewType, keep the current view but render different dates.
12581 // Accepts an optional scroll state to restore to.
12582 renderView: function(viewType, forcedScroll) {
12583
12584 this.ignoreWindowResize++;
12585
12586 var needsClearView = this.view && viewType && this.view.type !== viewType;
12587
12588 // if viewType is changing, remove the old view's rendering
12589 if (needsClearView) {
12590 this.freezeContentHeight(); // prevent a scroll jump when view element is removed
12591 this.clearView();
12592 }
12593
12594 // if viewType changed, or the view was never created, create a fresh view
12595 if (!this.view && viewType) {
12596 this.view =
12597 this.viewsByType[viewType] ||
12598 (this.viewsByType[viewType] = this.instantiateView(viewType));
12599
12600 this.view.setElement(
12601 $("<div class='fc-view fc-" + viewType + "-view' />").appendTo(this.contentEl)
12602 );
12603 this.toolbarsManager.proxyCall('activateButton', viewType);
12604 }
12605
12606 if (this.view) {
12607
12608 if (forcedScroll) {
12609 this.view.addForcedScroll(forcedScroll);
12610 }
12611
12612 if (this.elementVisible()) {
12613 this.view.setDate(this.currentDate);
12614 }
12615 }
12616
12617 if (needsClearView) {
12618 this.thawContentHeight();
12619 }
12620
12621 this.ignoreWindowResize--;
12622 },
12623
12624
12625 // Unrenders the current view and reflects this change in the Header.
12626 // Unregsiters the `view`, but does not remove from viewByType hash.
12627 clearView: function() {
12628 this.toolbarsManager.proxyCall('deactivateButton', this.view.type);
12629 this.view.removeElement();
12630 this.view = null;
12631 },
12632
12633
12634 // Destroys the view, including the view object. Then, re-instantiates it and renders it.
12635 // Maintains the same scroll state.
12636 // TODO: maintain any other user-manipulated state.
12637 reinitView: function() {
12638 this.ignoreWindowResize++;
12639 this.freezeContentHeight();
12640
12641 var viewType = this.view.type;
12642 var scrollState = this.view.queryScroll();
12643 this.clearView();
12644 this.calcSize();
12645 this.renderView(viewType, scrollState);
12646
12647 this.thawContentHeight();
12648 this.ignoreWindowResize--;
12649 },
12650
12651
12652 // Resizing
12653 // -----------------------------------------------------------------------------------
12654
12655
12656 getSuggestedViewHeight: function() {
12657 if (this.suggestedViewHeight === null) {
12658 this.calcSize();
12659 }
12660 return this.suggestedViewHeight;
12661 },
12662
12663
12664 isHeightAuto: function() {
12665 return this.opt('contentHeight') === 'auto' || this.opt('height') === 'auto';
12666 },
12667
12668
12669 updateSize: function(shouldRecalc) {
12670 if (this.elementVisible()) {
12671
12672 if (shouldRecalc) {
12673 this._calcSize();
12674 }
12675
12676 this.ignoreWindowResize++;
12677 this.view.updateSize(true); // isResize=true. will poll getSuggestedViewHeight() and isHeightAuto()
12678 this.ignoreWindowResize--;
12679
12680 return true; // signal success
12681 }
12682 },
12683
12684
12685 calcSize: function() {
12686 if (this.elementVisible()) {
12687 this._calcSize();
12688 }
12689 },
12690
12691
12692 _calcSize: function() { // assumes elementVisible
12693 var contentHeightInput = this.opt('contentHeight');
12694 var heightInput = this.opt('height');
12695
12696 if (typeof contentHeightInput === 'number') { // exists and not 'auto'
12697 this.suggestedViewHeight = contentHeightInput;
12698 }
12699 else if (typeof contentHeightInput === 'function') { // exists and is a function
12700 this.suggestedViewHeight = contentHeightInput();
12701 }
12702 else if (typeof heightInput === 'number') { // exists and not 'auto'
12703 this.suggestedViewHeight = heightInput - this.queryToolbarsHeight();
12704 }
12705 else if (typeof heightInput === 'function') { // exists and is a function
12706 this.suggestedViewHeight = heightInput() - this.queryToolbarsHeight();
12707 }
12708 else if (heightInput === 'parent') { // set to height of parent element
12709 this.suggestedViewHeight = this.el.parent().height() - this.queryToolbarsHeight();
12710 }
12711 else {
12712 this.suggestedViewHeight = Math.round(
12713 this.contentEl.width() /
12714 Math.max(this.opt('aspectRatio'), .5)
12715 );
12716 }
12717 },
12718
12719
12720 windowResize: function(ev) {
12721 if (
12722 !this.ignoreWindowResize &&
12723 ev.target === window && // so we don't process jqui "resize" events that have bubbled up
12724 this.view.renderUnzonedRange // view has already been rendered
12725 ) {
12726 if (this.updateSize(true)) {
12727 this.publiclyTrigger('windowResize', [ this.view ]);
12728 }
12729 }
12730 },
12731
12732
12733 /* Height "Freezing"
12734 -----------------------------------------------------------------------------*/
12735
12736
12737 freezeContentHeight: function() {
12738 this.contentEl.css({
12739 width: '100%',
12740 height: this.contentEl.height(),
12741 overflow: 'hidden'
12742 });
12743 },
12744
12745
12746 thawContentHeight: function() {
12747 this.contentEl.css({
12748 width: '',
12749 height: '',
12750 overflow: ''
12751 });
12752 }
12753
12754 });
12755
12756 ;;
12757
12758 Calendar.mixin({
12759
12760 header: null,
12761 footer: null,
12762 toolbarsManager: null,
12763
12764
12765 initToolbars: function() {
12766 this.header = new Toolbar(this, this.computeHeaderOptions());
12767 this.footer = new Toolbar(this, this.computeFooterOptions());
12768 this.toolbarsManager = new Iterator([ this.header, this.footer ]);
12769 },
12770
12771
12772 computeHeaderOptions: function() {
12773 return {
12774 extraClasses: 'fc-header-toolbar',
12775 layout: this.opt('header')
12776 };
12777 },
12778
12779
12780 computeFooterOptions: function() {
12781 return {
12782 extraClasses: 'fc-footer-toolbar',
12783 layout: this.opt('footer')
12784 };
12785 },
12786
12787
12788 // can be called repeatedly and Header will rerender
12789 renderHeader: function() {
12790 var header = this.header;
12791
12792 header.setToolbarOptions(this.computeHeaderOptions());
12793 header.render();
12794
12795 if (header.el) {
12796 this.el.prepend(header.el);
12797 }
12798 },
12799
12800
12801 // can be called repeatedly and Footer will rerender
12802 renderFooter: function() {
12803 var footer = this.footer;
12804
12805 footer.setToolbarOptions(this.computeFooterOptions());
12806 footer.render();
12807
12808 if (footer.el) {
12809 this.el.append(footer.el);
12810 }
12811 },
12812
12813
12814 setToolbarsTitle: function(title) {
12815 this.toolbarsManager.proxyCall('updateTitle', title);
12816 },
12817
12818
12819 updateToolbarButtons: function() {
12820 var now = this.getNow();
12821 var view = this.view;
12822 var todayInfo = view.buildDateProfile(now);
12823 var prevInfo = view.buildPrevDateProfile(this.currentDate);
12824 var nextInfo = view.buildNextDateProfile(this.currentDate);
12825
12826 this.toolbarsManager.proxyCall(
12827 (todayInfo.isValid && !view.currentUnzonedRange.containsDate(now)) ?
12828 'enableButton' :
12829 'disableButton',
12830 'today'
12831 );
12832
12833 this.toolbarsManager.proxyCall(
12834 prevInfo.isValid ?
12835 'enableButton' :
12836 'disableButton',
12837 'prev'
12838 );
12839
12840 this.toolbarsManager.proxyCall(
12841 nextInfo.isValid ?
12842 'enableButton' :
12843 'disableButton',
12844 'next'
12845 );
12846 },
12847
12848
12849 queryToolbarsHeight: function() {
12850 return this.toolbarsManager.items.reduce(function(accumulator, toolbar) {
12851 var toolbarHeight = toolbar.el ? toolbar.el.outerHeight(true) : 0; // includes margin
12852 return accumulator + toolbarHeight;
12853 }, 0);
12854 }
12855
12856 });
12857
12858 ;;
12859
12860 var BUSINESS_HOUR_EVENT_DEFAULTS = {
12861 start: '09:00',
12862 end: '17:00',
12863 dow: [ 1, 2, 3, 4, 5 ], // monday - friday
12864 rendering: 'inverse-background'
12865 // classNames are defined in businessHoursSegClasses
12866 };
12867
12868
12869 /*
12870 returns ComponentFootprint[]
12871 `businessHourDef` is optional. Use Calendar's setting if omitted.
12872 */
12873 Calendar.prototype.buildCurrentBusinessFootprints = function(wholeDay) {
12874 return this._buildCurrentBusinessFootprints(wholeDay, this.opt('businessHours'));
12875 };
12876
12877
12878 Calendar.prototype._buildCurrentBusinessFootprints = function(wholeDay, businessDefInput) {
12879 var eventPeriod = this.eventManager.currentPeriod;
12880 var businessInstanceGroup;
12881
12882 if (eventPeriod) {
12883 businessInstanceGroup = this.buildBusinessInstanceGroup(
12884 wholeDay,
12885 businessDefInput,
12886 eventPeriod.unzonedRange
12887 );
12888
12889 if (businessInstanceGroup) {
12890 return this.eventInstancesToFootprints( // in Calendar.constraints.js
12891 businessInstanceGroup.eventInstances
12892 );
12893 }
12894 }
12895
12896 return [];
12897 };
12898
12899
12900 /*
12901 If there are business hours, and they are within range, returns populated EventInstanceGroup.
12902 If there are business hours, but they aren't within range, returns a zero-item EventInstanceGroup.
12903 If there are NOT business hours, returns undefined.
12904 */
12905 Calendar.prototype.buildBusinessInstanceGroup = function(wholeDay, rawComplexDef, unzonedRange) {
12906 var eventDefs = this.buildBusinessDefs(wholeDay, rawComplexDef);
12907 var eventInstanceGroup;
12908
12909 if (eventDefs.length) {
12910 eventInstanceGroup = new EventInstanceGroup(
12911 eventDefsToEventInstances(eventDefs, unzonedRange)
12912 );
12913
12914 // so that inverse-background rendering can happen even when no eventRanges in view
12915 eventInstanceGroup.explicitEventDef = eventDefs[0];
12916
12917 return eventInstanceGroup;
12918 }
12919 };
12920
12921
12922 Calendar.prototype.buildBusinessDefs = function(wholeDay, rawComplexDef) {
12923 var rawDefs = [];
12924 var requireDow = false;
12925 var i;
12926 var defs = [];
12927
12928 if (rawComplexDef === true) {
12929 rawDefs = [ {} ]; // will get BUSINESS_HOUR_EVENT_DEFAULTS verbatim
12930 }
12931 else if ($.isPlainObject(rawComplexDef)) {
12932 rawDefs = [ rawComplexDef ];
12933 }
12934 else if ($.isArray(rawComplexDef)) {
12935 rawDefs = rawComplexDef;
12936 requireDow = true; // every sub-definition NEEDS a day-of-week
12937 }
12938
12939 for (i = 0; i < rawDefs.length; i++) {
12940 if (!requireDow || rawDefs[i].dow) {
12941 defs.push(
12942 this.buildBusinessDef(wholeDay, rawDefs[i])
12943 );
12944 }
12945 }
12946
12947 return defs;
12948 };
12949
12950
12951 Calendar.prototype.buildBusinessDef = function(wholeDay, rawDef) {
12952 var fullRawDef = $.extend({}, BUSINESS_HOUR_EVENT_DEFAULTS, rawDef);
12953
12954 if (wholeDay) {
12955 fullRawDef.start = null;
12956 fullRawDef.end = null;
12957 }
12958
12959 return RecurringEventDef.parse(
12960 fullRawDef,
12961 new EventSource(this) // dummy source
12962 );
12963 };
12964
12965 ;;
12966
12967 /*
12968 determines if eventInstanceGroup is allowed,
12969 in relation to other EVENTS and business hours.
12970 */
12971 Calendar.prototype.isEventInstanceGroupAllowed = function(eventInstanceGroup) {
12972 var eventDef = eventInstanceGroup.getEventDef();
12973 var eventFootprints = this.eventRangesToEventFootprints(eventInstanceGroup.getAllEventRanges());
12974 var i;
12975
12976 var peerEventInstances = this.getPeerEventInstances(eventDef);
12977 var peerEventRanges = eventInstancesToEventRanges(peerEventInstances);
12978 var peerEventFootprints = this.eventRangesToEventFootprints(peerEventRanges);
12979
12980 var constraintVal = eventDef.getConstraint();
12981 var overlapVal = eventDef.getOverlap();
12982
12983 var eventAllowFunc = this.opt('eventAllow');
12984
12985 for (i = 0; i < eventFootprints.length; i++) {
12986 if (
12987 !this.isFootprintAllowed(
12988 eventFootprints[i].componentFootprint,
12989 peerEventFootprints,
12990 constraintVal,
12991 overlapVal,
12992 eventFootprints[i].eventInstance
12993 )
12994 ) {
12995 return false;
12996 }
12997 }
12998
12999 if (eventAllowFunc) {
13000 for (i = 0; i < eventFootprints.length; i++) {
13001 if (
13002 eventAllowFunc(
13003 eventFootprints[i].componentFootprint.toLegacy(this),
13004 eventFootprints[i].getEventLegacy()
13005 ) === false
13006 ) {
13007 return false;
13008 }
13009 }
13010 }
13011
13012 return true;
13013 };
13014
13015
13016 Calendar.prototype.getPeerEventInstances = function(eventDef) {
13017 return this.eventManager.getEventInstancesWithoutId(eventDef.id);
13018 };
13019
13020
13021 Calendar.prototype.isSelectionFootprintAllowed = function(componentFootprint) {
13022 var peerEventInstances = this.eventManager.getEventInstances();
13023 var peerEventRanges = eventInstancesToEventRanges(peerEventInstances);
13024 var peerEventFootprints = this.eventRangesToEventFootprints(peerEventRanges);
13025
13026 var selectAllowFunc;
13027
13028 if (
13029 this.isFootprintAllowed(
13030 componentFootprint,
13031 peerEventFootprints,
13032 this.opt('selectConstraint'),
13033 this.opt('selectOverlap')
13034 )
13035 ) {
13036 selectAllowFunc = this.opt('selectAllow');
13037
13038 if (selectAllowFunc) {
13039 return selectAllowFunc(componentFootprint.toLegacy(this)) !== false;
13040 }
13041 else {
13042 return true;
13043 }
13044 }
13045
13046 return false;
13047 };
13048
13049
13050 Calendar.prototype.isFootprintAllowed = function(
13051 componentFootprint,
13052 peerEventFootprints,
13053 constraintVal,
13054 overlapVal,
13055 subjectEventInstance // optional
13056 ) {
13057 var constraintFootprints; // ComponentFootprint[]
13058 var overlapEventFootprints; // EventFootprint[]
13059
13060 if (constraintVal != null) {
13061 constraintFootprints = this.constraintValToFootprints(constraintVal, componentFootprint.isAllDay);
13062
13063 if (!this.isFootprintWithinConstraints(componentFootprint, constraintFootprints)) {
13064 return false;
13065 }
13066 }
13067
13068 overlapEventFootprints = this.collectOverlapEventFootprints(peerEventFootprints, componentFootprint);
13069
13070 if (overlapVal === false) {
13071 if (overlapEventFootprints.length) {
13072 return false;
13073 }
13074 }
13075 else if (typeof overlapVal === 'function') {
13076 if (!isOverlapsAllowedByFunc(overlapEventFootprints, overlapVal, subjectEventInstance)) {
13077 return false;
13078 }
13079 }
13080
13081 if (subjectEventInstance) {
13082 if (!isOverlapEventInstancesAllowed(overlapEventFootprints, subjectEventInstance)) {
13083 return false;
13084 }
13085 }
13086
13087 return true;
13088 };
13089
13090
13091 // Constraint
13092 // ------------------------------------------------------------------------------------------------
13093
13094
13095 Calendar.prototype.isFootprintWithinConstraints = function(componentFootprint, constraintFootprints) {
13096 var i;
13097
13098 for (i = 0; i < constraintFootprints.length; i++) {
13099 if (this.footprintContainsFootprint(constraintFootprints[i], componentFootprint)) {
13100 return true;
13101 }
13102 }
13103
13104 return false;
13105 };
13106
13107
13108 Calendar.prototype.constraintValToFootprints = function(constraintVal, isAllDay) {
13109 var eventInstances;
13110
13111 if (constraintVal === 'businessHours') {
13112 return this.buildCurrentBusinessFootprints(isAllDay);
13113 }
13114 else if (typeof constraintVal === 'object') {
13115 eventInstances = this.parseEventDefToInstances(constraintVal); // handles recurring events
13116
13117 if (!eventInstances) { // invalid input. fallback to parsing footprint directly
13118 return this.parseFootprints(constraintVal);
13119 }
13120 else {
13121 return this.eventInstancesToFootprints(eventInstances);
13122 }
13123 }
13124 else if (constraintVal != null) { // an ID
13125 eventInstances = this.eventManager.getEventInstancesWithId(constraintVal);
13126
13127 return this.eventInstancesToFootprints(eventInstances);
13128 }
13129 };
13130
13131
13132 // conversion util
13133 Calendar.prototype.eventInstancesToFootprints = function(eventInstances) {
13134 return eventFootprintsToComponentFootprints(
13135 this.eventRangesToEventFootprints(
13136 eventInstancesToEventRanges(eventInstances)
13137 )
13138 );
13139 };
13140
13141
13142 // Overlap
13143 // ------------------------------------------------------------------------------------------------
13144
13145
13146 Calendar.prototype.collectOverlapEventFootprints = function(peerEventFootprints, targetFootprint) {
13147 var overlapEventFootprints = [];
13148 var i;
13149
13150 for (i = 0; i < peerEventFootprints.length; i++) {
13151 if (
13152 this.footprintsIntersect(
13153 targetFootprint,
13154 peerEventFootprints[i].componentFootprint
13155 )
13156 ) {
13157 overlapEventFootprints.push(peerEventFootprints[i]);
13158 }
13159 }
13160
13161 return overlapEventFootprints;
13162 };
13163
13164
13165 // optional subjectEventInstance
13166 function isOverlapsAllowedByFunc(overlapEventFootprints, overlapFunc, subjectEventInstance) {
13167 var i;
13168
13169 for (i = 0; i < overlapEventFootprints.length; i++) {
13170 if (
13171 !overlapFunc(
13172 overlapEventFootprints[i].eventInstance.toLegacy(),
13173 subjectEventInstance ? subjectEventInstance.toLegacy() : null
13174 )
13175 ) {
13176 return false;
13177 }
13178 }
13179
13180 return true;
13181 }
13182
13183
13184 function isOverlapEventInstancesAllowed(overlapEventFootprints, subjectEventInstance) {
13185 var subjectLegacyInstance = subjectEventInstance.toLegacy();
13186 var i;
13187 var overlapEventInstance;
13188 var overlapEventDef;
13189 var overlapVal;
13190
13191 for (i = 0; i < overlapEventFootprints.length; i++) {
13192 overlapEventInstance = overlapEventFootprints[i].eventInstance;
13193 overlapEventDef = overlapEventInstance.def;
13194
13195 // don't need to pass in calendar, because don't want to consider global eventOverlap property,
13196 // because we already considered that earlier in the process.
13197 overlapVal = overlapEventDef.getOverlap();
13198
13199 if (overlapVal === false) {
13200 return false;
13201 }
13202 else if (typeof overlapVal === 'function') {
13203 if (
13204 !overlapVal(
13205 overlapEventInstance.toLegacy(),
13206 subjectLegacyInstance
13207 )
13208 ) {
13209 return false;
13210 }
13211 }
13212 }
13213
13214 return true;
13215 }
13216
13217
13218 // Conversion: eventDefs -> eventInstances -> eventRanges -> eventFootprints -> componentFootprints
13219 // ------------------------------------------------------------------------------------------------
13220 // NOTE: this might seem like repetitive code with the Grid class, however, this code is related to
13221 // constraints whereas the Grid code is related to rendering. Each approach might want to convert
13222 // eventRanges -> eventFootprints in a different way. Regardless, there are opportunities to make
13223 // this more DRY.
13224
13225
13226 /*
13227 Returns false on invalid input.
13228 */
13229 Calendar.prototype.parseEventDefToInstances = function(eventInput) {
13230 var eventPeriod = this.eventManager.currentPeriod;
13231 var eventDef = EventDefParser.parse(eventInput, new EventSource(this));
13232
13233 if (!eventDef) { // invalid
13234 return false;
13235 }
13236
13237 if (eventPeriod) {
13238 return eventDef.buildInstances(eventPeriod.unzonedRange);
13239 }
13240 else {
13241 return [];
13242 }
13243 };
13244
13245
13246 Calendar.prototype.eventRangesToEventFootprints = function(eventRanges) {
13247 var i;
13248 var eventFootprints = [];
13249
13250 for (i = 0; i < eventRanges.length; i++) {
13251 eventFootprints.push.apply(eventFootprints, // append
13252 this.eventRangeToEventFootprints(eventRanges[i])
13253 );
13254 }
13255
13256 return eventFootprints;
13257 };
13258
13259
13260 /*
13261 TODO: somehow more DRY with Grid::eventRangeToEventFootprints
13262 */
13263 Calendar.prototype.eventRangeToEventFootprints = function(eventRange) {
13264 return [
13265 new EventFootprint(
13266 new ComponentFootprint(
13267 eventRange.unzonedRange,
13268 eventRange.eventDef.isAllDay()
13269 ),
13270 eventRange.eventDef,
13271 eventRange.eventInstance // might not exist
13272 )
13273 ];
13274 };
13275
13276
13277 /*
13278 Parses footprints directly.
13279 Very similar to EventDateProfile::parse :(
13280 */
13281 Calendar.prototype.parseFootprints = function(rawInput) {
13282 var start, end;
13283
13284 if (rawInput.start) {
13285 start = this.moment(rawInput.start);
13286
13287 if (!start.isValid()) {
13288 start = null;
13289 }
13290 }
13291
13292 if (rawInput.end) {
13293 end = this.moment(rawInput.end);
13294
13295 if (!end.isValid()) {
13296 end = null;
13297 }
13298 }
13299
13300 return [
13301 new ComponentFootprint(
13302 new UnzonedRange(start, end),
13303 (start && !start.hasTime()) || (end && !end.hasTime()) // isAllDay
13304 )
13305 ];
13306 };
13307
13308
13309 // Footprint Utils
13310 // ----------------------------------------------------------------------------------------
13311
13312
13313 Calendar.prototype.footprintContainsFootprint = function(outerFootprint, innerFootprint) {
13314 return outerFootprint.unzonedRange.containsRange(innerFootprint.unzonedRange);
13315 };
13316
13317
13318 Calendar.prototype.footprintsIntersect = function(footprint0, footprint1) {
13319 return footprint0.unzonedRange.intersectsWith(footprint1.unzonedRange);
13320 };
13321
13322 ;;
13323
13324 Calendar.mixin({
13325
13326 // Sources
13327 // ------------------------------------------------------------------------------------
13328
13329
13330 getEventSources: function() {
13331 return this.eventManager.otherSources.slice(); // clone
13332 },
13333
13334
13335 getEventSourceById: function(id) {
13336 return this.eventManager.getSourceById(
13337 EventSource.normalizeId(id)
13338 );
13339 },
13340
13341
13342 addEventSource: function(sourceInput) {
13343 var source = EventSourceParser.parse(sourceInput, this);
13344
13345 if (source) {
13346 this.eventManager.addSource(source);
13347 }
13348 },
13349
13350
13351 removeEventSources: function(sourceMultiQuery) {
13352 var eventManager = this.eventManager;
13353 var sources;
13354 var i;
13355
13356 if (sourceMultiQuery == null) {
13357 this.eventManager.removeAllSources();
13358 }
13359 else {
13360 sources = eventManager.multiQuerySources(sourceMultiQuery);
13361
13362 eventManager.freeze();
13363
13364 for (i = 0; i < sources.length; i++) {
13365 eventManager.removeSource(sources[i]);
13366 }
13367
13368 eventManager.thaw();
13369 }
13370 },
13371
13372
13373 removeEventSource: function(sourceQuery) {
13374 var eventManager = this.eventManager;
13375 var sources = eventManager.querySources(sourceQuery);
13376 var i;
13377
13378 eventManager.freeze();
13379
13380 for (i = 0; i < sources.length; i++) {
13381 eventManager.removeSource(sources[i]);
13382 }
13383
13384 eventManager.thaw();
13385 },
13386
13387
13388 refetchEventSources: function(sourceMultiQuery) {
13389 var eventManager = this.eventManager;
13390 var sources = eventManager.multiQuerySources(sourceMultiQuery);
13391 var i;
13392
13393 eventManager.freeze();
13394
13395 for (i = 0; i < sources.length; i++) {
13396 eventManager.refetchSource(sources[i]);
13397 }
13398
13399 eventManager.thaw();
13400 },
13401
13402
13403 // Events
13404 // ------------------------------------------------------------------------------------
13405
13406
13407 refetchEvents: function() {
13408 this.eventManager.refetchAllSources();
13409 },
13410
13411
13412 renderEvents: function(eventInputs, isSticky) {
13413 this.eventManager.freeze();
13414
13415 for (var i = 0; i < eventInputs.length; i++) {
13416 this.renderEvent(eventInputs[i], isSticky);
13417 }
13418
13419 this.eventManager.thaw();
13420 },
13421
13422
13423 renderEvent: function(eventInput, isSticky) {
13424 var eventManager = this.eventManager;
13425 var eventDef = EventDefParser.parse(
13426 eventInput,
13427 eventInput.source || eventManager.stickySource
13428 );
13429
13430 if (eventDef) {
13431 eventManager.addEventDef(eventDef, isSticky);
13432 }
13433 },
13434
13435
13436 // legacyQuery operates on legacy event instance objects
13437 removeEvents: function(legacyQuery) {
13438 var eventManager = this.eventManager;
13439 var eventInstances = eventManager.getEventInstances();
13440 var legacyInstances;
13441 var idMap = {};
13442 var eventDef;
13443 var i;
13444
13445 if (legacyQuery == null) { // shortcut for removing all
13446 eventManager.removeAllEventDefs();
13447 }
13448 else {
13449 legacyInstances = eventInstances.map(function(eventInstance) {
13450 return eventInstance.toLegacy();
13451 });
13452
13453 legacyInstances = filterLegacyEventInstances(legacyInstances, legacyQuery);
13454
13455 // compute unique IDs
13456 for (i = 0; i < legacyInstances.length; i++) {
13457 eventDef = this.eventManager.getEventDefByUid(legacyInstances[i]._id);
13458 idMap[eventDef.id] = true;
13459 }
13460
13461 eventManager.freeze();
13462
13463 for (i in idMap) { // reuse `i` as an "id"
13464 eventManager.removeEventDefsById(i);
13465 }
13466
13467 eventManager.thaw();
13468 }
13469 },
13470
13471
13472 // legacyQuery operates on legacy event instance objects
13473 clientEvents: function(legacyQuery) {
13474 var eventInstances = this.eventManager.getEventInstances();
13475 var legacyEventInstances = eventInstances.map(function(eventInstance) {
13476 return eventInstance.toLegacy();
13477 });
13478
13479 return filterLegacyEventInstances(legacyEventInstances, legacyQuery);
13480 },
13481
13482
13483 updateEvents: function(eventPropsArray) {
13484 this.eventManager.freeze();
13485
13486 for (var i = 0; i < eventPropsArray.length; i++) {
13487 this.updateEvent(eventPropsArray[i]);
13488 }
13489
13490 this.eventManager.thaw();
13491 },
13492
13493
13494 updateEvent: function(eventProps) {
13495 var eventDef = this.eventManager.getEventDefByUid(eventProps._id);
13496 var eventInstance;
13497 var eventDefMutation;
13498
13499 if (eventDef instanceof SingleEventDef) {
13500 eventInstance = eventDef.buildInstance();
13501
13502 eventDefMutation = EventDefMutation.createFromRawProps(
13503 eventInstance,
13504 eventProps, // raw props
13505 null // largeUnit -- who uses it?
13506 );
13507
13508 this.eventManager.mutateEventsWithId(eventDef.id, eventDefMutation); // will release
13509 }
13510 }
13511
13512 });
13513
13514
13515 function filterLegacyEventInstances(legacyEventInstances, legacyQuery) {
13516 if (legacyQuery == null) {
13517 return legacyEventInstances;
13518 }
13519 else if ($.isFunction(legacyQuery)) {
13520 return legacyEventInstances.filter(legacyQuery);
13521 }
13522 else { // an event ID
13523 legacyQuery += ''; // normalize to string
13524
13525 return legacyEventInstances.filter(function(legacyEventInstance) {
13526 // soft comparison because id not be normalized to string
13527 return legacyEventInstance.id == legacyQuery;
13528 });
13529 }
13530 }
13531
13532 ;;
13533
13534 Calendar.defaults = {
13535
13536 titleRangeSeparator: ' \u2013 ', // en dash
13537 monthYearFormat: 'MMMM YYYY', // required for en. other locales rely on datepicker computable option
13538
13539 defaultTimedEventDuration: '02:00:00',
13540 defaultAllDayEventDuration: { days: 1 },
13541 forceEventDuration: false,
13542 nextDayThreshold: '09:00:00', // 9am
13543
13544 // display
13545 defaultView: 'month',
13546 aspectRatio: 1.35,
13547 header: {
13548 left: 'title',
13549 center: '',
13550 right: 'today prev,next'
13551 },
13552 weekends: true,
13553 weekNumbers: false,
13554
13555 weekNumberTitle: 'W',
13556 weekNumberCalculation: 'local',
13557
13558 //editable: false,
13559
13560 //nowIndicator: false,
13561
13562 scrollTime: '06:00:00',
13563 minTime: '00:00:00',
13564 maxTime: '24:00:00',
13565 showNonCurrentDates: true,
13566
13567 // event ajax
13568 lazyFetching: true,
13569 startParam: 'start',
13570 endParam: 'end',
13571 timezoneParam: 'timezone',
13572
13573 timezone: false,
13574
13575 //allDayDefault: undefined,
13576
13577 // locale
13578 isRTL: false,
13579 buttonText: {
13580 prev: "prev",
13581 next: "next",
13582 prevYear: "prev year",
13583 nextYear: "next year",
13584 year: 'year', // TODO: locale files need to specify this
13585 today: 'today',
13586 month: 'month',
13587 week: 'week',
13588 day: 'day'
13589 },
13590 //buttonIcons: null,
13591
13592 allDayText: 'all-day',
13593
13594 // jquery-ui theming
13595 theme: false,
13596 //themeButtonIcons: null,
13597
13598 //eventResizableFromStart: false,
13599 dragOpacity: .75,
13600 dragRevertDuration: 500,
13601 dragScroll: true,
13602
13603 //selectable: false,
13604 unselectAuto: true,
13605 //selectMinDistance: 0,
13606
13607 dropAccept: '*',
13608
13609 eventOrder: 'title',
13610 //eventRenderWait: null,
13611
13612 eventLimit: false,
13613 eventLimitText: 'more',
13614 eventLimitClick: 'popover',
13615 dayPopoverFormat: 'LL',
13616
13617 handleWindowResize: true,
13618 windowResizeDelay: 100, // milliseconds before an updateSize happens
13619
13620 longPressDelay: 1000
13621
13622 };
13623
13624
13625 Calendar.englishDefaults = { // used by locale.js
13626 dayPopoverFormat: 'dddd, MMMM D'
13627 };
13628
13629
13630 Calendar.rtlDefaults = { // right-to-left defaults
13631 header: { // TODO: smarter solution (first/center/last ?)
13632 left: 'next,prev today',
13633 center: '',
13634 right: 'title'
13635 },
13636 buttonIcons: {
13637 prev: 'right-single-arrow',
13638 next: 'left-single-arrow',
13639 prevYear: 'right-double-arrow',
13640 nextYear: 'left-double-arrow'
13641 },
13642 themeButtonIcons: {
13643 prev: 'circle-triangle-e',
13644 next: 'circle-triangle-w',
13645 nextYear: 'seek-prev',
13646 prevYear: 'seek-next'
13647 }
13648 };
13649
13650 ;;
13651
13652 var localeOptionHash = FC.locales = {}; // initialize and expose
13653
13654
13655 // TODO: document the structure and ordering of a FullCalendar locale file
13656
13657
13658 // Initialize jQuery UI datepicker translations while using some of the translations
13659 // Will set this as the default locales for datepicker.
13660 FC.datepickerLocale = function(localeCode, dpLocaleCode, dpOptions) {
13661
13662 // get the FullCalendar internal option hash for this locale. create if necessary
13663 var fcOptions = localeOptionHash[localeCode] || (localeOptionHash[localeCode] = {});
13664
13665 // transfer some simple options from datepicker to fc
13666 fcOptions.isRTL = dpOptions.isRTL;
13667 fcOptions.weekNumberTitle = dpOptions.weekHeader;
13668
13669 // compute some more complex options from datepicker
13670 $.each(dpComputableOptions, function(name, func) {
13671 fcOptions[name] = func(dpOptions);
13672 });
13673
13674 // is jQuery UI Datepicker is on the page?
13675 if ($.datepicker) {
13676
13677 // Register the locale data.
13678 // FullCalendar and MomentJS use locale codes like "pt-br" but Datepicker
13679 // does it like "pt-BR" or if it doesn't have the locale, maybe just "pt".
13680 // Make an alias so the locale can be referenced either way.
13681 $.datepicker.regional[dpLocaleCode] =
13682 $.datepicker.regional[localeCode] = // alias
13683 dpOptions;
13684
13685 // Alias 'en' to the default locale data. Do this every time.
13686 $.datepicker.regional.en = $.datepicker.regional[''];
13687
13688 // Set as Datepicker's global defaults.
13689 $.datepicker.setDefaults(dpOptions);
13690 }
13691 };
13692
13693
13694 // Sets FullCalendar-specific translations. Will set the locales as the global default.
13695 FC.locale = function(localeCode, newFcOptions) {
13696 var fcOptions;
13697 var momOptions;
13698
13699 // get the FullCalendar internal option hash for this locale. create if necessary
13700 fcOptions = localeOptionHash[localeCode] || (localeOptionHash[localeCode] = {});
13701
13702 // provided new options for this locales? merge them in
13703 if (newFcOptions) {
13704 fcOptions = localeOptionHash[localeCode] = mergeOptions([ fcOptions, newFcOptions ]);
13705 }
13706
13707 // compute locale options that weren't defined.
13708 // always do this. newFcOptions can be undefined when initializing from i18n file,
13709 // so no way to tell if this is an initialization or a default-setting.
13710 momOptions = getMomentLocaleData(localeCode); // will fall back to en
13711 $.each(momComputableOptions, function(name, func) {
13712 if (fcOptions[name] == null) {
13713 fcOptions[name] = func(momOptions, fcOptions);
13714 }
13715 });
13716
13717 // set it as the default locale for FullCalendar
13718 Calendar.defaults.locale = localeCode;
13719 };
13720
13721
13722 // NOTE: can't guarantee any of these computations will run because not every locale has datepicker
13723 // configs, so make sure there are English fallbacks for these in the defaults file.
13724 var dpComputableOptions = {
13725
13726 buttonText: function(dpOptions) {
13727 return {
13728 // the translations sometimes wrongly contain HTML entities
13729 prev: stripHtmlEntities(dpOptions.prevText),
13730 next: stripHtmlEntities(dpOptions.nextText),
13731 today: stripHtmlEntities(dpOptions.currentText)
13732 };
13733 },
13734
13735 // Produces format strings like "MMMM YYYY" -> "September 2014"
13736 monthYearFormat: function(dpOptions) {
13737 return dpOptions.showMonthAfterYear ?
13738 'YYYY[' + dpOptions.yearSuffix + '] MMMM' :
13739 'MMMM YYYY[' + dpOptions.yearSuffix + ']';
13740 }
13741
13742 };
13743
13744 var momComputableOptions = {
13745
13746 // Produces format strings like "ddd M/D" -> "Fri 9/15"
13747 dayOfMonthFormat: function(momOptions, fcOptions) {
13748 var format = momOptions.longDateFormat('l'); // for the format like "M/D/YYYY"
13749
13750 // strip the year off the edge, as well as other misc non-whitespace chars
13751 format = format.replace(/^Y+[^\w\s]*|[^\w\s]*Y+$/g, '');
13752
13753 if (fcOptions.isRTL) {
13754 format += ' ddd'; // for RTL, add day-of-week to end
13755 }
13756 else {
13757 format = 'ddd ' + format; // for LTR, add day-of-week to beginning
13758 }
13759 return format;
13760 },
13761
13762 // Produces format strings like "h:mma" -> "6:00pm"
13763 mediumTimeFormat: function(momOptions) { // can't be called `timeFormat` because collides with option
13764 return momOptions.longDateFormat('LT')
13765 .replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand
13766 },
13767
13768 // Produces format strings like "h(:mm)a" -> "6pm" / "6:30pm"
13769 smallTimeFormat: function(momOptions) {
13770 return momOptions.longDateFormat('LT')
13771 .replace(':mm', '(:mm)')
13772 .replace(/(\Wmm)$/, '($1)') // like above, but for foreign locales
13773 .replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand
13774 },
13775
13776 // Produces format strings like "h(:mm)t" -> "6p" / "6:30p"
13777 extraSmallTimeFormat: function(momOptions) {
13778 return momOptions.longDateFormat('LT')
13779 .replace(':mm', '(:mm)')
13780 .replace(/(\Wmm)$/, '($1)') // like above, but for foreign locales
13781 .replace(/\s*a$/i, 't'); // convert to AM/PM/am/pm to lowercase one-letter. remove any spaces beforehand
13782 },
13783
13784 // Produces format strings like "ha" / "H" -> "6pm" / "18"
13785 hourFormat: function(momOptions) {
13786 return momOptions.longDateFormat('LT')
13787 .replace(':mm', '')
13788 .replace(/(\Wmm)$/, '') // like above, but for foreign locales
13789 .replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand
13790 },
13791
13792 // Produces format strings like "h:mm" -> "6:30" (with no AM/PM)
13793 noMeridiemTimeFormat: function(momOptions) {
13794 return momOptions.longDateFormat('LT')
13795 .replace(/\s*a$/i, ''); // remove trailing AM/PM
13796 }
13797
13798 };
13799
13800
13801 // options that should be computed off live calendar options (considers override options)
13802 // TODO: best place for this? related to locale?
13803 // TODO: flipping text based on isRTL is a bad idea because the CSS `direction` might want to handle it
13804 var instanceComputableOptions = {
13805
13806 // Produces format strings for results like "Mo 16"
13807 smallDayDateFormat: function(options) {
13808 return options.isRTL ?
13809 'D dd' :
13810 'dd D';
13811 },
13812
13813 // Produces format strings for results like "Wk 5"
13814 weekFormat: function(options) {
13815 return options.isRTL ?
13816 'w[ ' + options.weekNumberTitle + ']' :
13817 '[' + options.weekNumberTitle + ' ]w';
13818 },
13819
13820 // Produces format strings for results like "Wk5"
13821 smallWeekFormat: function(options) {
13822 return options.isRTL ?
13823 'w[' + options.weekNumberTitle + ']' :
13824 '[' + options.weekNumberTitle + ']w';
13825 }
13826
13827 };
13828
13829 // TODO: make these computable properties in optionsModel
13830 function populateInstanceComputableOptions(options) {
13831 $.each(instanceComputableOptions, function(name, func) {
13832 if (options[name] == null) {
13833 options[name] = func(options);
13834 }
13835 });
13836 }
13837
13838
13839 // Returns moment's internal locale data. If doesn't exist, returns English.
13840 function getMomentLocaleData(localeCode) {
13841 return moment.localeData(localeCode) || moment.localeData('en');
13842 }
13843
13844
13845 // Initialize English by forcing computation of moment-derived options.
13846 // Also, sets it as the default.
13847 FC.locale('en', Calendar.englishDefaults);
13848
13849 ;;
13850
13851 var UnzonedRange = FC.UnzonedRange = Class.extend({
13852
13853 startMs: null, // if null, no start constraint
13854 endMs: null, // if null, no end constraint
13855
13856 // TODO: move these into footprint.
13857 // Especially, doesn't make sense for null startMs/endMs.
13858 isStart: true,
13859 isEnd: true,
13860
13861 constructor: function(startInput, endInput) {
13862
13863 if (moment.isMoment(startInput)) {
13864 startInput = startInput.clone().stripZone();
13865 }
13866
13867 if (moment.isMoment(endInput)) {
13868 endInput = endInput.clone().stripZone();
13869 }
13870
13871 if (startInput) {
13872 this.startMs = startInput.valueOf();
13873 }
13874
13875 if (endInput) {
13876 this.endMs = endInput.valueOf();
13877 }
13878 },
13879
13880 intersect: function(otherRange) {
13881 var startMs = this.startMs;
13882 var endMs = this.endMs;
13883 var newRange = null;
13884
13885 if (otherRange.startMs !== null) {
13886 if (startMs === null) {
13887 startMs = otherRange.startMs;
13888 }
13889 else {
13890 startMs = Math.max(startMs, otherRange.startMs);
13891 }
13892 }
13893
13894 if (otherRange.endMs !== null) {
13895 if (endMs === null) {
13896 endMs = otherRange.endMs;
13897 }
13898 else {
13899 endMs = Math.min(endMs, otherRange.endMs);
13900 }
13901 }
13902
13903 if (startMs === null || endMs === null || startMs < endMs) {
13904 newRange = new UnzonedRange(startMs, endMs);
13905 newRange.isStart = this.isStart && startMs === this.startMs;
13906 newRange.isEnd = this.isEnd && endMs === this.endMs;
13907 }
13908
13909 return newRange;
13910 },
13911
13912
13913 intersectsWith: function(otherRange) {
13914 return (this.endMs === null || otherRange.startMs === null || this.endMs > otherRange.startMs) &&
13915 (this.startMs === null || otherRange.endMs === null || this.startMs < otherRange.endMs);
13916 },
13917
13918
13919 containsRange: function(innerRange) {
13920 return (this.startMs === null || (innerRange.startMs !== null && innerRange.startMs >= this.startMs)) &&
13921 (this.endMs === null || (innerRange.endMs !== null && innerRange.endMs <= this.endMs));
13922 },
13923
13924
13925 // `date` can be a moment, a Date, or a millisecond time.
13926 containsDate: function(date) {
13927 var ms = date.valueOf();
13928
13929 return (this.startMs === null || ms >= this.startMs) &&
13930 (this.endMs === null || ms < this.endMs);
13931 },
13932
13933
13934 // If the given date is not within the given range, move it inside.
13935 // (If it's past the end, make it one millisecond before the end).
13936 // `date` can be a moment, a Date, or a millisecond time.
13937 // Returns a MS-time.
13938 constrainDate: function(date) {
13939 var ms = date.valueOf();
13940
13941 if (this.startMs !== null && ms < this.startMs) {
13942 ms = this.startMs;
13943 }
13944
13945 if (this.endMs !== null && ms >= this.endMs) {
13946 ms = this.endMs - 1;
13947 }
13948
13949 return ms;
13950 },
13951
13952
13953 equals: function(otherRange) {
13954 return this.startMs === otherRange.startMs && this.endMs === otherRange.endMs;
13955 },
13956
13957
13958 clone: function() {
13959 var range = new UnzonedRange(this.startMs, this.endMs);
13960
13961 range.isStart = this.isStart;
13962 range.isEnd = this.isEnd;
13963
13964 return range;
13965 },
13966
13967
13968 // Returns an ambig-zoned moment from startMs.
13969 // BEWARE: returned moment is not localized.
13970 // Formatting and start-of-week will be default.
13971 getStart: function() {
13972 if (this.startMs !== null) {
13973 return FC.moment.utc(this.startMs).stripZone();
13974 }
13975 },
13976
13977 // Returns an ambig-zoned moment from startMs.
13978 // BEWARE: returned moment is not localized.
13979 // Formatting and start-of-week will be default.
13980 getEnd: function() {
13981 if (this.endMs !== null) {
13982 return FC.moment.utc(this.endMs).stripZone();
13983 }
13984 }
13985
13986 });
13987
13988
13989 /*
13990 SIDEEFFECT: will mutate eventRanges.
13991 Will return a new array result.
13992 Only works for non-open-ended ranges.
13993 */
13994 function invertUnzonedRanges(ranges, constraintRange) {
13995 var invertedRanges = [];
13996 var startMs = constraintRange.startMs; // the end of the previous range. the start of the new range
13997 var i;
13998 var dateRange;
13999
14000 // ranges need to be in order. required for our date-walking algorithm
14001 ranges.sort(compareUnzonedRanges);
14002
14003 for (i = 0; i < ranges.length; i++) {
14004 dateRange = ranges[i];
14005
14006 // add the span of time before the event (if there is any)
14007 if (dateRange.startMs > startMs) { // compare millisecond time (skip any ambig logic)
14008 invertedRanges.push(
14009 new UnzonedRange(startMs, dateRange.startMs)
14010 );
14011 }
14012
14013 if (dateRange.endMs > startMs) {
14014 startMs = dateRange.endMs;
14015 }
14016 }
14017
14018 // add the span of time after the last event (if there is any)
14019 if (startMs < constraintRange.endMs) { // compare millisecond time (skip any ambig logic)
14020 invertedRanges.push(
14021 new UnzonedRange(startMs, constraintRange.endMs)
14022 );
14023 }
14024
14025 return invertedRanges;
14026 }
14027
14028
14029 /*
14030 Only works for non-open-ended ranges.
14031 */
14032 function compareUnzonedRanges(range1, range2) {
14033 return range1.startMs - range2.startMs; // earlier ranges go first
14034 }
14035
14036 ;;
14037
14038 /*
14039 Meant to be immutable
14040 */
14041 var ComponentFootprint = FC.ComponentFootprint = Class.extend({
14042
14043 unzonedRange: null,
14044 isAllDay: false, // component can choose to ignore this
14045
14046
14047 constructor: function(unzonedRange, isAllDay) {
14048 this.unzonedRange = unzonedRange;
14049 this.isAllDay = isAllDay;
14050 },
14051
14052
14053 /*
14054 Only works for non-open-ended ranges.
14055 */
14056 toLegacy: function(calendar) {
14057 return {
14058 start: calendar.msToMoment(this.unzonedRange.startMs, this.isAllDay),
14059 end: calendar.msToMoment(this.unzonedRange.endMs, this.isAllDay)
14060 };
14061 }
14062
14063 });
14064
14065 ;;
14066
14067 var EventManager = Class.extend(EmitterMixin, ListenerMixin, {
14068
14069 currentPeriod: null,
14070
14071 calendar: null,
14072 stickySource: null,
14073 otherSources: null, // does not include sticky source
14074
14075
14076 constructor: function(calendar) {
14077 this.calendar = calendar;
14078 this.stickySource = new ArrayEventSource(calendar);
14079 this.otherSources = [];
14080 },
14081
14082
14083 requestEvents: function(start, end, timezone, force) {
14084 if (
14085 force ||
14086 !this.currentPeriod ||
14087 !this.currentPeriod.isWithinRange(start, end) ||
14088 timezone !== this.currentPeriod.timezone
14089 ) {
14090 this.setPeriod( // will change this.currentPeriod
14091 new EventPeriod(start, end, timezone)
14092 );
14093 }
14094
14095 return this.currentPeriod.whenReleased();
14096 },
14097
14098
14099 // Source Adding/Removing
14100 // -----------------------------------------------------------------------------------------------------------------
14101
14102
14103 addSource: function(eventSource) {
14104 this.otherSources.push(eventSource);
14105
14106 if (this.currentPeriod) {
14107 this.currentPeriod.requestSource(eventSource); // might release
14108 }
14109 },
14110
14111
14112 removeSource: function(doomedSource) {
14113 removeExact(this.otherSources, doomedSource);
14114
14115 if (this.currentPeriod) {
14116 this.currentPeriod.purgeSource(doomedSource); // might release
14117 }
14118 },
14119
14120
14121 removeAllSources: function() {
14122 this.otherSources = [];
14123
14124 if (this.currentPeriod) {
14125 this.currentPeriod.purgeAllSources(); // might release
14126 }
14127 },
14128
14129
14130 // Source Refetching
14131 // -----------------------------------------------------------------------------------------------------------------
14132
14133
14134 refetchSource: function(eventSource) {
14135 var currentPeriod = this.currentPeriod;
14136
14137 if (currentPeriod) {
14138 currentPeriod.freeze();
14139 currentPeriod.purgeSource(eventSource);
14140 currentPeriod.requestSource(eventSource);
14141 currentPeriod.thaw();
14142 }
14143 },
14144
14145
14146 refetchAllSources: function() {
14147 var currentPeriod = this.currentPeriod;
14148
14149 if (currentPeriod) {
14150 currentPeriod.freeze();
14151 currentPeriod.purgeAllSources();
14152 currentPeriod.requestSources(this.getSources());
14153 currentPeriod.thaw();
14154 }
14155 },
14156
14157
14158 // Source Querying
14159 // -----------------------------------------------------------------------------------------------------------------
14160
14161
14162 getSources: function() {
14163 return [ this.stickySource ].concat(this.otherSources);
14164 },
14165
14166
14167 // like querySources, but accepts multple match criteria (like multiple IDs)
14168 multiQuerySources: function(matchInputs) {
14169
14170 // coerce into an array
14171 if (!matchInputs) {
14172 matchInputs = [];
14173 }
14174 else if (!$.isArray(matchInputs)) {
14175 matchInputs = [ matchInputs ];
14176 }
14177
14178 var matchingSources = [];
14179 var i;
14180
14181 // resolve raw inputs to real event source objects
14182 for (i = 0; i < matchInputs.length; i++) {
14183 matchingSources.push.apply( // append
14184 matchingSources,
14185 this.querySources(matchInputs[i])
14186 );
14187 }
14188
14189 return matchingSources;
14190 },
14191
14192
14193 // matchInput can either by a real event source object, an ID, or the function/URL for the source.
14194 // returns an array of matching source objects.
14195 querySources: function(matchInput) {
14196 var sources = this.otherSources;
14197 var i, source;
14198
14199 // given a proper event source object
14200 for (i = 0; i < sources.length; i++) {
14201 source = sources[i];
14202
14203 if (source === matchInput) {
14204 return [ source ];
14205 }
14206 }
14207
14208 // an ID match
14209 source = this.getSourceById(EventSource.normalizeId(matchInput));
14210 if (source) {
14211 return [ source ];
14212 }
14213
14214 // parse as an event source
14215 matchInput = EventSourceParser.parse(matchInput, this.calendar);
14216 if (matchInput) {
14217
14218 return $.grep(sources, function(source) {
14219 return isSourcesEquivalent(matchInput, source);
14220 });
14221 }
14222 },
14223
14224
14225 /*
14226 ID assumed to already be normalized
14227 */
14228 getSourceById: function(id) {
14229 return $.grep(this.otherSources, function(source) {
14230 return source.id && source.id === id;
14231 })[0];
14232 },
14233
14234
14235 // Event-Period
14236 // -----------------------------------------------------------------------------------------------------------------
14237
14238
14239 setPeriod: function(eventPeriod) {
14240 if (this.currentPeriod) {
14241 this.unbindPeriod(this.currentPeriod);
14242 this.currentPeriod = null;
14243 }
14244
14245 this.currentPeriod = eventPeriod;
14246 this.bindPeriod(eventPeriod);
14247
14248 eventPeriod.requestSources(this.getSources());
14249 },
14250
14251
14252 bindPeriod: function(eventPeriod) {
14253 this.listenTo(eventPeriod, 'release', function(eventsPayload) {
14254 this.trigger('release', eventsPayload);
14255 });
14256 },
14257
14258
14259 unbindPeriod: function(eventPeriod) {
14260 this.stopListeningTo(eventPeriod);
14261 },
14262
14263
14264 // Event Getting/Adding/Removing
14265 // -----------------------------------------------------------------------------------------------------------------
14266
14267
14268 getEventDefByUid: function(uid) {
14269 if (this.currentPeriod) {
14270 return this.currentPeriod.getEventDefByUid(uid);
14271 }
14272 },
14273
14274
14275 addEventDef: function(eventDef, isSticky) {
14276 if (isSticky) {
14277 this.stickySource.addEventDef(eventDef);
14278 }
14279
14280 if (this.currentPeriod) {
14281 this.currentPeriod.addEventDef(eventDef); // might release
14282 }
14283 },
14284
14285
14286 removeEventDefsById: function(eventId) {
14287 this.getSources().forEach(function(eventSource) {
14288 eventSource.removeEventDefsById(eventId);
14289 });
14290
14291 if (this.currentPeriod) {
14292 this.currentPeriod.removeEventDefsById(eventId); // might release
14293 }
14294 },
14295
14296
14297 removeAllEventDefs: function() {
14298 this.getSources().forEach(function(eventSource) {
14299 eventSource.removeAllEventDefs();
14300 });
14301
14302 if (this.currentPeriod) {
14303 this.currentPeriod.removeAllEventDefs();
14304 }
14305 },
14306
14307
14308 // Event Mutating
14309 // -----------------------------------------------------------------------------------------------------------------
14310
14311
14312 /*
14313 Returns an undo function.
14314 */
14315 mutateEventsWithId: function(eventDefId, eventDefMutation) {
14316 var currentPeriod = this.currentPeriod;
14317 var eventDefs;
14318 var undoFuncs = [];
14319
14320 if (currentPeriod) {
14321
14322 currentPeriod.freeze();
14323
14324 eventDefs = currentPeriod.getEventDefsById(eventDefId);
14325 eventDefs.forEach(function(eventDef) {
14326 // add/remove esp because id might change
14327 currentPeriod.removeEventDef(eventDef);
14328 undoFuncs.push(eventDefMutation.mutateSingle(eventDef));
14329 currentPeriod.addEventDef(eventDef);
14330 });
14331
14332 currentPeriod.thaw();
14333
14334 return function() {
14335 currentPeriod.freeze();
14336
14337 for (var i = 0; i < eventDefs.length; i++) {
14338 currentPeriod.removeEventDef(eventDefs[i]);
14339 undoFuncs[i]();
14340 currentPeriod.addEventDef(eventDefs[i]);
14341 }
14342
14343 currentPeriod.thaw();
14344 };
14345 }
14346
14347 return function() { };
14348 },
14349
14350
14351 /*
14352 copies and then mutates
14353 */
14354 buildMutatedEventInstanceGroup: function(eventDefId, eventDefMutation) {
14355 var eventDefs = this.getEventDefsById(eventDefId);
14356 var i;
14357 var defCopy;
14358 var allInstances = [];
14359
14360 for (i = 0; i < eventDefs.length; i++) {
14361 defCopy = eventDefs[i].clone();
14362
14363 if (defCopy instanceof SingleEventDef) {
14364 eventDefMutation.mutateSingle(defCopy);
14365
14366 allInstances.push.apply(allInstances, // append
14367 defCopy.buildInstances()
14368 );
14369 }
14370 }
14371
14372 return new EventInstanceGroup(allInstances);
14373 },
14374
14375
14376 // Freezing
14377 // -----------------------------------------------------------------------------------------------------------------
14378
14379
14380 freeze: function() {
14381 if (this.currentPeriod) {
14382 this.currentPeriod.freeze();
14383 }
14384 },
14385
14386
14387 thaw: function() {
14388 if (this.currentPeriod) {
14389 this.currentPeriod.thaw();
14390 }
14391 }
14392
14393 });
14394
14395
14396 // Methods that straight-up query the current EventPeriod for an array of results.
14397 [
14398 'getEventDefsById',
14399 'getEventInstances',
14400 'getEventInstancesWithId',
14401 'getEventInstancesWithoutId'
14402 ].forEach(function(methodName) {
14403
14404 EventManager.prototype[methodName] = function() {
14405 var currentPeriod = this.currentPeriod;
14406
14407 if (currentPeriod) {
14408 return currentPeriod[methodName].apply(currentPeriod, arguments);
14409 }
14410
14411 return [];
14412 };
14413 });
14414
14415
14416 function isSourcesEquivalent(source0, source1) {
14417 return source0.getPrimitive() == source1.getPrimitive();
14418 }
14419
14420 ;;
14421
14422 var EventPeriod = Class.extend(EmitterMixin, {
14423
14424 start: null,
14425 end: null,
14426 timezone: null,
14427
14428 unzonedRange: null,
14429
14430 requestsByUid: null,
14431 pendingCnt: 0,
14432
14433 freezeDepth: 0,
14434 stuntedReleaseCnt: 0,
14435 releaseCnt: 0,
14436
14437 eventDefsByUid: null,
14438 eventDefsById: null,
14439 eventInstanceGroupsById: null,
14440
14441
14442 constructor: function(start, end, timezone) {
14443 this.start = start;
14444 this.end = end;
14445 this.timezone = timezone;
14446
14447 this.unzonedRange = new UnzonedRange(
14448 start.clone().stripZone(),
14449 end.clone().stripZone()
14450 );
14451
14452 this.requestsByUid = {};
14453 this.eventDefsByUid = {};
14454 this.eventDefsById = {};
14455 this.eventInstanceGroupsById = {};
14456 },
14457
14458
14459 isWithinRange: function(start, end) {
14460 // TODO: use a range util function?
14461 return !start.isBefore(this.start) && !end.isAfter(this.end);
14462 },
14463
14464
14465 // Requesting and Purging
14466 // -----------------------------------------------------------------------------------------------------------------
14467
14468
14469 requestSources: function(sources) {
14470 this.freeze();
14471
14472 for (var i = 0; i < sources.length; i++) {
14473 this.requestSource(sources[i]);
14474 }
14475
14476 this.thaw();
14477 },
14478
14479
14480 requestSource: function(source) {
14481 var _this = this;
14482 var request = { source: source, status: 'pending' };
14483
14484 this.requestsByUid[source.uid] = request;
14485 this.pendingCnt += 1;
14486
14487 source.fetch(this.start, this.end, this.timezone).then(function(eventDefs) {
14488 if (request.status !== 'cancelled') {
14489 request.status = 'completed';
14490 request.eventDefs = eventDefs;
14491
14492 _this.addEventDefs(eventDefs);
14493 _this.pendingCnt--;
14494 _this.tryRelease();
14495 }
14496 }, function() { // failure
14497 if (request.status !== 'cancelled') {
14498 request.status = 'failed';
14499
14500 _this.pendingCnt--;
14501 _this.tryRelease();
14502 }
14503 });
14504 },
14505
14506
14507 purgeSource: function(source) {
14508 var request = this.requestsByUid[source.uid];
14509
14510 if (request) {
14511 delete this.requestsByUid[source.uid];
14512
14513 if (request.status === 'pending') {
14514 request.status = 'cancelled';
14515 this.pendingCnt--;
14516 this.tryRelease();
14517 }
14518 else if (request.status === 'completed') {
14519 request.eventDefs.forEach(this.removeEventDef.bind(this));
14520 }
14521 }
14522 },
14523
14524
14525 purgeAllSources: function() {
14526 var requestsByUid = this.requestsByUid;
14527 var uid, request;
14528 var completedCnt = 0;
14529
14530 for (uid in requestsByUid) {
14531 request = requestsByUid[uid];
14532
14533 if (request.status === 'pending') {
14534 request.status = 'cancelled';
14535 }
14536 else if (request.status === 'completed') {
14537 completedCnt++;
14538 }
14539 }
14540
14541 this.requestsByUid = {};
14542 this.pendingCnt = 0;
14543
14544 if (completedCnt) {
14545 this.removeAllEventDefs(); // might release
14546 }
14547 },
14548
14549
14550 // Event Definitions
14551 // -----------------------------------------------------------------------------------------------------------------
14552
14553
14554 getEventDefByUid: function(eventDefUid) {
14555 return this.eventDefsByUid[eventDefUid];
14556 },
14557
14558
14559 getEventDefsById: function(eventDefId) {
14560 var a = this.eventDefsById[eventDefId];
14561
14562 if (a) {
14563 return a.slice(); // clone
14564 }
14565
14566 return [];
14567 },
14568
14569
14570 addEventDefs: function(eventDefs) {
14571 for (var i = 0; i < eventDefs.length; i++) {
14572 this.addEventDef(eventDefs[i]);
14573 }
14574 },
14575
14576
14577 addEventDef: function(eventDef) {
14578 var eventDefsById = this.eventDefsById;
14579 var eventDefId = eventDef.id;
14580 var eventDefs = eventDefsById[eventDefId] || (eventDefsById[eventDefId] = []);
14581 var eventInstances = eventDef.buildInstances(this.unzonedRange);
14582 var i;
14583
14584 eventDefs.push(eventDef);
14585
14586 this.eventDefsByUid[eventDef.uid] = eventDef;
14587
14588 for (i = 0; i < eventInstances.length; i++) {
14589 this.addEventInstance(eventInstances[i], eventDefId);
14590 }
14591 },
14592
14593
14594 removeEventDefsById: function(eventDefId) {
14595 var _this = this;
14596
14597 this.getEventDefsById(eventDefId).forEach(function(eventDef) {
14598 _this.removeEventDef(eventDef);
14599 });
14600 },
14601
14602
14603 removeAllEventDefs: function() {
14604 var isEmpty = $.isEmptyObject(this.eventDefsByUid);
14605
14606 this.eventDefsByUid = {};
14607 this.eventDefsById = {};
14608 this.eventInstanceGroupsById = {};
14609
14610 if (!isEmpty) {
14611 this.tryRelease();
14612 }
14613 },
14614
14615
14616 removeEventDef: function(eventDef) {
14617 var eventDefsById = this.eventDefsById;
14618 var eventDefs = eventDefsById[eventDef.id];
14619
14620 delete this.eventDefsByUid[eventDef.uid];
14621
14622 if (eventDefs) {
14623 removeExact(eventDefs, eventDef);
14624
14625 if (!eventDefs.length) {
14626 delete eventDefsById[eventDef.id];
14627 }
14628
14629 this.removeEventInstancesForDef(eventDef);
14630 }
14631 },
14632
14633
14634 // Event Instances
14635 // -----------------------------------------------------------------------------------------------------------------
14636
14637
14638 getEventInstances: function() { // TODO: consider iterator
14639 var eventInstanceGroupsById = this.eventInstanceGroupsById;
14640 var eventInstances = [];
14641 var id;
14642
14643 for (id in eventInstanceGroupsById) {
14644 eventInstances.push.apply(eventInstances, // append
14645 eventInstanceGroupsById[id].eventInstances
14646 );
14647 }
14648
14649 return eventInstances;
14650 },
14651
14652
14653 getEventInstancesWithId: function(eventDefId) {
14654 var eventInstanceGroup = this.eventInstanceGroupsById[eventDefId];
14655
14656 if (eventInstanceGroup) {
14657 return eventInstanceGroup.eventInstances.slice(); // clone
14658 }
14659
14660 return [];
14661 },
14662
14663
14664 getEventInstancesWithoutId: function(eventDefId) { // TODO: consider iterator
14665 var eventInstanceGroupsById = this.eventInstanceGroupsById;
14666 var matchingInstances = [];
14667 var id;
14668
14669 for (id in eventInstanceGroupsById) {
14670 if (id !== eventDefId) {
14671 matchingInstances.push.apply(matchingInstances, // append
14672 eventInstanceGroupsById[id].eventInstances
14673 );
14674 }
14675 }
14676
14677 return matchingInstances;
14678 },
14679
14680
14681 addEventInstance: function(eventInstance, eventDefId) {
14682 var eventInstanceGroupsById = this.eventInstanceGroupsById;
14683 var eventInstanceGroup = eventInstanceGroupsById[eventDefId] ||
14684 (eventInstanceGroupsById[eventDefId] = new EventInstanceGroup());
14685
14686 eventInstanceGroup.eventInstances.push(eventInstance);
14687
14688 this.tryRelease();
14689 },
14690
14691
14692 removeEventInstancesForDef: function(eventDef) {
14693 var eventInstanceGroupsById = this.eventInstanceGroupsById;
14694 var eventInstanceGroup = eventInstanceGroupsById[eventDef.id];
14695 var removeCnt;
14696
14697 if (eventInstanceGroup) {
14698 removeCnt = removeMatching(eventInstanceGroup.eventInstances, function(currentEventInstance) {
14699 return currentEventInstance.def === eventDef;
14700 });
14701
14702 if (!eventInstanceGroup.eventInstances.length) {
14703 delete eventInstanceGroupsById[eventDef.id];
14704 }
14705
14706 if (removeCnt) {
14707 this.tryRelease();
14708 }
14709 }
14710 },
14711
14712
14713 // Releasing and Freezing
14714 // -----------------------------------------------------------------------------------------------------------------
14715
14716
14717 tryRelease: function() {
14718 if (!this.pendingCnt) {
14719 if (!this.freezeDepth) {
14720 this.release();
14721 }
14722 else {
14723 this.stuntedReleaseCnt++;
14724 }
14725 }
14726 },
14727
14728
14729 release: function() {
14730 this.releaseCnt++;
14731 this.trigger('release', this.eventInstanceGroupsById);
14732 },
14733
14734
14735 whenReleased: function() {
14736 var _this = this;
14737
14738 if (this.releaseCnt) {
14739 return Promise.resolve(this.eventInstanceGroupsById);
14740 }
14741 else {
14742 return Promise.construct(function(onResolve) {
14743 _this.one('release', onResolve);
14744 });
14745 }
14746 },
14747
14748
14749 freeze: function() {
14750 if (!(this.freezeDepth++)) {
14751 this.stuntedReleaseCnt = 0;
14752 }
14753 },
14754
14755
14756 thaw: function() {
14757 if (!(--this.freezeDepth) && this.stuntedReleaseCnt && !this.pendingCnt) {
14758 this.release();
14759 }
14760 }
14761
14762 });
14763
14764 ;;
14765
14766 var EventDefParser = {
14767
14768 parse: function(eventInput, source) {
14769 if (
14770 isTimeString(eventInput.start) || moment.isDuration(eventInput.start) ||
14771 isTimeString(eventInput.end) || moment.isDuration(eventInput.end)
14772 ) {
14773 return RecurringEventDef.parse(eventInput, source);
14774 }
14775 else {
14776 return SingleEventDef.parse(eventInput, source);
14777 }
14778 }
14779
14780 };
14781
14782 ;;
14783
14784 var EventDef = FC.EventDef = Class.extend(ParsableModelMixin, {
14785
14786 source: null, // required
14787
14788 id: null, // normalized supplied ID
14789 rawId: null, // unnormalized supplied ID
14790 uid: null, // internal ID. new ID for every definition
14791
14792 // NOTE: eventOrder sorting relies on these
14793 title: null,
14794 url: null,
14795 rendering: null,
14796 constraint: null,
14797 overlap: null,
14798 editable: null,
14799 startEditable: null,
14800 durationEditable: null,
14801 color: null,
14802 backgroundColor: null,
14803 borderColor: null,
14804 textColor: null,
14805
14806 className: null, // an array. TODO: rename to className*s* (API breakage)
14807 miscProps: null,
14808
14809
14810 constructor: function(source) {
14811 this.source = source;
14812 this.className = [];
14813 this.miscProps = {};
14814 },
14815
14816
14817 isAllDay: function() {
14818 // subclasses must implement
14819 },
14820
14821
14822 buildInstances: function(unzonedRange) {
14823 // subclasses must implement
14824 },
14825
14826
14827 clone: function() {
14828 var copy = new this.constructor(this.source);
14829
14830 copy.id = this.id;
14831 copy.rawId = this.rawId;
14832 copy.uid = this.uid; // not really unique anymore :(
14833
14834 EventDef.copyVerbatimStandardProps(this, copy);
14835
14836 copy.className = this.className; // should clone?
14837 copy.miscProps = $.extend({}, this.miscProps);
14838
14839 return copy;
14840 },
14841
14842
14843 hasInverseRendering: function() {
14844 return this.getRendering() === 'inverse-background';
14845 },
14846
14847
14848 hasBgRendering: function() {
14849 var rendering = this.getRendering();
14850
14851 return rendering === 'inverse-background' || rendering === 'background';
14852 },
14853
14854
14855 getRendering: function() {
14856 if (this.rendering != null) {
14857 return this.rendering;
14858 }
14859
14860 return this.source.rendering;
14861 },
14862
14863
14864 getConstraint: function() {
14865 if (this.constraint != null) {
14866 return this.constraint;
14867 }
14868
14869 if (this.source.constraint != null) {
14870 return this.source.constraint;
14871 }
14872
14873 return this.source.calendar.opt('eventConstraint'); // what about View option?
14874 },
14875
14876
14877 getOverlap: function() {
14878 if (this.overlap != null) {
14879 return this.overlap;
14880 }
14881
14882 if (this.source.overlap != null) {
14883 return this.source.overlap;
14884 }
14885
14886 return this.source.calendar.opt('eventOverlap'); // what about View option?
14887 },
14888
14889
14890 isStartExplicitlyEditable: function() {
14891 if (this.startEditable !== null) {
14892 return this.startEditable;
14893 }
14894
14895 return this.source.startEditable;
14896 },
14897
14898
14899 isDurationExplicitlyEditable: function() {
14900 if (this.durationEditable !== null) {
14901 return this.durationEditable;
14902 }
14903
14904 return this.source.durationEditable;
14905 },
14906
14907
14908 isExplicitlyEditable: function() {
14909 if (this.editable !== null) {
14910 return this.editable;
14911 }
14912
14913 return this.source.editable;
14914 },
14915
14916
14917 toLegacy: function() {
14918 var obj = $.extend({}, this.miscProps);
14919
14920 obj._id = this.uid;
14921 obj.source = this.source;
14922 obj.className = this.className; // should clone?
14923 obj.allDay = this.isAllDay();
14924
14925 if (this.rawId != null) {
14926 obj.id = this.rawId;
14927 }
14928
14929 EventDef.copyVerbatimStandardProps(this, obj);
14930
14931 return obj;
14932 },
14933
14934
14935 applyManualRawProps: function(rawProps) {
14936
14937 if (rawProps.id != null) {
14938 this.id = EventDef.normalizeId((this.rawId = rawProps.id));
14939 }
14940 else {
14941 this.id = EventDef.generateId();
14942 }
14943
14944 if (rawProps._id != null) { // accept this prop, even tho somewhat internal
14945 this.uid = String(rawProps._id);
14946 }
14947 else {
14948 this.uid = EventDef.generateId();
14949 }
14950
14951 // TODO: converge with EventSource
14952 if ($.isArray(rawProps.className)) {
14953 this.className = rawProps.className;
14954 }
14955 if (typeof rawProps.className === 'string') {
14956 this.className = rawProps.className.split(/\s+/);
14957 }
14958
14959 return true;
14960 },
14961
14962
14963 applyOtherRawProps: function(rawProps) {
14964 this.miscProps = rawProps;
14965 }
14966
14967 });
14968
14969 // finish initializing the mixin
14970 EventDef.allowRawProps = ParsableModelMixin_allowRawProps;
14971 EventDef.copyVerbatimStandardProps = ParsableModelMixin_copyVerbatimStandardProps;
14972
14973
14974 // IDs
14975 // ---------------------------------------------------------------------------------------------------------------------
14976 // TODO: converge with EventSource
14977
14978
14979 EventDef.uuid = 0;
14980
14981
14982 EventDef.normalizeId = function(id) {
14983 return String(id);
14984 };
14985
14986
14987 EventDef.generateId = function() {
14988 return '_fc' + (EventDef.uuid++);
14989 };
14990
14991
14992 // Parsing
14993 // ---------------------------------------------------------------------------------------------------------------------
14994
14995
14996 EventDef.allowRawProps({
14997 // not automatically assigned (`false`)
14998 _id: false,
14999 id: false,
15000 className: false,
15001 source: false, // will ignored
15002
15003 // automatically assigned (`true`)
15004 title: true,
15005 url: true,
15006 rendering: true,
15007 constraint: true,
15008 overlap: true,
15009 editable: true,
15010 startEditable: true,
15011 durationEditable: true,
15012 color: true,
15013 backgroundColor: true,
15014 borderColor: true,
15015 textColor: true
15016 });
15017
15018
15019 EventDef.parse = function(rawInput, source) {
15020 var def = new this(source);
15021 var calendarTransform = source.calendar.opt('eventDataTransform');
15022 var sourceTransform = source.eventDataTransform;
15023
15024 if (calendarTransform) {
15025 rawInput = calendarTransform(rawInput);
15026 }
15027 if (sourceTransform) {
15028 rawInput = sourceTransform(rawInput);
15029 }
15030
15031 if (def.applyRawProps(rawInput)) {
15032 return def;
15033 }
15034
15035 return false;
15036 };
15037
15038 ;;
15039
15040 var SingleEventDef = EventDef.extend({
15041
15042 dateProfile: null,
15043
15044
15045 /*
15046 Will receive start/end params, but will be ignored.
15047 */
15048 buildInstances: function() {
15049 return [ this.buildInstance() ];
15050 },
15051
15052
15053 buildInstance: function() {
15054 return new EventInstance(
15055 this, // definition
15056 this.dateProfile
15057 );
15058 },
15059
15060
15061 isAllDay: function() {
15062 return this.dateProfile.isAllDay();
15063 },
15064
15065
15066 clone: function() {
15067 var def = EventDef.prototype.clone.call(this);
15068
15069 def.dateProfile = this.dateProfile;
15070
15071 return def;
15072 },
15073
15074
15075 rezone: function() {
15076 var calendar = this.source.calendar;
15077 var dateProfile = this.dateProfile;
15078
15079 this.dateProfile = new EventDateProfile(
15080 calendar.moment(dateProfile.start),
15081 dateProfile.end ? calendar.moment(dateProfile.end) : null,
15082 calendar
15083 );
15084 },
15085
15086
15087 /*
15088 NOTE: if super-method fails, should still attempt to apply
15089 */
15090 applyManualRawProps: function(rawProps) {
15091 var superSuccess = EventDef.prototype.applyManualRawProps.apply(this, arguments);
15092 var dateProfile = EventDateProfile.parse(rawProps, this.source); // returns null on failure
15093
15094 if (dateProfile) {
15095 this.dateProfile = dateProfile;
15096
15097 // make sure `date` shows up in the legacy event objects as-is
15098 if (rawProps.date != null) {
15099 this.miscProps.date = rawProps.date;
15100 }
15101
15102 return superSuccess;
15103 }
15104 else {
15105 return false;
15106 }
15107 }
15108
15109 });
15110
15111
15112 // Parsing
15113 // ---------------------------------------------------------------------------------------------------------------------
15114
15115
15116 SingleEventDef.allowRawProps({ // false = manually process
15117 start: false,
15118 date: false, // alias for 'start'
15119 end: false,
15120 allDay: false
15121 });
15122
15123 ;;
15124
15125 var RecurringEventDef = EventDef.extend({
15126
15127 startTime: null, // duration
15128 endTime: null, // duration, or null
15129 dowHash: null, // object hash, or null
15130
15131
15132 isAllDay: function() {
15133 return !this.startTime && !this.endTime;
15134 },
15135
15136
15137 buildInstances: function(unzonedRange) {
15138 var calendar = this.source.calendar;
15139 var unzonedDate = unzonedRange.getStart();
15140 var unzonedEnd = unzonedRange.getEnd();
15141 var zonedDayStart;
15142 var instanceStart, instanceEnd;
15143 var instances = [];
15144
15145 while (unzonedDate.isBefore(unzonedEnd)) {
15146
15147 // if everyday, or this particular day-of-week
15148 if (!this.dowHash || this.dowHash[unzonedDate.day()]) {
15149
15150 zonedDayStart = calendar.applyTimezone(unzonedDate);
15151 instanceStart = zonedDayStart.clone();
15152 instanceEnd = null;
15153
15154 if (this.startTime) {
15155 instanceStart.time(this.startTime);
15156 }
15157 else {
15158 instanceStart.stripTime();
15159 }
15160
15161 if (this.endTime) {
15162 instanceEnd = zonedDayStart.clone().time(this.endTime);
15163 }
15164
15165 instances.push(
15166 new EventInstance(
15167 this, // definition
15168 new EventDateProfile(instanceStart, instanceEnd, calendar)
15169 )
15170 );
15171 }
15172
15173 unzonedDate.add(1, 'days');
15174 }
15175
15176 return instances;
15177 },
15178
15179
15180 setDow: function(dowNumbers) {
15181
15182 if (!this.dowHash) {
15183 this.dowHash = {};
15184 }
15185
15186 for (var i = 0; i < dowNumbers.length; i++) {
15187 this.dowHash[dowNumbers[i]] = true;
15188 }
15189 },
15190
15191
15192 clone: function() {
15193 var def = EventDef.prototype.clone.call(this);
15194
15195 if (def.startTime) {
15196 def.startTime = moment.duration(this.startTime);
15197 }
15198
15199 if (def.endTime) {
15200 def.endTime = moment.duration(this.endTime);
15201 }
15202
15203 if (this.dowHash) {
15204 def.dowHash = $.extend({}, this.dowHash);
15205 }
15206
15207 return def;
15208 },
15209
15210
15211 /*
15212 NOTE: if super-method fails, should still attempt to apply
15213 */
15214 applyRawProps: function(rawProps) {
15215 var superSuccess = EventDef.prototype.applyRawProps.apply(this, arguments);
15216
15217 if (rawProps.start) {
15218 this.startTime = moment.duration(rawProps.start);
15219 }
15220
15221 if (rawProps.end) {
15222 this.endTime = moment.duration(rawProps.end);
15223 }
15224
15225 if (rawProps.dow) {
15226 this.setDow(rawProps.dow);
15227 }
15228
15229 return superSuccess;
15230 }
15231
15232 });
15233
15234
15235 // Parsing
15236 // ---------------------------------------------------------------------------------------------------------------------
15237
15238
15239 RecurringEventDef.allowRawProps({ // false = manually process
15240 start: false,
15241 end: false,
15242 dow: false
15243 });
15244
15245 ;;
15246
15247 var EventInstance = Class.extend({
15248
15249 def: null, // EventDef
15250 dateProfile: null, // EventDateProfile
15251
15252
15253 constructor: function(def, dateProfile) {
15254 this.def = def;
15255 this.dateProfile = dateProfile;
15256 },
15257
15258
15259 toLegacy: function() {
15260 var dateProfile = this.dateProfile;
15261 var obj = this.def.toLegacy();
15262
15263 obj.start = dateProfile.start.clone();
15264 obj.end = dateProfile.end ? dateProfile.end.clone() : null;
15265
15266 return obj;
15267 }
15268
15269 });
15270
15271 ;;
15272
15273 /*
15274 It's expected that there will be at least one EventInstance,
15275 OR that an explicitEventDef is assigned.
15276 */
15277 var EventInstanceGroup = Class.extend({
15278
15279 eventInstances: null,
15280 explicitEventDef: null, // optional
15281
15282
15283 constructor: function(eventInstances) {
15284 this.eventInstances = eventInstances || [];
15285 },
15286
15287
15288 getAllEventRanges: function() {
15289 return eventInstancesToEventRanges(this.eventInstances);
15290 },
15291
15292
15293 sliceRenderRanges: function(constraintRange) {
15294 if (this.isInverse()) {
15295 return this.sliceInverseRenderRanges(constraintRange);
15296 }
15297 else {
15298 return this.sliceNormalRenderRanges(constraintRange);
15299 }
15300 },
15301
15302
15303 sliceNormalRenderRanges: function(constraintRange) {
15304 var eventInstances = this.eventInstances;
15305 var i, eventInstance;
15306 var slicedRange;
15307 var slicedEventRanges = [];
15308
15309 for (i = 0; i < eventInstances.length; i++) {
15310 eventInstance = eventInstances[i];
15311
15312 slicedRange = eventInstance.dateProfile.unzonedRange.intersect(constraintRange);
15313
15314 if (slicedRange) {
15315 slicedEventRanges.push(
15316 new EventRange(
15317 slicedRange,
15318 eventInstance.def,
15319 eventInstance
15320 )
15321 );
15322 }
15323 }
15324
15325 return slicedEventRanges;
15326 },
15327
15328
15329 sliceInverseRenderRanges: function(constraintRange) {
15330 var unzonedRanges = eventInstancesToUnzonedRanges(this.eventInstances);
15331 var ownerDef = this.getEventDef();
15332
15333 unzonedRanges = invertUnzonedRanges(unzonedRanges, constraintRange);
15334
15335 return unzonedRanges.map(function(unzonedRange) {
15336 return new EventRange(unzonedRange, ownerDef); // don't give an EventDef
15337 });
15338 },
15339
15340
15341 isInverse: function() {
15342 return this.getEventDef().hasInverseRendering();
15343 },
15344
15345
15346 getEventDef: function() {
15347 return this.explicitEventDef || this.eventInstances[0].def;
15348 }
15349
15350 });
15351
15352 ;;
15353
15354 /*
15355 Meant to be immutable
15356 */
15357 var EventDateProfile = Class.extend({
15358
15359 start: null,
15360 end: null,
15361 unzonedRange: null,
15362
15363
15364 constructor: function(start, end, calendar) {
15365 this.start = start;
15366 this.end = end || null;
15367 this.unzonedRange = this.buildUnzonedRange(calendar);
15368 },
15369
15370
15371 isAllDay: function() {
15372 return !(this.start.hasTime() || (this.end && this.end.hasTime()));
15373 },
15374
15375
15376 /*
15377 Needs a Calendar object
15378 */
15379 buildUnzonedRange: function(calendar) {
15380 var startMs = this.start.clone().stripZone().valueOf();
15381 var endMs = this.getEnd(calendar).stripZone().valueOf();
15382
15383 return new UnzonedRange(startMs, endMs);
15384 },
15385
15386
15387 /*
15388 Needs a Calendar object
15389 */
15390 getEnd: function(calendar) {
15391 return this.end ?
15392 this.end.clone() :
15393 // derive the end from the start and allDay. compute allDay if necessary
15394 calendar.getDefaultEventEnd(
15395 this.isAllDay(),
15396 this.start
15397 );
15398 }
15399
15400 });
15401
15402
15403 /*
15404 Needs an EventSource object
15405 */
15406 EventDateProfile.parse = function(rawProps, source) {
15407 var startInput = rawProps.start || rawProps.date;
15408 var endInput = rawProps.end;
15409
15410 if (!startInput) {
15411 return false;
15412 }
15413
15414 var calendar = source.calendar;
15415 var start = calendar.moment(startInput);
15416 var end = endInput ? calendar.moment(endInput) : null;
15417 var forcedAllDay = rawProps.allDay;
15418 var forceEventDuration = calendar.opt('forceEventDuration');
15419
15420 if (!start.isValid()) {
15421 return false;
15422 }
15423
15424 if (end && (!end.isValid() || !end.isAfter(start))) {
15425 end = null;
15426 }
15427
15428 if (forcedAllDay == null) {
15429 forcedAllDay = source.allDayDefault;
15430 if (forcedAllDay == null) {
15431 forcedAllDay = calendar.opt('allDayDefault');
15432 }
15433 }
15434
15435 if (forcedAllDay === true) {
15436 start.stripTime();
15437 if (end) {
15438 end.stripTime();
15439 }
15440 }
15441 else if (forcedAllDay === false) {
15442 if (!start.hasTime()) {
15443 start.time(0);
15444 }
15445 if (end && !end.hasTime()) {
15446 end.time(0);
15447 }
15448 }
15449
15450 if (!end && forceEventDuration) {
15451 end = calendar.getDefaultEventEnd(!start.hasTime(), start);
15452 }
15453
15454 return new EventDateProfile(start, end, calendar);
15455 };
15456
15457 ;;
15458
15459 var EventRange = Class.extend({
15460
15461 unzonedRange: null,
15462 eventDef: null,
15463 eventInstance: null, // optional
15464
15465
15466 constructor: function(unzonedRange, eventDef, eventInstance) {
15467 this.unzonedRange = unzonedRange;
15468 this.eventDef = eventDef;
15469
15470 if (eventInstance) {
15471 this.eventInstance = eventInstance;
15472 }
15473 }
15474
15475 });
15476
15477 ;;
15478
15479 var EventFootprint = FC.EventFootprint = Class.extend({
15480
15481 componentFootprint: null,
15482 eventDef: null,
15483 eventInstance: null, // optional
15484
15485
15486 constructor: function(componentFootprint, eventDef, eventInstance) {
15487 this.componentFootprint = componentFootprint;
15488 this.eventDef = eventDef;
15489
15490 if (eventInstance) {
15491 this.eventInstance = eventInstance;
15492 }
15493 },
15494
15495
15496 getEventLegacy: function() {
15497 return (this.eventInstance || this.eventDef).toLegacy();
15498 }
15499
15500 });
15501
15502 ;;
15503
15504 var EventDefMutation = FC.EventDefMutation = Class.extend({
15505
15506 // won't ever be empty. will be null instead.
15507 // callers should use setDateMutation for setting.
15508 dateMutation: null,
15509
15510 // hack to get updateEvent/createFromRawProps to work.
15511 // not undo-able and not considered in isEmpty.
15512 rawProps: null, // raw (pre-parse-like)
15513
15514
15515 /*
15516 eventDef assumed to be a SingleEventDef.
15517 returns an undo function.
15518 */
15519 mutateSingle: function(eventDef) {
15520 var origDateProfile;
15521
15522 if (this.dateMutation) {
15523 origDateProfile = eventDef.dateProfile;
15524
15525 eventDef.dateProfile = this.dateMutation.buildNewDateProfile(
15526 origDateProfile,
15527 eventDef.source.calendar
15528 );
15529 }
15530
15531 // can't undo
15532 if (this.rawProps) {
15533 eventDef.applyRawProps(this.rawProps);
15534 }
15535
15536 if (origDateProfile) {
15537 return function() {
15538 eventDef.dateProfile = origDateProfile;
15539 };
15540 }
15541 else {
15542 return function() { };
15543 }
15544 },
15545
15546
15547 setDateMutation: function(dateMutation) {
15548 if (dateMutation && !dateMutation.isEmpty()) {
15549 this.dateMutation = dateMutation;
15550 }
15551 else {
15552 this.dateMutation = null;
15553 }
15554 },
15555
15556
15557 isEmpty: function() {
15558 return !this.dateMutation;
15559 }
15560
15561 });
15562
15563
15564 EventDefMutation.createFromRawProps = function(eventInstance, newRawProps, largeUnit) {
15565 var eventDef = eventInstance.def;
15566 var applicableRawProps = {};
15567 var propName;
15568 var newDateProfile;
15569 var dateMutation;
15570 var defMutation;
15571
15572 for (propName in newRawProps) {
15573 if (
15574 // ignore object-type custom properties and any date-related properties,
15575 // as well as any other internal property
15576 typeof newRawProps[propName] !== 'object' &&
15577 propName !== 'start' && propName !== 'end' && propName !== 'allDay' &&
15578 propName !== 'source' && propName !== '_id'
15579 ) {
15580 applicableRawProps[propName] = newRawProps[propName];
15581 }
15582 }
15583
15584 newDateProfile = EventDateProfile.parse(newRawProps, eventDef.source);
15585
15586 if (newDateProfile) { // no failure?
15587 dateMutation = EventDefDateMutation.createFromDiff(
15588 eventInstance.dateProfile,
15589 newDateProfile,
15590 largeUnit
15591 );
15592 }
15593
15594 defMutation = new EventDefMutation();
15595 defMutation.rawProps = applicableRawProps;
15596
15597 if (dateMutation) {
15598 defMutation.dateMutation = dateMutation;
15599 }
15600
15601 return defMutation;
15602 };
15603
15604 ;;
15605
15606 var EventDefDateMutation = Class.extend({
15607
15608 clearEnd: false,
15609 forceTimed: false,
15610 forceAllDay: false,
15611
15612 // Durations. if 0-ms duration, will be null instead.
15613 // Callers should not set this directly.
15614 dateDelta: null,
15615 startDelta: null,
15616 endDelta: null,
15617
15618
15619 /*
15620 returns an undo function.
15621 */
15622 buildNewDateProfile: function(eventDateProfile, calendar) {
15623 var start = eventDateProfile.start.clone();
15624 var end = null;
15625 var shouldRezone = false;
15626
15627 if (!this.clearEnd && eventDateProfile.end) {
15628 end = eventDateProfile.end.clone();
15629 }
15630
15631 if (this.forceTimed) {
15632 shouldRezone = true;
15633
15634 if (!start.hasTime()) {
15635 start.time(0);
15636 }
15637
15638 if (end && !end.hasTime()) {
15639 end.time(0);
15640 }
15641 }
15642 else if (this.forceAllDay) {
15643
15644 if (start.hasTime()) {
15645 start.stripTime();
15646 }
15647
15648 if (end && end.hasTime()) {
15649 end.stripTime();
15650 }
15651 }
15652
15653 if (this.dateDelta) {
15654 shouldRezone = true;
15655
15656 start.add(this.dateDelta);
15657
15658 if (end) {
15659 end.add(this.dateDelta);
15660 }
15661 }
15662
15663 // do this before adding startDelta to start, so we can work off of start
15664 if (this.endDelta) {
15665 shouldRezone = true;
15666
15667 if (!end) {
15668 end = calendar.getDefaultEventEnd(eventDateProfile.isAllDay(), start);
15669 }
15670
15671 end.add(this.endDelta);
15672 }
15673
15674 if (this.startDelta) {
15675 shouldRezone = true;
15676
15677 start.add(this.startDelta);
15678 }
15679
15680 if (shouldRezone) {
15681 start = calendar.applyTimezone(start);
15682
15683 if (end) {
15684 end = calendar.applyTimezone(end);
15685 }
15686 }
15687
15688 // TODO: okay to access calendar option?
15689 if (!end && calendar.opt('forceEventDuration')) {
15690 end = calendar.getDefaultEventEnd(eventDateProfile.isAllDay(), start);
15691 }
15692
15693 return new EventDateProfile(start, end, calendar);
15694 },
15695
15696
15697 setDateDelta: function(dateDelta) {
15698 if (dateDelta && dateDelta.valueOf()) {
15699 this.dateDelta = dateDelta;
15700 }
15701 else {
15702 this.dateDelta = null;
15703 }
15704 },
15705
15706
15707 setStartDelta: function(startDelta) {
15708 if (startDelta && startDelta.valueOf()) {
15709 this.startDelta = startDelta;
15710 }
15711 else {
15712 this.startDelta = null;
15713 }
15714 },
15715
15716
15717 setEndDelta: function(endDelta) {
15718 if (endDelta && endDelta.valueOf()) {
15719 this.endDelta = endDelta;
15720 }
15721 else {
15722 this.endDelta = null;
15723 }
15724 },
15725
15726
15727 isEmpty: function() {
15728 return !this.clearEnd && !this.forceTimed && !this.forceAllDay &&
15729 !this.dateDelta && !this.startDelta && !this.endDelta;
15730 }
15731
15732 });
15733
15734
15735 EventDefDateMutation.createFromDiff = function(dateProfile0, dateProfile1, largeUnit) {
15736 var clearEnd = dateProfile0.end && !dateProfile1.end;
15737 var forceTimed = dateProfile0.isAllDay() && !dateProfile1.isAllDay();
15738 var forceAllDay = !dateProfile0.isAllDay() && dateProfile1.isAllDay();
15739 var dateDelta;
15740 var endDiff;
15741 var endDelta;
15742 var mutation;
15743
15744 // subtracts the dates in the appropriate way, returning a duration
15745 function subtractDates(date1, date0) { // date1 - date0
15746 if (largeUnit) {
15747 return diffByUnit(date1, date0, largeUnit); // poorly named
15748 }
15749 else if (dateProfile1.isAllDay()) {
15750 return diffDay(date1, date0); // poorly named
15751 }
15752 else {
15753 return diffDayTime(date1, date0); // poorly named
15754 }
15755 }
15756
15757 dateDelta = subtractDates(dateProfile1.start, dateProfile0.start);
15758
15759 if (dateProfile1.end) {
15760 // use unzonedRanges because dateProfile0.end might be null
15761 endDiff = subtractDates(
15762 dateProfile1.unzonedRange.getEnd(),
15763 dateProfile0.unzonedRange.getEnd()
15764 );
15765 endDelta = endDiff.subtract(dateDelta);
15766 }
15767
15768 mutation = new EventDefDateMutation();
15769 mutation.clearEnd = clearEnd;
15770 mutation.forceTimed = forceTimed;
15771 mutation.forceAllDay = forceAllDay;
15772 mutation.setDateDelta(dateDelta);
15773 mutation.setEndDelta(endDelta);
15774
15775 return mutation;
15776 };
15777
15778 ;;
15779
15780 function eventDefsToEventInstances(eventDefs, unzonedRange) {
15781 var eventInstances = [];
15782 var i;
15783
15784 for (i = 0; i < eventDefs.length; i++) {
15785 eventInstances.push.apply(eventInstances, // append
15786 eventDefs[i].buildInstances(unzonedRange)
15787 );
15788 }
15789
15790 return eventInstances;
15791 }
15792
15793
15794 function eventInstancesToEventRanges(eventInstances) {
15795 return eventInstances.map(function(eventInstance) {
15796 return new EventRange(
15797 eventInstance.dateProfile.unzonedRange,
15798 eventInstance.def,
15799 eventInstance
15800 );
15801 });
15802 }
15803
15804
15805 function eventInstancesToUnzonedRanges(eventInstances) {
15806 return eventInstances.map(function(eventInstance) {
15807 return eventInstance.dateProfile.unzonedRange;
15808 });
15809 }
15810
15811
15812 function eventFootprintsToComponentFootprints(eventFootprints) {
15813 return eventFootprints.map(function(eventFootprint) {
15814 return eventFootprint.componentFootprint;
15815 });
15816 }
15817
15818 ;;
15819
15820 var EventSource = Class.extend(ParsableModelMixin, {
15821
15822 calendar: null,
15823
15824 id: null, // can stay null
15825 uid: null,
15826 color: null,
15827 backgroundColor: null,
15828 borderColor: null,
15829 textColor: null,
15830 className: null, // array
15831 editable: null,
15832 startEditable: null,
15833 durationEditable: null,
15834 rendering: null,
15835 overlap: null,
15836 constraint: null,
15837 allDayDefault: null,
15838 eventDataTransform: null, // optional function
15839
15840
15841 constructor: function(calendar) {
15842 this.calendar = calendar;
15843 this.className = [];
15844 this.uid = String(EventSource.uuid++);
15845 },
15846
15847
15848 fetch: function(start, end, timezone) {
15849 // subclasses must implement. must return a promise.
15850 },
15851
15852
15853 removeEventDefsById: function(eventDefId) {
15854 // optional for subclasses to implement
15855 },
15856
15857
15858 removeAllEventDefs: function() {
15859 // optional for subclasses to implement
15860 },
15861
15862
15863 /*
15864 For compairing/matching
15865 */
15866 getPrimitive: function(otherSource) {
15867 // subclasses must implement
15868 },
15869
15870
15871 parseEventDefs: function(rawEventDefs) {
15872 var i;
15873 var eventDef;
15874 var eventDefs = [];
15875
15876 for (i = 0; i < rawEventDefs.length; i++) {
15877 eventDef = EventDefParser.parse(
15878 rawEventDefs[i],
15879 this // source
15880 );
15881
15882 if (eventDef) {
15883 eventDefs.push(eventDef);
15884 }
15885 }
15886
15887 return eventDefs;
15888 },
15889
15890
15891 applyManualRawProps: function(rawProps) {
15892
15893 if (rawProps.id != null) {
15894 this.id = EventSource.normalizeId(rawProps.id);
15895 }
15896
15897 // TODO: converge with EventDef
15898 if ($.isArray(rawProps.className)) {
15899 this.className = rawProps.className;
15900 }
15901 else if (typeof rawProps.className === 'string') {
15902 this.className = rawProps.className.split(/\s+/);
15903 }
15904
15905 return true;
15906 }
15907
15908 });
15909
15910
15911 // finish initializing the mixin
15912 EventSource.allowRawProps = ParsableModelMixin_allowRawProps;
15913
15914
15915 // IDs
15916 // ---------------------------------------------------------------------------------------------------------------------
15917 // TODO: converge with EventDef
15918
15919
15920 EventSource.uuid = 0;
15921
15922
15923 EventSource.normalizeId = function(id) {
15924 if (id) {
15925 return String(id);
15926 }
15927
15928 return null;
15929 };
15930
15931
15932 // Parsing
15933 // ---------------------------------------------------------------------------------------------------------------------
15934
15935
15936 EventSource.allowRawProps({
15937 // manually process...
15938 id: false,
15939 className: false,
15940
15941 // automatically transfer...
15942 color: true,
15943 backgroundColor: true,
15944 borderColor: true,
15945 textColor: true,
15946 editable: true,
15947 startEditable: true,
15948 durationEditable: true,
15949 rendering: true,
15950 overlap: true,
15951 constraint: true,
15952 allDayDefault: true,
15953 eventDataTransform: true
15954 });
15955
15956
15957 /*
15958 rawInput can be any data type!
15959 */
15960 EventSource.parse = function(rawInput, calendar) {
15961 var source = new this(calendar);
15962
15963 if (typeof rawInput === 'object') {
15964 if (source.applyRawProps(rawInput)) {
15965 return source;
15966 }
15967 }
15968
15969 return false;
15970 };
15971
15972
15973 FC.EventSource = EventSource;
15974
15975 ;;
15976
15977 var EventSourceParser = {
15978
15979 sourceClasses: [],
15980
15981
15982 registerClass: function(EventSourceClass) {
15983 this.sourceClasses.unshift(EventSourceClass); // give highest priority
15984 },
15985
15986
15987 parse: function(rawInput, calendar) {
15988 var sourceClasses = this.sourceClasses;
15989 var i;
15990 var eventSource;
15991
15992 for (i = 0; i < sourceClasses.length; i++) {
15993 eventSource = sourceClasses[i].parse(rawInput, calendar);
15994
15995 if (eventSource) {
15996 return eventSource;
15997 }
15998 }
15999 }
16000
16001 };
16002
16003
16004 FC.EventSourceParser = EventSourceParser;
16005
16006 ;;
16007
16008 var ArrayEventSource = EventSource.extend({
16009
16010 rawEventDefs: null, // unparsed
16011 eventDefs: null,
16012 currentTimezone: null,
16013
16014
16015 constructor: function(calendar) {
16016 EventSource.apply(this, arguments); // super-constructor
16017 this.eventDefs = []; // for if setRawEventDefs is never called
16018 },
16019
16020
16021 setRawEventDefs: function(rawEventDefs) {
16022 this.rawEventDefs = rawEventDefs;
16023 this.eventDefs = this.parseEventDefs(rawEventDefs);
16024 },
16025
16026
16027 fetch: function(start, end, timezone) {
16028 var eventDefs = this.eventDefs;
16029 var i;
16030
16031 if (
16032 this.currentTimezone !== null &&
16033 this.currentTimezone !== timezone
16034 ) {
16035 for (i = 0; i < eventDefs.length; i++) {
16036 if (eventDefs[i] instanceof SingleEventDef) {
16037 eventDefs[i].rezone();
16038 }
16039 }
16040 }
16041
16042 this.currentTimezone = timezone;
16043
16044 return Promise.resolve(eventDefs);
16045 },
16046
16047
16048 addEventDef: function(eventDef) {
16049 this.eventDefs.push(eventDef);
16050 },
16051
16052
16053 /*
16054 eventDefId already normalized to a string
16055 */
16056 removeEventDefsById: function(eventDefId) {
16057 return removeMatching(this.eventDefs, function(eventDef) {
16058 return eventDef.id === eventDefId;
16059 });
16060 },
16061
16062
16063 removeAllEventDefs: function() {
16064 this.eventDefs = [];
16065 },
16066
16067
16068 getPrimitive: function() {
16069 return this.rawEventDefs;
16070 },
16071
16072
16073 applyManualRawProps: function(rawProps) {
16074 var superSuccess = EventSource.prototype.applyManualRawProps.apply(this, arguments);
16075
16076 this.setRawEventDefs(rawProps.events);
16077
16078 return superSuccess;
16079 }
16080
16081 });
16082
16083
16084 ArrayEventSource.allowRawProps({
16085 events: false // don't automatically transfer
16086 });
16087
16088
16089 ArrayEventSource.parse = function(rawInput, calendar) {
16090 var rawProps;
16091
16092 // normalize raw input
16093 if ($.isArray(rawInput.events)) { // extended form
16094 rawProps = rawInput;
16095 }
16096 else if ($.isArray(rawInput)) { // short form
16097 rawProps = { events: rawInput };
16098 }
16099
16100 if (rawProps) {
16101 return EventSource.parse.call(this, rawProps, calendar);
16102 }
16103
16104 return false;
16105 };
16106
16107
16108 EventSourceParser.registerClass(ArrayEventSource);
16109
16110 FC.ArrayEventSource = ArrayEventSource;
16111
16112 ;;
16113
16114 var FuncEventSource = EventSource.extend({
16115
16116 func: null,
16117
16118
16119 fetch: function(start, end, timezone) {
16120 var _this = this;
16121
16122 this.calendar.pushLoading();
16123
16124 return Promise.construct(function(onResolve) {
16125 _this.func.call(
16126 this.calendar,
16127 start.clone(),
16128 end.clone(),
16129 timezone,
16130 function(rawEventDefs) {
16131 _this.calendar.popLoading();
16132
16133 onResolve(_this.parseEventDefs(rawEventDefs));
16134 }
16135 );
16136 });
16137 },
16138
16139
16140 getPrimitive: function() {
16141 return this.func;
16142 },
16143
16144
16145 applyManualRawProps: function(rawProps) {
16146 var superSuccess = EventSource.prototype.applyManualRawProps.apply(this, arguments);
16147
16148 this.func = rawProps.events;
16149
16150 return superSuccess;
16151 }
16152
16153 });
16154
16155
16156 FuncEventSource.allowRawProps({
16157 events: false // don't automatically transfer
16158 });
16159
16160
16161 FuncEventSource.parse = function(rawInput, calendar) {
16162 var rawProps;
16163
16164 // normalize raw input
16165 if ($.isFunction(rawInput.events)) { // extended form
16166 rawProps = rawInput;
16167 }
16168 else if ($.isFunction(rawInput)) { // short form
16169 rawProps = { events: rawInput };
16170 }
16171
16172 if (rawProps) {
16173 return EventSource.parse.call(this, rawProps, calendar);
16174 }
16175
16176 return false;
16177 };
16178
16179
16180 EventSourceParser.registerClass(FuncEventSource);
16181
16182 FC.FuncEventSource = FuncEventSource;
16183
16184 ;;
16185
16186 var JsonFeedEventSource = EventSource.extend({
16187
16188 // these props must all be manually set before calling fetch
16189 startParam: null,
16190 endParam: null,
16191 timezoneParam: null,
16192 ajaxSettings: null,
16193
16194
16195 fetch: function(start, end, timezone) {
16196 var _this = this;
16197 var ajaxSettings = this.ajaxSettings;
16198 var onSuccess = ajaxSettings.success;
16199 var onError = ajaxSettings.error;
16200 var requestParams = this.buildRequestParams(start, end, timezone);
16201
16202 // todo: eventually handle the promise's then,
16203 // don't intercept success/error
16204 // tho will be a breaking API change
16205
16206 this.calendar.pushLoading();
16207
16208 return Promise.construct(function(onResolve, onReject) {
16209 $.ajax($.extend(
16210 {}, // avoid mutation
16211 JsonFeedEventSource.AJAX_DEFAULTS,
16212 ajaxSettings, // should have a `url`
16213 {
16214 data: requestParams,
16215 success: function(rawEventDefs) {
16216 var callbackRes;
16217
16218 _this.calendar.popLoading();
16219
16220 if (rawEventDefs) {
16221 callbackRes = applyAll(onSuccess, this, arguments); // redirect `this`
16222
16223 if ($.isArray(callbackRes)) {
16224 rawEventDefs = callbackRes;
16225 }
16226
16227 onResolve(_this.parseEventDefs(rawEventDefs));
16228 }
16229 else {
16230 onReject();
16231 }
16232 },
16233 error: function() {
16234 _this.calendar.popLoading();
16235
16236 applyAll(onError, this, arguments); // redirect `this`
16237 onReject();
16238 }
16239 }
16240 ));
16241 });
16242 },
16243
16244
16245 buildRequestParams: function(start, end, timezone) {
16246 var calendar = this.calendar;
16247 var ajaxSettings = this.ajaxSettings;
16248 var startParam, endParam, timezoneParam;
16249 var customRequestParams;
16250 var params = {};
16251
16252 startParam = this.startParam;
16253 if (startParam == null) {
16254 startParam = calendar.opt('startParam');
16255 }
16256
16257 endParam = this.endParam;
16258 if (endParam == null) {
16259 endParam = calendar.opt('endParam');
16260 }
16261
16262 timezoneParam = this.timezoneParam;
16263 if (timezoneParam == null) {
16264 timezoneParam = calendar.opt('timezoneParam');
16265 }
16266
16267 // retrieve any outbound GET/POST $.ajax data from the options
16268 if ($.isFunction(ajaxSettings.data)) {
16269 // supplied as a function that returns a key/value object
16270 customRequestParams = ajaxSettings.data();
16271 }
16272 else {
16273 // probably supplied as a straight key/value object
16274 customRequestParams = ajaxSettings.data || {};
16275 }
16276
16277 $.extend(params, customRequestParams);
16278
16279 params[startParam] = start.format();
16280 params[endParam] = end.format();
16281
16282 if (timezone && timezone !== 'local') {
16283 params[timezoneParam] = timezone;
16284 }
16285
16286 return params;
16287 },
16288
16289
16290 getPrimitive: function() {
16291 return this.ajaxSettings.url;
16292 },
16293
16294
16295 applyOtherRawProps: function(rawProps) {
16296 EventSource.prototype.applyOtherRawProps.apply(this, arguments);
16297
16298 this.ajaxSettings = rawProps;
16299 }
16300
16301 });
16302
16303
16304 JsonFeedEventSource.AJAX_DEFAULTS = {
16305 dataType: 'json',
16306 cache: false
16307 };
16308
16309
16310 JsonFeedEventSource.allowRawProps({
16311 // automatically transfer (true)...
16312 startParam: true,
16313 endParam: true,
16314 timezoneParam: true
16315 });
16316
16317
16318 JsonFeedEventSource.parse = function(rawInput, calendar) {
16319 var rawProps;
16320
16321 // normalize raw input
16322 if (typeof rawInput.url === 'string') { // extended form
16323 rawProps = rawInput;
16324 }
16325 else if (typeof rawInput === 'string') { // short form
16326 rawProps = { url: rawInput }; // will end up in ajaxSettings
16327 }
16328
16329 if (rawProps) {
16330 return EventSource.parse.call(this, rawProps, calendar);
16331 }
16332
16333 return false;
16334 };
16335
16336
16337 EventSourceParser.registerClass(JsonFeedEventSource);
16338
16339 FC.JsonFeedEventSource = JsonFeedEventSource;
16340
16341 ;;
16342
16343 var ThemeRegistry = FC.ThemeRegistry = {
16344
16345 themeClassHash: {},
16346
16347
16348 register: function(themeName, themeClass) {
16349 this.themeClassHash[themeName] = themeClass;
16350 },
16351
16352
16353 getThemeClass: function(themeSetting) {
16354 if (!themeSetting) {
16355 return StandardTheme;
16356 }
16357 else if (themeSetting === true) {
16358 return JqueryUiTheme;
16359 }
16360 else {
16361 return this.themeClassHash[themeSetting];
16362 }
16363 }
16364
16365 };
16366
16367 ;;
16368
16369 var Theme = FC.Theme = Class.extend({
16370
16371 classes: {},
16372 iconClasses: {},
16373 baseIconClass: '',
16374 iconOverrideOption: null,
16375 iconOverrideCustomButtonOption: null,
16376 iconOverridePrefix: '',
16377
16378
16379 constructor: function(optionsModel) {
16380 this.optionsModel = optionsModel;
16381 this.processIconOverride();
16382 },
16383
16384
16385 processIconOverride: function() {
16386 if (this.iconOverrideOption) {
16387 this.setIconOverride(
16388 this.optionsModel.get(this.iconOverrideOption)
16389 );
16390 }
16391 },
16392
16393
16394 setIconOverride: function(iconOverrideHash) {
16395 var iconClassesCopy;
16396 var buttonName;
16397
16398 if ($.isPlainObject(iconOverrideHash)) {
16399 iconClassesCopy = $.extend({}, this.iconClasses);
16400
16401 for (buttonName in iconOverrideHash) {
16402 iconClassesCopy[buttonName] = this.applyIconOverridePrefix(
16403 iconOverrideHash[buttonName]
16404 );
16405 }
16406
16407 this.iconClasses = iconClassesCopy;
16408 }
16409 else if (iconOverrideHash === false) {
16410 this.iconClasses = {};
16411 }
16412 },
16413
16414
16415 applyIconOverridePrefix: function(className) {
16416 var prefix = this.iconOverridePrefix;
16417
16418 if (prefix && className.indexOf(prefix) !== 0) { // if not already present
16419 className = prefix + className;
16420 }
16421
16422 return className;
16423 },
16424
16425
16426 getClass: function(key) {
16427 return this.classes[key] || '';
16428 },
16429
16430
16431 getIconClass: function(buttonName) {
16432 var className = this.iconClasses[buttonName];
16433
16434 if (className) {
16435 return this.baseIconClass + ' ' + className;
16436 }
16437
16438 return '';
16439 },
16440
16441
16442 getCustomButtonIconClass: function(customButtonProps) {
16443 var className;
16444
16445 if (this.iconOverrideCustomButtonOption) {
16446 className = customButtonProps[this.iconOverrideCustomButtonOption];
16447
16448 if (className) {
16449 return this.baseIconClass + ' ' + this.applyIconOverridePrefix(className);
16450 }
16451 }
16452
16453 return '';
16454 }
16455
16456 });
16457
16458 ;;
16459
16460 var StandardTheme = Theme.extend({
16461
16462 classes: {
16463 widget: 'fc-unthemed',
16464 widgetHeader: 'fc-widget-header',
16465 widgetContent: 'fc-widget-content',
16466
16467 buttonGroup: 'fc-button-group',
16468 button: 'fc-button',
16469 cornerLeft: 'fc-corner-left',
16470 cornerRight: 'fc-corner-right',
16471 stateDefault: 'fc-state-default',
16472 stateActive: 'fc-state-active',
16473 stateDisabled: 'fc-state-disabled',
16474 stateHover: 'fc-state-hover',
16475 stateDown: 'fc-state-down',
16476
16477 popoverHeader: 'fc-widget-header',
16478 popoverContent: 'fc-widget-content',
16479
16480 // day grid
16481 headerRow: 'fc-widget-header',
16482 dayRow: 'fc-widget-content',
16483
16484 // list view
16485 listView: 'fc-widget-content'
16486 },
16487
16488 baseIconClass: 'fc-icon',
16489 iconClasses: {
16490 close: 'fc-icon-x',
16491 prev: 'fc-icon-left-single-arrow',
16492 next: 'fc-icon-right-single-arrow',
16493 prevYear: 'fc-icon-left-double-arrow',
16494 nextYear: 'fc-icon-right-double-arrow'
16495 },
16496
16497 iconOverrideOption: 'buttonIcons',
16498 iconOverrideCustomButtonOption: 'icon',
16499 iconOverridePrefix: 'fc-icon-'
16500
16501 });
16502
16503 ThemeRegistry.register('standard', StandardTheme);
16504
16505 ;;
16506
16507 var JqueryUiTheme = Theme.extend({
16508
16509 classes: {
16510 widget: 'ui-widget',
16511 widgetHeader: 'ui-widget-header',
16512 widgetContent: 'ui-widget-content',
16513
16514 buttonGroup: 'fc-button-group',
16515 button: 'ui-button',
16516 cornerLeft: 'ui-corner-left',
16517 cornerRight: 'ui-corner-right',
16518 stateDefault: 'ui-state-default',
16519 stateActive: 'ui-state-active',
16520 stateDisabled: 'ui-state-disabled',
16521 stateHover: 'ui-state-hover',
16522 stateDown: 'ui-state-down',
16523
16524 today: 'ui-state-highlight',
16525
16526 popoverHeader: 'ui-widget-header',
16527 popoverContent: 'ui-widget-content',
16528
16529 // day grid
16530 headerRow: 'ui-widget-header',
16531 dayRow: 'ui-widget-content',
16532
16533 // list view
16534 listView: 'ui-widget-content'
16535 },
16536
16537 baseIconClass: 'ui-icon',
16538 iconClasses: {
16539 close: 'ui-icon-closethick',
16540 prev: 'ui-icon-circle-triangle-w',
16541 next: 'ui-icon-circle-triangle-e',
16542 prevYear: 'ui-icon-seek-prev',
16543 nextYear: 'ui-icon-seek-next'
16544 },
16545
16546 iconOverrideOption: 'themeButtonIcons',
16547 iconOverrideCustomButtonOption: 'themeIcon',
16548 iconOverridePrefix: 'ui-icon-'
16549
16550 });
16551
16552 ThemeRegistry.register('jquery-ui', JqueryUiTheme);
16553
16554 ;;
16555
16556 var BootstrapTheme = Theme.extend({
16557
16558 classes: {
16559 widget: 'fc-bootstrap3',
16560
16561 tableGrid: 'table-bordered', // avoid `table` class b/c don't want margins. only border color
16562 tableList: 'table table-striped', // `table` class creates bottom margin but who cares
16563
16564 buttonGroup: 'btn-group',
16565 button: 'btn btn-default',
16566 stateActive: 'active',
16567 stateDisabled: 'disabled',
16568
16569 today: 'alert alert-info', // the plain `info` class requires `.table`, too much to ask
16570
16571 popover: 'panel panel-default',
16572 popoverHeader: 'panel-heading',
16573 popoverContent: 'panel-body',
16574
16575 // day grid
16576 headerRow: 'panel-default', // avoid `panel` class b/c don't want margins/radius. only border color
16577 dayRow: 'panel-default', // "
16578
16579 // list view
16580 listView: 'panel panel-default'
16581 },
16582
16583 baseIconClass: 'glyphicon',
16584 iconClasses: {
16585 close: 'glyphicon-remove',
16586 prev: 'glyphicon-chevron-left',
16587 next: 'glyphicon-chevron-right',
16588 prevYear: 'glyphicon-backward',
16589 nextYear: 'glyphicon-forward'
16590 },
16591
16592 iconOverrideOption: 'bootstrapGlyphicons',
16593 iconOverrideCustomButtonOption: 'bootstrapGlyphicon',
16594 iconOverridePrefix: 'glyphicon-'
16595
16596 });
16597
16598 ThemeRegistry.register('bootstrap3', BootstrapTheme);
16599
16600 ;;
16601
16602 /* An abstract class for the "basic" views, as well as month view. Renders one or more rows of day cells.
16603 ----------------------------------------------------------------------------------------------------------------------*/
16604 // It is a manager for a DayGrid subcomponent, which does most of the heavy lifting.
16605 // It is responsible for managing width/height.
16606
16607 var BasicView = FC.BasicView = View.extend({
16608
16609 scroller: null,
16610
16611 dayGridClass: DayGrid, // class the dayGrid will be instantiated from (overridable by subclasses)
16612 dayGrid: null, // the main subcomponent that does most of the heavy lifting
16613
16614 dayNumbersVisible: false, // display day numbers on each day cell?
16615 colWeekNumbersVisible: false, // display week numbers along the side?
16616 cellWeekNumbersVisible: false, // display week numbers in day cell?
16617
16618 weekNumberWidth: null, // width of all the week-number cells running down the side
16619
16620 headContainerEl: null, // div that hold's the dayGrid's rendered date header
16621 headRowEl: null, // the fake row element of the day-of-week header
16622
16623
16624 initialize: function() {
16625 this.dayGrid = this.instantiateDayGrid();
16626 this.addChild(this.dayGrid);
16627
16628 this.scroller = new Scroller({
16629 overflowX: 'hidden',
16630 overflowY: 'auto'
16631 });
16632 },
16633
16634
16635 // Generates the DayGrid object this view needs. Draws from this.dayGridClass
16636 instantiateDayGrid: function() {
16637 // generate a subclass on the fly with BasicView-specific behavior
16638 // TODO: cache this subclass
16639 var subclass = this.dayGridClass.extend(basicDayGridMethods);
16640
16641 return new subclass(this);
16642 },
16643
16644
16645 // Computes the date range that will be rendered.
16646 buildRenderRange: function(currentUnzonedRange, currentRangeUnit) {
16647 var renderUnzonedRange = View.prototype.buildRenderRange.apply(this, arguments); // an UnzonedRange
16648 var start = this.calendar.msToUtcMoment(renderUnzonedRange.startMs, this.isRangeAllDay);
16649 var end = this.calendar.msToUtcMoment(renderUnzonedRange.endMs, this.isRangeAllDay);
16650
16651 // year and month views should be aligned with weeks. this is already done for week
16652 if (/^(year|month)$/.test(currentRangeUnit)) {
16653 start.startOf('week');
16654
16655 // make end-of-week if not already
16656 if (end.weekday()) {
16657 end.add(1, 'week').startOf('week'); // exclusively move backwards
16658 }
16659 }
16660
16661 return this.trimHiddenDays(new UnzonedRange(start, end));
16662 },
16663
16664
16665 // Renders the view into `this.el`, which should already be assigned
16666 renderDates: function() {
16667
16668 this.dayGrid.breakOnWeeks = /year|month|week/.test(this.currentRangeUnit); // do before Grid::setRange
16669 this.dayGrid.setRange(this.renderUnzonedRange);
16670
16671 this.dayNumbersVisible = this.dayGrid.rowCnt > 1; // TODO: make grid responsible
16672 if (this.opt('weekNumbers')) {
16673 if (this.opt('weekNumbersWithinDays')) {
16674 this.cellWeekNumbersVisible = true;
16675 this.colWeekNumbersVisible = false;
16676 }
16677 else {
16678 this.cellWeekNumbersVisible = false;
16679 this.colWeekNumbersVisible = true;
16680 };
16681 }
16682 this.dayGrid.numbersVisible = this.dayNumbersVisible ||
16683 this.cellWeekNumbersVisible || this.colWeekNumbersVisible;
16684
16685 this.el.addClass('fc-basic-view').html(this.renderSkeletonHtml());
16686 this.renderHead();
16687
16688 this.scroller.render();
16689 var dayGridContainerEl = this.scroller.el.addClass('fc-day-grid-container');
16690 var dayGridEl = $('<div class="fc-day-grid" />').appendTo(dayGridContainerEl);
16691 this.el.find('.fc-body > tr > td').append(dayGridContainerEl);
16692
16693 this.dayGrid.setElement(dayGridEl);
16694 this.dayGrid.renderDates(this.hasRigidRows());
16695 },
16696
16697
16698 // render the day-of-week headers
16699 renderHead: function() {
16700 this.headContainerEl =
16701 this.el.find('.fc-head-container')
16702 .html(this.dayGrid.renderHeadHtml());
16703 this.headRowEl = this.headContainerEl.find('.fc-row');
16704 },
16705
16706
16707 // Unrenders the content of the view. Since we haven't separated skeleton rendering from date rendering,
16708 // always completely kill the dayGrid's rendering.
16709 unrenderDates: function() {
16710 this.dayGrid.unrenderDates();
16711 this.dayGrid.removeElement();
16712 this.scroller.destroy();
16713 },
16714
16715
16716 // Builds the HTML skeleton for the view.
16717 // The day-grid component will render inside of a container defined by this HTML.
16718 renderSkeletonHtml: function() {
16719 var theme = this.calendar.theme;
16720
16721 return '' +
16722 '<table class="' + theme.getClass('tableGrid') + '">' +
16723 '<thead class="fc-head">' +
16724 '<tr>' +
16725 '<td class="fc-head-container ' + theme.getClass('widgetHeader') + '"></td>' +
16726 '</tr>' +
16727 '</thead>' +
16728 '<tbody class="fc-body">' +
16729 '<tr>' +
16730 '<td class="' + theme.getClass('widgetContent') + '"></td>' +
16731 '</tr>' +
16732 '</tbody>' +
16733 '</table>';
16734 },
16735
16736
16737 // Generates an HTML attribute string for setting the width of the week number column, if it is known
16738 weekNumberStyleAttr: function() {
16739 if (this.weekNumberWidth !== null) {
16740 return 'style="width:' + this.weekNumberWidth + 'px"';
16741 }
16742 return '';
16743 },
16744
16745
16746 // Determines whether each row should have a constant height
16747 hasRigidRows: function() {
16748 var eventLimit = this.opt('eventLimit');
16749 return eventLimit && typeof eventLimit !== 'number';
16750 },
16751
16752
16753 /* Dimensions
16754 ------------------------------------------------------------------------------------------------------------------*/
16755
16756
16757 // Refreshes the horizontal dimensions of the view
16758 updateWidth: function() {
16759 if (this.colWeekNumbersVisible) {
16760 // Make sure all week number cells running down the side have the same width.
16761 // Record the width for cells created later.
16762 this.weekNumberWidth = matchCellWidths(
16763 this.el.find('.fc-week-number')
16764 );
16765 }
16766 },
16767
16768
16769 // Adjusts the vertical dimensions of the view to the specified values
16770 setHeight: function(totalHeight, isAuto) {
16771 var eventLimit = this.opt('eventLimit');
16772 var scrollerHeight;
16773 var scrollbarWidths;
16774
16775 // reset all heights to be natural
16776 this.scroller.clear();
16777 uncompensateScroll(this.headRowEl);
16778
16779 this.dayGrid.removeSegPopover(); // kill the "more" popover if displayed
16780
16781 // is the event limit a constant level number?
16782 if (eventLimit && typeof eventLimit === 'number') {
16783 this.dayGrid.limitRows(eventLimit); // limit the levels first so the height can redistribute after
16784 }
16785
16786 // distribute the height to the rows
16787 // (totalHeight is a "recommended" value if isAuto)
16788 scrollerHeight = this.computeScrollerHeight(totalHeight);
16789 this.setGridHeight(scrollerHeight, isAuto);
16790
16791 // is the event limit dynamically calculated?
16792 if (eventLimit && typeof eventLimit !== 'number') {
16793 this.dayGrid.limitRows(eventLimit); // limit the levels after the grid's row heights have been set
16794 }
16795
16796 if (!isAuto) { // should we force dimensions of the scroll container?
16797
16798 this.scroller.setHeight(scrollerHeight);
16799 scrollbarWidths = this.scroller.getScrollbarWidths();
16800
16801 if (scrollbarWidths.left || scrollbarWidths.right) { // using scrollbars?
16802
16803 compensateScroll(this.headRowEl, scrollbarWidths);
16804
16805 // doing the scrollbar compensation might have created text overflow which created more height. redo
16806 scrollerHeight = this.computeScrollerHeight(totalHeight);
16807 this.scroller.setHeight(scrollerHeight);
16808 }
16809
16810 // guarantees the same scrollbar widths
16811 this.scroller.lockOverflow(scrollbarWidths);
16812 }
16813 },
16814
16815
16816 // given a desired total height of the view, returns what the height of the scroller should be
16817 computeScrollerHeight: function(totalHeight) {
16818 return totalHeight -
16819 subtractInnerElHeight(this.el, this.scroller.el); // everything that's NOT the scroller
16820 },
16821
16822
16823 // Sets the height of just the DayGrid component in this view
16824 setGridHeight: function(height, isAuto) {
16825 if (isAuto) {
16826 undistributeHeight(this.dayGrid.rowEls); // let the rows be their natural height with no expanding
16827 }
16828 else {
16829 distributeHeight(this.dayGrid.rowEls, height, true); // true = compensate for height-hogging rows
16830 }
16831 },
16832
16833
16834 /* Scroll
16835 ------------------------------------------------------------------------------------------------------------------*/
16836
16837
16838 computeInitialDateScroll: function() {
16839 return { top: 0 };
16840 },
16841
16842
16843 queryDateScroll: function() {
16844 return { top: this.scroller.getScrollTop() };
16845 },
16846
16847
16848 applyDateScroll: function(scroll) {
16849 if (scroll.top !== undefined) {
16850 this.scroller.setScrollTop(scroll.top);
16851 }
16852 },
16853
16854
16855 /* Events
16856 ------------------------------------------------------------------------------------------------------------------*/
16857
16858
16859 // Renders the given events onto the view and populates the segments array
16860 renderEventsPayload: function(eventsPayload) {
16861 this.dayGrid.renderEventsPayload(eventsPayload);
16862
16863 // must compensate for events that overflow the row
16864 // TODO: how will ChronoComponent handle this?
16865 this.updateHeight();
16866 }
16867
16868 });
16869
16870
16871 // Methods that will customize the rendering behavior of the BasicView's dayGrid
16872 var basicDayGridMethods = {
16873
16874
16875 // Generates the HTML that will go before the day-of week header cells
16876 renderHeadIntroHtml: function() {
16877 var view = this.view;
16878
16879 if (view.colWeekNumbersVisible) {
16880 return '' +
16881 '<th class="fc-week-number ' + view.calendar.theme.getClass('widgetHeader') + '" ' + view.weekNumberStyleAttr() + '>' +
16882 '<span>' + // needed for matchCellWidths
16883 htmlEscape(this.opt('weekNumberTitle')) +
16884 '</span>' +
16885 '</th>';
16886 }
16887
16888 return '';
16889 },
16890
16891
16892 // Generates the HTML that will go before content-skeleton cells that display the day/week numbers
16893 renderNumberIntroHtml: function(row) {
16894 var view = this.view;
16895 var weekStart = this.getCellDate(row, 0);
16896
16897 if (view.colWeekNumbersVisible) {
16898 return '' +
16899 '<td class="fc-week-number" ' + view.weekNumberStyleAttr() + '>' +
16900 view.buildGotoAnchorHtml( // aside from link, important for matchCellWidths
16901 { date: weekStart, type: 'week', forceOff: this.colCnt === 1 },
16902 weekStart.format('w') // inner HTML
16903 ) +
16904 '</td>';
16905 }
16906
16907 return '';
16908 },
16909
16910
16911 // Generates the HTML that goes before the day bg cells for each day-row
16912 renderBgIntroHtml: function() {
16913 var view = this.view;
16914
16915 if (view.colWeekNumbersVisible) {
16916 return '<td class="fc-week-number ' + view.calendar.theme.getClass('widgetContent') + '" ' +
16917 view.weekNumberStyleAttr() + '></td>';
16918 }
16919
16920 return '';
16921 },
16922
16923
16924 // Generates the HTML that goes before every other type of row generated by DayGrid.
16925 // Affects helper-skeleton and highlight-skeleton rows.
16926 renderIntroHtml: function() {
16927 var view = this.view;
16928
16929 if (view.colWeekNumbersVisible) {
16930 return '<td class="fc-week-number" ' + view.weekNumberStyleAttr() + '></td>';
16931 }
16932
16933 return '';
16934 }
16935
16936 };
16937
16938 ;;
16939
16940 /* A month view with day cells running in rows (one-per-week) and columns
16941 ----------------------------------------------------------------------------------------------------------------------*/
16942
16943 var MonthView = FC.MonthView = BasicView.extend({
16944
16945
16946 // Computes the date range that will be rendered.
16947 buildRenderRange: function() {
16948 var renderUnzonedRange = BasicView.prototype.buildRenderRange.apply(this, arguments);
16949 var start = this.calendar.msToUtcMoment(renderUnzonedRange.startMs, this.isRangeAllDay);
16950 var end = this.calendar.msToUtcMoment(renderUnzonedRange.endMs, this.isRangeAllDay);
16951 var rowCnt;
16952
16953 // ensure 6 weeks
16954 if (this.isFixedWeeks()) {
16955 rowCnt = Math.ceil( // could be partial weeks due to hiddenDays
16956 end.diff(start, 'weeks', true) // dontRound=true
16957 );
16958 end.add(6 - rowCnt, 'weeks');
16959 }
16960
16961 return new UnzonedRange(start, end);
16962 },
16963
16964
16965 // Overrides the default BasicView behavior to have special multi-week auto-height logic
16966 setGridHeight: function(height, isAuto) {
16967
16968 // if auto, make the height of each row the height that it would be if there were 6 weeks
16969 if (isAuto) {
16970 height *= this.rowCnt / 6;
16971 }
16972
16973 distributeHeight(this.dayGrid.rowEls, height, !isAuto); // if auto, don't compensate for height-hogging rows
16974 },
16975
16976
16977 isFixedWeeks: function() {
16978 return this.opt('fixedWeekCount');
16979 },
16980
16981
16982 isDateInOtherMonth: function(date) {
16983 return date.month() !== moment.utc(this.currentUnzonedRange.startMs).month(); // TODO: optimize
16984 }
16985
16986 });
16987
16988 ;;
16989
16990 fcViews.basic = {
16991 'class': BasicView
16992 };
16993
16994 fcViews.basicDay = {
16995 type: 'basic',
16996 duration: { days: 1 }
16997 };
16998
16999 fcViews.basicWeek = {
17000 type: 'basic',
17001 duration: { weeks: 1 }
17002 };
17003
17004 fcViews.month = {
17005 'class': MonthView,
17006 duration: { months: 1 }, // important for prev/next
17007 defaults: {
17008 fixedWeekCount: true
17009 }
17010 };
17011 ;;
17012
17013 /* An abstract class for all agenda-related views. Displays one more columns with time slots running vertically.
17014 ----------------------------------------------------------------------------------------------------------------------*/
17015 // Is a manager for the TimeGrid subcomponent and possibly the DayGrid subcomponent (if allDaySlot is on).
17016 // Responsible for managing width/height.
17017
17018 var AgendaView = FC.AgendaView = View.extend({
17019
17020 scroller: null,
17021
17022 timeGridClass: TimeGrid, // class used to instantiate the timeGrid. subclasses can override
17023 timeGrid: null, // the main time-grid subcomponent of this view
17024
17025 dayGridClass: DayGrid, // class used to instantiate the dayGrid. subclasses can override
17026 dayGrid: null, // the "all-day" subcomponent. if all-day is turned off, this will be null
17027
17028 axisWidth: null, // the width of the time axis running down the side
17029
17030 headContainerEl: null, // div that hold's the timeGrid's rendered date header
17031 noScrollRowEls: null, // set of fake row elements that must compensate when scroller has scrollbars
17032
17033 // when the time-grid isn't tall enough to occupy the given height, we render an <hr> underneath
17034 bottomRuleEl: null,
17035
17036 // indicates that minTime/maxTime affects rendering
17037 usesMinMaxTime: true,
17038
17039
17040 initialize: function() {
17041 this.timeGrid = this.instantiateTimeGrid();
17042 this.addChild(this.timeGrid);
17043
17044 if (this.opt('allDaySlot')) { // should we display the "all-day" area?
17045 this.dayGrid = this.instantiateDayGrid(); // the all-day subcomponent of this view
17046 this.addChild(this.dayGrid);
17047 }
17048
17049 this.scroller = new Scroller({
17050 overflowX: 'hidden',
17051 overflowY: 'auto'
17052 });
17053 },
17054
17055
17056 // Instantiates the TimeGrid object this view needs. Draws from this.timeGridClass
17057 instantiateTimeGrid: function() {
17058 var subclass = this.timeGridClass.extend(agendaTimeGridMethods);
17059
17060 return new subclass(this);
17061 },
17062
17063
17064 // Instantiates the DayGrid object this view might need. Draws from this.dayGridClass
17065 instantiateDayGrid: function() {
17066 var subclass = this.dayGridClass.extend(agendaDayGridMethods);
17067
17068 return new subclass(this);
17069 },
17070
17071
17072 /* Rendering
17073 ------------------------------------------------------------------------------------------------------------------*/
17074
17075
17076 // Renders the view into `this.el`, which has already been assigned
17077 renderDates: function() {
17078
17079 this.timeGrid.setRange(this.renderUnzonedRange);
17080
17081 if (this.dayGrid) {
17082 this.dayGrid.setRange(this.renderUnzonedRange);
17083 }
17084
17085 this.el.addClass('fc-agenda-view').html(this.renderSkeletonHtml());
17086 this.renderHead();
17087
17088 this.scroller.render();
17089 var timeGridWrapEl = this.scroller.el.addClass('fc-time-grid-container');
17090 var timeGridEl = $('<div class="fc-time-grid" />').appendTo(timeGridWrapEl);
17091 this.el.find('.fc-body > tr > td').append(timeGridWrapEl);
17092
17093 this.timeGrid.setElement(timeGridEl);
17094 this.timeGrid.renderDates();
17095
17096 // the <hr> that sometimes displays under the time-grid
17097 this.bottomRuleEl = $('<hr class="fc-divider ' + this.calendar.theme.getClass('widgetHeader') + '"/>')
17098 .appendTo(this.timeGrid.el); // inject it into the time-grid
17099
17100 if (this.dayGrid) {
17101 this.dayGrid.setElement(this.el.find('.fc-day-grid'));
17102 this.dayGrid.renderDates();
17103
17104 // have the day-grid extend it's coordinate area over the <hr> dividing the two grids
17105 this.dayGrid.bottomCoordPadding = this.dayGrid.el.next('hr').outerHeight();
17106 }
17107
17108 this.noScrollRowEls = this.el.find('.fc-row:not(.fc-scroller *)'); // fake rows not within the scroller
17109 },
17110
17111
17112 // render the day-of-week headers
17113 renderHead: function() {
17114 this.headContainerEl =
17115 this.el.find('.fc-head-container')
17116 .html(this.timeGrid.renderHeadHtml());
17117 },
17118
17119
17120 // Unrenders the content of the view. Since we haven't separated skeleton rendering from date rendering,
17121 // always completely kill each grid's rendering.
17122 // TODO: move this over to ChronoComponent
17123 unrenderDates: function() {
17124 this.timeGrid.unrenderDates();
17125 this.timeGrid.removeElement();
17126
17127 if (this.dayGrid) {
17128 this.dayGrid.unrenderDates();
17129 this.dayGrid.removeElement();
17130 }
17131
17132 this.scroller.destroy();
17133 },
17134
17135
17136 // Builds the HTML skeleton for the view.
17137 // The day-grid and time-grid components will render inside containers defined by this HTML.
17138 renderSkeletonHtml: function() {
17139 var theme = this.calendar.theme;
17140
17141 return '' +
17142 '<table class="' + theme.getClass('tableGrid') + '">' +
17143 '<thead class="fc-head">' +
17144 '<tr>' +
17145 '<td class="fc-head-container ' + theme.getClass('widgetHeader') + '"></td>' +
17146 '</tr>' +
17147 '</thead>' +
17148 '<tbody class="fc-body">' +
17149 '<tr>' +
17150 '<td class="' + theme.getClass('widgetContent') + '">' +
17151 (this.dayGrid ?
17152 '<div class="fc-day-grid"/>' +
17153 '<hr class="fc-divider ' + theme.getClass('widgetHeader') + '"/>' :
17154 ''
17155 ) +
17156 '</td>' +
17157 '</tr>' +
17158 '</tbody>' +
17159 '</table>';
17160 },
17161
17162
17163 // Generates an HTML attribute string for setting the width of the axis, if it is known
17164 axisStyleAttr: function() {
17165 if (this.axisWidth !== null) {
17166 return 'style="width:' + this.axisWidth + 'px"';
17167 }
17168 return '';
17169 },
17170
17171
17172 /* Now Indicator
17173 ------------------------------------------------------------------------------------------------------------------*/
17174
17175
17176 getNowIndicatorUnit: function() {
17177 return this.timeGrid.getNowIndicatorUnit();
17178 },
17179
17180
17181 /* Dimensions
17182 ------------------------------------------------------------------------------------------------------------------*/
17183
17184
17185 updateSize: function(isResize) {
17186 this.timeGrid.updateSize(isResize);
17187
17188 View.prototype.updateSize.call(this, isResize); // call the super-method
17189 },
17190
17191
17192 // Refreshes the horizontal dimensions of the view
17193 updateWidth: function() {
17194 // make all axis cells line up, and record the width so newly created axis cells will have it
17195 this.axisWidth = matchCellWidths(this.el.find('.fc-axis'));
17196 },
17197
17198
17199 // Adjusts the vertical dimensions of the view to the specified values
17200 setHeight: function(totalHeight, isAuto) {
17201 var eventLimit;
17202 var scrollerHeight;
17203 var scrollbarWidths;
17204
17205 // reset all dimensions back to the original state
17206 this.bottomRuleEl.hide(); // .show() will be called later if this <hr> is necessary
17207 this.scroller.clear(); // sets height to 'auto' and clears overflow
17208 uncompensateScroll(this.noScrollRowEls);
17209
17210 // limit number of events in the all-day area
17211 if (this.dayGrid) {
17212 this.dayGrid.removeSegPopover(); // kill the "more" popover if displayed
17213
17214 eventLimit = this.opt('eventLimit');
17215 if (eventLimit && typeof eventLimit !== 'number') {
17216 eventLimit = AGENDA_ALL_DAY_EVENT_LIMIT; // make sure "auto" goes to a real number
17217 }
17218 if (eventLimit) {
17219 this.dayGrid.limitRows(eventLimit);
17220 }
17221 }
17222
17223 if (!isAuto) { // should we force dimensions of the scroll container?
17224
17225 scrollerHeight = this.computeScrollerHeight(totalHeight);
17226 this.scroller.setHeight(scrollerHeight);
17227 scrollbarWidths = this.scroller.getScrollbarWidths();
17228
17229 if (scrollbarWidths.left || scrollbarWidths.right) { // using scrollbars?
17230
17231 // make the all-day and header rows lines up
17232 compensateScroll(this.noScrollRowEls, scrollbarWidths);
17233
17234 // the scrollbar compensation might have changed text flow, which might affect height, so recalculate
17235 // and reapply the desired height to the scroller.
17236 scrollerHeight = this.computeScrollerHeight(totalHeight);
17237 this.scroller.setHeight(scrollerHeight);
17238 }
17239
17240 // guarantees the same scrollbar widths
17241 this.scroller.lockOverflow(scrollbarWidths);
17242
17243 // if there's any space below the slats, show the horizontal rule.
17244 // this won't cause any new overflow, because lockOverflow already called.
17245 if (this.timeGrid.getTotalSlatHeight() < scrollerHeight) {
17246 this.bottomRuleEl.show();
17247 }
17248 }
17249 },
17250
17251
17252 // given a desired total height of the view, returns what the height of the scroller should be
17253 computeScrollerHeight: function(totalHeight) {
17254 return totalHeight -
17255 subtractInnerElHeight(this.el, this.scroller.el); // everything that's NOT the scroller
17256 },
17257
17258
17259 /* Scroll
17260 ------------------------------------------------------------------------------------------------------------------*/
17261
17262
17263 // Computes the initial pre-configured scroll state prior to allowing the user to change it
17264 computeInitialDateScroll: function() {
17265 var scrollTime = moment.duration(this.opt('scrollTime'));
17266 var top = this.timeGrid.computeTimeTop(scrollTime);
17267
17268 // zoom can give weird floating-point values. rather scroll a little bit further
17269 top = Math.ceil(top);
17270
17271 if (top) {
17272 top++; // to overcome top border that slots beyond the first have. looks better
17273 }
17274
17275 return { top: top };
17276 },
17277
17278
17279 queryDateScroll: function() {
17280 return { top: this.scroller.getScrollTop() };
17281 },
17282
17283
17284 applyDateScroll: function(scroll) {
17285 if (scroll.top !== undefined) {
17286 this.scroller.setScrollTop(scroll.top);
17287 }
17288 },
17289
17290
17291 /* Hit Areas
17292 ------------------------------------------------------------------------------------------------------------------*/
17293 // forward all hit-related method calls to the grids (dayGrid might not be defined)
17294
17295
17296 getHitFootprint: function(hit) {
17297 // TODO: hit.component is set as a hack to identify where the hit came from
17298 return hit.component.getHitFootprint(hit);
17299 },
17300
17301
17302 getHitEl: function(hit) {
17303 // TODO: hit.component is set as a hack to identify where the hit came from
17304 return hit.component.getHitEl(hit);
17305 },
17306
17307
17308 /* Events
17309 ------------------------------------------------------------------------------------------------------------------*/
17310
17311
17312 // Renders events onto the view and populates the View's segment array
17313 renderEventsPayload: function(eventsPayload) {
17314 var dayEventsPayload = {};
17315 var timedEventsPayload = {};
17316 var daySegs = [];
17317 var timedSegs;
17318 var id, eventInstanceGroup;
17319
17320 // separate the events into all-day and timed
17321 for (id in eventsPayload) {
17322 eventInstanceGroup = eventsPayload[id];
17323
17324 if (eventInstanceGroup.getEventDef().isAllDay()) {
17325 dayEventsPayload[id] = eventInstanceGroup;
17326 }
17327 else {
17328 timedEventsPayload[id] = eventInstanceGroup;
17329 }
17330 }
17331
17332 // render the events in the subcomponents
17333 timedSegs = this.timeGrid.renderEventsPayload(timedEventsPayload);
17334 if (this.dayGrid) {
17335 daySegs = this.dayGrid.renderEventsPayload(dayEventsPayload);
17336 }
17337
17338 // the all-day area is flexible and might have a lot of events, so shift the height
17339 // TODO: how will ChronoComponent handle this?
17340 this.updateHeight();
17341 },
17342
17343
17344 /* Dragging (for events and external elements)
17345 ------------------------------------------------------------------------------------------------------------------*/
17346
17347
17348 // A returned value of `true` signals that a mock "helper" event has been rendered.
17349 renderDrag: function(eventFootprints, seg) {
17350 if (eventFootprints.length) {
17351 if (!eventFootprints[0].componentFootprint.isAllDay) {
17352 return this.timeGrid.renderDrag(eventFootprints, seg);
17353 }
17354 else if (this.dayGrid) {
17355 return this.dayGrid.renderDrag(eventFootprints, seg);
17356 }
17357 }
17358 },
17359
17360
17361 /* Selection
17362 ------------------------------------------------------------------------------------------------------------------*/
17363
17364
17365 // Renders a visual indication of a selection
17366 renderSelectionFootprint: function(componentFootprint) {
17367 if (!componentFootprint.isAllDay) {
17368 this.timeGrid.renderSelectionFootprint(componentFootprint);
17369 }
17370 else if (this.dayGrid) {
17371 this.dayGrid.renderSelectionFootprint(componentFootprint);
17372 }
17373 }
17374
17375 });
17376
17377
17378 // Methods that will customize the rendering behavior of the AgendaView's timeGrid
17379 // TODO: move into TimeGrid
17380 var agendaTimeGridMethods = {
17381
17382
17383 // Generates the HTML that will go before the day-of week header cells
17384 renderHeadIntroHtml: function() {
17385 var view = this.view;
17386 var weekStart = view.calendar.msToUtcMoment(this.unzonedRange.startMs, true);
17387 var weekText;
17388
17389 if (this.opt('weekNumbers')) {
17390 weekText = weekStart.format(this.opt('smallWeekFormat'));
17391
17392 return '' +
17393 '<th class="fc-axis fc-week-number ' + view.calendar.theme.getClass('widgetHeader') + '" ' + view.axisStyleAttr() + '>' +
17394 view.buildGotoAnchorHtml( // aside from link, important for matchCellWidths
17395 { date: weekStart, type: 'week', forceOff: this.colCnt > 1 },
17396 htmlEscape(weekText) // inner HTML
17397 ) +
17398 '</th>';
17399 }
17400 else {
17401 return '<th class="fc-axis ' + view.calendar.theme.getClass('widgetHeader') + '" ' + view.axisStyleAttr() + '></th>';
17402 }
17403 },
17404
17405
17406 // Generates the HTML that goes before the bg of the TimeGrid slot area. Long vertical column.
17407 renderBgIntroHtml: function() {
17408 var view = this.view;
17409
17410 return '<td class="fc-axis ' + view.calendar.theme.getClass('widgetContent') + '" ' + view.axisStyleAttr() + '></td>';
17411 },
17412
17413
17414 // Generates the HTML that goes before all other types of cells.
17415 // Affects content-skeleton, helper-skeleton, highlight-skeleton for both the time-grid and day-grid.
17416 renderIntroHtml: function() {
17417 var view = this.view;
17418
17419 return '<td class="fc-axis" ' + view.axisStyleAttr() + '></td>';
17420 }
17421
17422 };
17423
17424
17425 // Methods that will customize the rendering behavior of the AgendaView's dayGrid
17426 var agendaDayGridMethods = {
17427
17428
17429 // Generates the HTML that goes before the all-day cells
17430 renderBgIntroHtml: function() {
17431 var view = this.view;
17432
17433 return '' +
17434 '<td class="fc-axis ' + view.calendar.theme.getClass('widgetContent') + '" ' + view.axisStyleAttr() + '>' +
17435 '<span>' + // needed for matchCellWidths
17436 view.getAllDayHtml() +
17437 '</span>' +
17438 '</td>';
17439 },
17440
17441
17442 // Generates the HTML that goes before all other types of cells.
17443 // Affects content-skeleton, helper-skeleton, highlight-skeleton for both the time-grid and day-grid.
17444 renderIntroHtml: function() {
17445 var view = this.view;
17446
17447 return '<td class="fc-axis" ' + view.axisStyleAttr() + '></td>';
17448 }
17449
17450 };
17451
17452 ;;
17453
17454 var AGENDA_ALL_DAY_EVENT_LIMIT = 5;
17455
17456 // potential nice values for the slot-duration and interval-duration
17457 // from largest to smallest
17458 var AGENDA_STOCK_SUB_DURATIONS = [
17459 { hours: 1 },
17460 { minutes: 30 },
17461 { minutes: 15 },
17462 { seconds: 30 },
17463 { seconds: 15 }
17464 ];
17465
17466 fcViews.agenda = {
17467 'class': AgendaView,
17468 defaults: {
17469 allDaySlot: true,
17470 slotDuration: '00:30:00',
17471 slotEventOverlap: true // a bad name. confused with overlap/constraint system
17472 }
17473 };
17474
17475 fcViews.agendaDay = {
17476 type: 'agenda',
17477 duration: { days: 1 }
17478 };
17479
17480 fcViews.agendaWeek = {
17481 type: 'agenda',
17482 duration: { weeks: 1 }
17483 };
17484 ;;
17485
17486 /*
17487 Responsible for the scroller, and forwarding event-related actions into the "grid"
17488 */
17489 var ListView = View.extend({
17490
17491 grid: null,
17492 scroller: null,
17493
17494 initialize: function() {
17495 this.grid = new ListViewGrid(this);
17496 this.addChild(this.grid);
17497
17498 this.scroller = new Scroller({
17499 overflowX: 'hidden',
17500 overflowY: 'auto'
17501 });
17502 },
17503
17504 renderSkeleton: function() {
17505 this.el.addClass(
17506 'fc-list-view ' +
17507 this.calendar.theme.getClass('listView')
17508 );
17509
17510 this.scroller.render();
17511 this.scroller.el.appendTo(this.el);
17512
17513 this.grid.setElement(this.scroller.scrollEl);
17514 },
17515
17516 unrenderSkeleton: function() {
17517 this.scroller.destroy(); // will remove the Grid too
17518 },
17519
17520 setHeight: function(totalHeight, isAuto) {
17521 this.scroller.setHeight(this.computeScrollerHeight(totalHeight));
17522 },
17523
17524 computeScrollerHeight: function(totalHeight) {
17525 return totalHeight -
17526 subtractInnerElHeight(this.el, this.scroller.el); // everything that's NOT the scroller
17527 },
17528
17529 renderDates: function() {
17530 this.grid.setRange(this.renderUnzonedRange); // needs to process range-related options
17531 },
17532
17533 isEventDefResizable: function(eventDef) {
17534 return false;
17535 },
17536
17537 isEventDefDraggable: function(eventDef) {
17538 return false;
17539 }
17540
17541 });
17542
17543 /*
17544 Responsible for event rendering and user-interaction.
17545 Its "el" is the inner-content of the above view's scroller.
17546 */
17547 var ListViewGrid = Grid.extend({
17548
17549 dayDates: null, // localized ambig-time moment array
17550 dayRanges: null, // UnzonedRange[], of start-end of each day
17551 segSelector: '.fc-list-item', // which elements accept event actions
17552 hasDayInteractions: false, // no day selection or day clicking
17553
17554 rangeUpdated: function() {
17555 var calendar = this.view.calendar;
17556 var dayStart = calendar.msToUtcMoment(this.unzonedRange.startMs, true);
17557 var viewEnd = calendar.msToUtcMoment(this.unzonedRange.endMs, true);
17558 var dayDates = [];
17559 var dayRanges = [];
17560
17561 while (dayStart < viewEnd) {
17562
17563 dayDates.push(dayStart.clone());
17564
17565 dayRanges.push(new UnzonedRange(
17566 dayStart,
17567 dayStart.clone().add(1, 'day')
17568 ));
17569
17570 dayStart.add(1, 'day');
17571 }
17572
17573 this.dayDates = dayDates;
17574 this.dayRanges = dayRanges;
17575 },
17576
17577 // slices by day
17578 componentFootprintToSegs: function(footprint) {
17579 var view = this.view;
17580 var dayRanges = this.dayRanges;
17581 var dayIndex;
17582 var segRange;
17583 var seg;
17584 var segs = [];
17585
17586 for (dayIndex = 0; dayIndex < dayRanges.length; dayIndex++) {
17587 segRange = footprint.unzonedRange.intersect(dayRanges[dayIndex]);
17588
17589 if (segRange) {
17590 seg = {
17591 startMs: segRange.startMs,
17592 endMs: segRange.endMs,
17593 isStart: segRange.isStart,
17594 isEnd: segRange.isEnd,
17595 dayIndex: dayIndex
17596 };
17597
17598 segs.push(seg);
17599
17600 // detect when footprint won't go fully into the next day,
17601 // and mutate the latest seg to the be the end.
17602 if (
17603 !seg.isEnd && !footprint.isAllDay &&
17604 footprint.unzonedRange.endMs < dayRanges[dayIndex + 1].startMs + view.nextDayThreshold
17605 ) {
17606 seg.endMs = footprint.unzonedRange.endMs;
17607 seg.isEnd = true;
17608 break;
17609 }
17610 }
17611 }
17612
17613 return segs;
17614 },
17615
17616 // like "4:00am"
17617 computeEventTimeFormat: function() {
17618 return this.opt('mediumTimeFormat');
17619 },
17620
17621 // for events with a url, the whole <tr> should be clickable,
17622 // but it's impossible to wrap with an <a> tag. simulate this.
17623 handleSegClick: function(seg, ev) {
17624 var url;
17625
17626 Grid.prototype.handleSegClick.apply(this, arguments); // super. might prevent the default action
17627
17628 // not clicking on or within an <a> with an href
17629 if (!$(ev.target).closest('a[href]').length) {
17630 url = seg.footprint.eventDef.url;
17631
17632 if (url && !ev.isDefaultPrevented()) { // jsEvent not cancelled in handler
17633 window.location.href = url; // simulate link click
17634 }
17635 }
17636 },
17637
17638 // returns list of foreground segs that were actually rendered
17639 renderFgSegs: function(segs) {
17640 segs = this.renderFgSegEls(segs); // might filter away hidden events
17641
17642 if (!segs.length) {
17643 this.renderEmptyMessage();
17644 }
17645 else {
17646 this.renderSegList(segs);
17647 }
17648
17649 return segs;
17650 },
17651
17652 renderEmptyMessage: function() {
17653 this.el.html(
17654 '<div class="fc-list-empty-wrap2">' + // TODO: try less wraps
17655 '<div class="fc-list-empty-wrap1">' +
17656 '<div class="fc-list-empty">' +
17657 htmlEscape(this.opt('noEventsMessage')) +
17658 '</div>' +
17659 '</div>' +
17660 '</div>'
17661 );
17662 },
17663
17664 // render the event segments in the view
17665 renderSegList: function(allSegs) {
17666 var segsByDay = this.groupSegsByDay(allSegs); // sparse array
17667 var dayIndex;
17668 var daySegs;
17669 var i;
17670 var tableEl = $('<table class="fc-list-table ' + this.view.calendar.theme.getClass('tableList') + '"><tbody/></table>');
17671 var tbodyEl = tableEl.find('tbody');
17672
17673 for (dayIndex = 0; dayIndex < segsByDay.length; dayIndex++) {
17674 daySegs = segsByDay[dayIndex];
17675
17676 if (daySegs) { // sparse array, so might be undefined
17677
17678 // append a day header
17679 tbodyEl.append(this.dayHeaderHtml(this.dayDates[dayIndex]));
17680
17681 this.sortEventSegs(daySegs);
17682
17683 for (i = 0; i < daySegs.length; i++) {
17684 tbodyEl.append(daySegs[i].el); // append event row
17685 }
17686 }
17687 }
17688
17689 this.el.empty().append(tableEl);
17690 },
17691
17692 // Returns a sparse array of arrays, segs grouped by their dayIndex
17693 groupSegsByDay: function(segs) {
17694 var segsByDay = []; // sparse array
17695 var i, seg;
17696
17697 for (i = 0; i < segs.length; i++) {
17698 seg = segs[i];
17699 (segsByDay[seg.dayIndex] || (segsByDay[seg.dayIndex] = []))
17700 .push(seg);
17701 }
17702
17703 return segsByDay;
17704 },
17705
17706 // generates the HTML for the day headers that live amongst the event rows
17707 dayHeaderHtml: function(dayDate) {
17708 var view = this.view;
17709 var mainFormat = this.opt('listDayFormat');
17710 var altFormat = this.opt('listDayAltFormat');
17711
17712 return '<tr class="fc-list-heading" data-date="' + dayDate.format('YYYY-MM-DD') + '">' +
17713 '<td class="' + view.calendar.theme.getClass('widgetHeader') + '" colspan="3">' +
17714 (mainFormat ?
17715 view.buildGotoAnchorHtml(
17716 dayDate,
17717 { 'class': 'fc-list-heading-main' },
17718 htmlEscape(dayDate.format(mainFormat)) // inner HTML
17719 ) :
17720 '') +
17721 (altFormat ?
17722 view.buildGotoAnchorHtml(
17723 dayDate,
17724 { 'class': 'fc-list-heading-alt' },
17725 htmlEscape(dayDate.format(altFormat)) // inner HTML
17726 ) :
17727 '') +
17728 '</td>' +
17729 '</tr>';
17730 },
17731
17732 // generates the HTML for a single event row
17733 fgSegHtml: function(seg) {
17734 var view = this.view;
17735 var calendar = view.calendar;
17736 var theme = calendar.theme;
17737 var classes = [ 'fc-list-item' ].concat(this.getSegCustomClasses(seg));
17738 var bgColor = this.getSegBackgroundColor(seg);
17739 var eventFootprint = seg.footprint;
17740 var eventDef = eventFootprint.eventDef;
17741 var componentFootprint = eventFootprint.componentFootprint;
17742 var url = eventDef.url;
17743 var timeHtml;
17744
17745 if (componentFootprint.isAllDay) {
17746 timeHtml = view.getAllDayHtml();
17747 }
17748 // if the event appears to span more than one day
17749 else if (view.isMultiDayRange(componentFootprint.unzonedRange)) {
17750 if (seg.isStart || seg.isEnd) { // outer segment that probably lasts part of the day
17751 timeHtml = htmlEscape(this._getEventTimeText(
17752 calendar.msToMoment(seg.startMs),
17753 calendar.msToMoment(seg.endMs),
17754 componentFootprint.isAllDay
17755 ));
17756 }
17757 else { // inner segment that lasts the whole day
17758 timeHtml = view.getAllDayHtml();
17759 }
17760 }
17761 else {
17762 // Display the normal time text for the *event's* times
17763 timeHtml = htmlEscape(this.getEventTimeText(eventFootprint));
17764 }
17765
17766 if (url) {
17767 classes.push('fc-has-url');
17768 }
17769
17770 return '<tr class="' + classes.join(' ') + '">' +
17771 (this.displayEventTime ?
17772 '<td class="fc-list-item-time ' + theme.getClass('widgetContent') + '">' +
17773 (timeHtml || '') +
17774 '</td>' :
17775 '') +
17776 '<td class="fc-list-item-marker ' + theme.getClass('widgetContent') + '">' +
17777 '<span class="fc-event-dot"' +
17778 (bgColor ?
17779 ' style="background-color:' + bgColor + '"' :
17780 '') +
17781 '></span>' +
17782 '</td>' +
17783 '<td class="fc-list-item-title ' + theme.getClass('widgetContent') + '">' +
17784 '<a' + (url ? ' href="' + htmlEscape(url) + '"' : '') + '>' +
17785 htmlEscape(eventDef.title || '') +
17786 '</a>' +
17787 '</td>' +
17788 '</tr>';
17789 }
17790
17791 });
17792
17793 ;;
17794
17795 fcViews.list = {
17796 'class': ListView,
17797 buttonTextKey: 'list', // what to lookup in locale files
17798 defaults: {
17799 buttonText: 'list', // text to display for English
17800 listDayFormat: 'LL', // like "January 1, 2016"
17801 noEventsMessage: 'No events to display'
17802 }
17803 };
17804
17805 fcViews.listDay = {
17806 type: 'list',
17807 duration: { days: 1 },
17808 defaults: {
17809 listDayFormat: 'dddd' // day-of-week is all we need. full date is probably in header
17810 }
17811 };
17812
17813 fcViews.listWeek = {
17814 type: 'list',
17815 duration: { weeks: 1 },
17816 defaults: {
17817 listDayFormat: 'dddd', // day-of-week is more important
17818 listDayAltFormat: 'LL'
17819 }
17820 };
17821
17822 fcViews.listMonth = {
17823 type: 'list',
17824 duration: { month: 1 },
17825 defaults: {
17826 listDayAltFormat: 'dddd' // day-of-week is nice-to-have
17827 }
17828 };
17829
17830 fcViews.listYear = {
17831 type: 'list',
17832 duration: { year: 1 },
17833 defaults: {
17834 listDayAltFormat: 'dddd' // day-of-week is nice-to-have
17835 }
17836 };
17837
17838 ;;
17839
17840 return FC; // export for Node/CommonJS
17841 });