[SPIP][PLUGINS] v3.0-->v3.2
[lhc/web/www.git] / www / plugins-dist / organiseur / lib / fullcalendar / fullcalendar.js
1 /*!
2 * FullCalendar v3.2.0
3 * Docs & License: https://fullcalendar.io/
4 * (c) 2017 Adam Shaw
5 */
6
7 (function(factory) {
8 if (typeof define === 'function' && define.amd) {
9 define([ 'jquery', 'moment' ], factory);
10 }
11 else if (typeof exports === 'object') { // Node/CommonJS
12 module.exports = factory(require('jquery'), require('moment'));
13 }
14 else {
15 factory(jQuery, moment);
16 }
17 })(function($, moment) {
18
19 ;;
20
21 var FC = $.fullCalendar = {
22 version: "3.2.0",
23 // When introducing internal API incompatibilities (where fullcalendar plugins would break),
24 // the minor version of the calendar should be upped (ex: 2.7.2 -> 2.8.0)
25 // and the below integer should be incremented.
26 internalApiVersion: 8
27 };
28 var fcViews = FC.views = {};
29
30
31 $.fn.fullCalendar = function(options) {
32 var args = Array.prototype.slice.call(arguments, 1); // for a possible method call
33 var res = this; // what this function will return (this jQuery object by default)
34
35 this.each(function(i, _element) { // loop each DOM element involved
36 var element = $(_element);
37 var calendar = element.data('fullCalendar'); // get the existing calendar object (if any)
38 var singleRes; // the returned value of this single method call
39
40 // a method call
41 if (typeof options === 'string') {
42 if (calendar && $.isFunction(calendar[options])) {
43 singleRes = calendar[options].apply(calendar, args);
44 if (!i) {
45 res = singleRes; // record the first method call result
46 }
47 if (options === 'destroy') { // for the destroy method, must remove Calendar object data
48 element.removeData('fullCalendar');
49 }
50 }
51 }
52 // a new calendar initialization
53 else if (!calendar) { // don't initialize twice
54 calendar = new Calendar(element, options);
55 element.data('fullCalendar', calendar);
56 calendar.render();
57 }
58 });
59
60 return res;
61 };
62
63
64 var complexOptions = [ // names of options that are objects whose properties should be combined
65 'header',
66 'footer',
67 'buttonText',
68 'buttonIcons',
69 'themeButtonIcons'
70 ];
71
72
73 // Merges an array of option objects into a single object
74 function mergeOptions(optionObjs) {
75 return mergeProps(optionObjs, complexOptions);
76 }
77
78 ;;
79
80 // exports
81 FC.intersectRanges = intersectRanges;
82 FC.applyAll = applyAll;
83 FC.debounce = debounce;
84 FC.isInt = isInt;
85 FC.htmlEscape = htmlEscape;
86 FC.cssToStr = cssToStr;
87 FC.proxy = proxy;
88 FC.capitaliseFirstLetter = capitaliseFirstLetter;
89
90
91 /* FullCalendar-specific DOM Utilities
92 ----------------------------------------------------------------------------------------------------------------------*/
93
94
95 // Given the scrollbar widths of some other container, create borders/margins on rowEls in order to match the left
96 // and right space that was offset by the scrollbars. A 1-pixel border first, then margin beyond that.
97 function compensateScroll(rowEls, scrollbarWidths) {
98 if (scrollbarWidths.left) {
99 rowEls.css({
100 'border-left-width': 1,
101 'margin-left': scrollbarWidths.left - 1
102 });
103 }
104 if (scrollbarWidths.right) {
105 rowEls.css({
106 'border-right-width': 1,
107 'margin-right': scrollbarWidths.right - 1
108 });
109 }
110 }
111
112
113 // Undoes compensateScroll and restores all borders/margins
114 function uncompensateScroll(rowEls) {
115 rowEls.css({
116 'margin-left': '',
117 'margin-right': '',
118 'border-left-width': '',
119 'border-right-width': ''
120 });
121 }
122
123
124 // Make the mouse cursor express that an event is not allowed in the current area
125 function disableCursor() {
126 $('body').addClass('fc-not-allowed');
127 }
128
129
130 // Returns the mouse cursor to its original look
131 function enableCursor() {
132 $('body').removeClass('fc-not-allowed');
133 }
134
135
136 // Given a total available height to fill, have `els` (essentially child rows) expand to accomodate.
137 // By default, all elements that are shorter than the recommended height are expanded uniformly, not considering
138 // any other els that are already too tall. if `shouldRedistribute` is on, it considers these tall rows and
139 // reduces the available height.
140 function distributeHeight(els, availableHeight, shouldRedistribute) {
141
142 // *FLOORING NOTE*: we floor in certain places because zoom can give inaccurate floating-point dimensions,
143 // and it is better to be shorter than taller, to avoid creating unnecessary scrollbars.
144
145 var minOffset1 = Math.floor(availableHeight / els.length); // for non-last element
146 var minOffset2 = Math.floor(availableHeight - minOffset1 * (els.length - 1)); // for last element *FLOORING NOTE*
147 var flexEls = []; // elements that are allowed to expand. array of DOM nodes
148 var flexOffsets = []; // amount of vertical space it takes up
149 var flexHeights = []; // actual css height
150 var usedHeight = 0;
151
152 undistributeHeight(els); // give all elements their natural height
153
154 // find elements that are below the recommended height (expandable).
155 // important to query for heights in a single first pass (to avoid reflow oscillation).
156 els.each(function(i, el) {
157 var minOffset = i === els.length - 1 ? minOffset2 : minOffset1;
158 var naturalOffset = $(el).outerHeight(true);
159
160 if (naturalOffset < minOffset) {
161 flexEls.push(el);
162 flexOffsets.push(naturalOffset);
163 flexHeights.push($(el).height());
164 }
165 else {
166 // this element stretches past recommended height (non-expandable). mark the space as occupied.
167 usedHeight += naturalOffset;
168 }
169 });
170
171 // readjust the recommended height to only consider the height available to non-maxed-out rows.
172 if (shouldRedistribute) {
173 availableHeight -= usedHeight;
174 minOffset1 = Math.floor(availableHeight / flexEls.length);
175 minOffset2 = Math.floor(availableHeight - minOffset1 * (flexEls.length - 1)); // *FLOORING NOTE*
176 }
177
178 // assign heights to all expandable elements
179 $(flexEls).each(function(i, el) {
180 var minOffset = i === flexEls.length - 1 ? minOffset2 : minOffset1;
181 var naturalOffset = flexOffsets[i];
182 var naturalHeight = flexHeights[i];
183 var newHeight = minOffset - (naturalOffset - naturalHeight); // subtract the margin/padding
184
185 if (naturalOffset < minOffset) { // we check this again because redistribution might have changed things
186 $(el).height(newHeight);
187 }
188 });
189 }
190
191
192 // Undoes distrubuteHeight, restoring all els to their natural height
193 function undistributeHeight(els) {
194 els.height('');
195 }
196
197
198 // Given `els`, a jQuery set of <td> cells, find the cell with the largest natural width and set the widths of all the
199 // cells to be that width.
200 // PREREQUISITE: if you want a cell to take up width, it needs to have a single inner element w/ display:inline
201 function matchCellWidths(els) {
202 var maxInnerWidth = 0;
203
204 els.find('> *').each(function(i, innerEl) {
205 var innerWidth = $(innerEl).outerWidth();
206 if (innerWidth > maxInnerWidth) {
207 maxInnerWidth = innerWidth;
208 }
209 });
210
211 maxInnerWidth++; // sometimes not accurate of width the text needs to stay on one line. insurance
212
213 els.width(maxInnerWidth);
214
215 return maxInnerWidth;
216 }
217
218
219 // Given one element that resides inside another,
220 // Subtracts the height of the inner element from the outer element.
221 function subtractInnerElHeight(outerEl, innerEl) {
222 var both = outerEl.add(innerEl);
223 var diff;
224
225 // effin' IE8/9/10/11 sometimes returns 0 for dimensions. this weird hack was the only thing that worked
226 both.css({
227 position: 'relative', // cause a reflow, which will force fresh dimension recalculation
228 left: -1 // ensure reflow in case the el was already relative. negative is less likely to cause new scroll
229 });
230 diff = outerEl.outerHeight() - innerEl.outerHeight(); // grab the dimensions
231 both.css({ position: '', left: '' }); // undo hack
232
233 return diff;
234 }
235
236
237 /* Element Geom Utilities
238 ----------------------------------------------------------------------------------------------------------------------*/
239
240 FC.getOuterRect = getOuterRect;
241 FC.getClientRect = getClientRect;
242 FC.getContentRect = getContentRect;
243 FC.getScrollbarWidths = getScrollbarWidths;
244
245
246 // borrowed from https://github.com/jquery/jquery-ui/blob/1.11.0/ui/core.js#L51
247 function getScrollParent(el) {
248 var position = el.css('position'),
249 scrollParent = el.parents().filter(function() {
250 var parent = $(this);
251 return (/(auto|scroll)/).test(
252 parent.css('overflow') + parent.css('overflow-y') + parent.css('overflow-x')
253 );
254 }).eq(0);
255
256 return position === 'fixed' || !scrollParent.length ? $(el[0].ownerDocument || document) : scrollParent;
257 }
258
259
260 // Queries the outer bounding area of a jQuery element.
261 // Returns a rectangle with absolute coordinates: left, right (exclusive), top, bottom (exclusive).
262 // Origin is optional.
263 function getOuterRect(el, origin) {
264 var offset = el.offset();
265 var left = offset.left - (origin ? origin.left : 0);
266 var top = offset.top - (origin ? origin.top : 0);
267
268 return {
269 left: left,
270 right: left + el.outerWidth(),
271 top: top,
272 bottom: top + el.outerHeight()
273 };
274 }
275
276
277 // Queries the area within the margin/border/scrollbars of a jQuery element. Does not go within the padding.
278 // Returns a rectangle with absolute coordinates: left, right (exclusive), top, bottom (exclusive).
279 // Origin is optional.
280 // NOTE: should use clientLeft/clientTop, but very unreliable cross-browser.
281 function getClientRect(el, origin) {
282 var offset = el.offset();
283 var scrollbarWidths = getScrollbarWidths(el);
284 var left = offset.left + getCssFloat(el, 'border-left-width') + scrollbarWidths.left - (origin ? origin.left : 0);
285 var top = offset.top + getCssFloat(el, 'border-top-width') + scrollbarWidths.top - (origin ? origin.top : 0);
286
287 return {
288 left: left,
289 right: left + el[0].clientWidth, // clientWidth includes padding but NOT scrollbars
290 top: top,
291 bottom: top + el[0].clientHeight // clientHeight includes padding but NOT scrollbars
292 };
293 }
294
295
296 // Queries the area within the margin/border/padding of a jQuery element. Assumed not to have scrollbars.
297 // Returns a rectangle with absolute coordinates: left, right (exclusive), top, bottom (exclusive).
298 // Origin is optional.
299 function getContentRect(el, origin) {
300 var offset = el.offset(); // just outside of border, margin not included
301 var left = offset.left + getCssFloat(el, 'border-left-width') + getCssFloat(el, 'padding-left') -
302 (origin ? origin.left : 0);
303 var top = offset.top + getCssFloat(el, 'border-top-width') + getCssFloat(el, 'padding-top') -
304 (origin ? origin.top : 0);
305
306 return {
307 left: left,
308 right: left + el.width(),
309 top: top,
310 bottom: top + el.height()
311 };
312 }
313
314
315 // Returns the computed left/right/top/bottom scrollbar widths for the given jQuery element.
316 // NOTE: should use clientLeft/clientTop, but very unreliable cross-browser.
317 function getScrollbarWidths(el) {
318 var leftRightWidth = el.innerWidth() - el[0].clientWidth; // the paddings cancel out, leaving the scrollbars
319 var bottomWidth = el.innerHeight() - el[0].clientHeight; // "
320 var widths;
321
322 leftRightWidth = sanitizeScrollbarWidth(leftRightWidth);
323 bottomWidth = sanitizeScrollbarWidth(bottomWidth);
324
325 widths = { left: 0, right: 0, top: 0, bottom: bottomWidth };
326
327 if (getIsLeftRtlScrollbars() && el.css('direction') == 'rtl') { // is the scrollbar on the left side?
328 widths.left = leftRightWidth;
329 }
330 else {
331 widths.right = leftRightWidth;
332 }
333
334 return widths;
335 }
336
337
338 // The scrollbar width computations in getScrollbarWidths are sometimes flawed when it comes to
339 // retina displays, rounding, and IE11. Massage them into a usable value.
340 function sanitizeScrollbarWidth(width) {
341 width = Math.max(0, width); // no negatives
342 width = Math.round(width);
343 return width;
344 }
345
346
347 // Logic for determining if, when the element is right-to-left, the scrollbar appears on the left side
348
349 var _isLeftRtlScrollbars = null;
350
351 function getIsLeftRtlScrollbars() { // responsible for caching the computation
352 if (_isLeftRtlScrollbars === null) {
353 _isLeftRtlScrollbars = computeIsLeftRtlScrollbars();
354 }
355 return _isLeftRtlScrollbars;
356 }
357
358 function computeIsLeftRtlScrollbars() { // creates an offscreen test element, then removes it
359 var el = $('<div><div/></div>')
360 .css({
361 position: 'absolute',
362 top: -1000,
363 left: 0,
364 border: 0,
365 padding: 0,
366 overflow: 'scroll',
367 direction: 'rtl'
368 })
369 .appendTo('body');
370 var innerEl = el.children();
371 var res = innerEl.offset().left > el.offset().left; // is the inner div shifted to accommodate a left scrollbar?
372 el.remove();
373 return res;
374 }
375
376
377 // Retrieves a jQuery element's computed CSS value as a floating-point number.
378 // If the queried value is non-numeric (ex: IE can return "medium" for border width), will just return zero.
379 function getCssFloat(el, prop) {
380 return parseFloat(el.css(prop)) || 0;
381 }
382
383
384 /* Mouse / Touch Utilities
385 ----------------------------------------------------------------------------------------------------------------------*/
386
387 FC.preventDefault = preventDefault;
388
389
390 // Returns a boolean whether this was a left mouse click and no ctrl key (which means right click on Mac)
391 function isPrimaryMouseButton(ev) {
392 return ev.which == 1 && !ev.ctrlKey;
393 }
394
395
396 function getEvX(ev) {
397 var touches = ev.originalEvent.touches;
398
399 // on mobile FF, pageX for touch events is present, but incorrect,
400 // so, look at touch coordinates first.
401 if (touches && touches.length) {
402 return touches[0].pageX;
403 }
404
405 return ev.pageX;
406 }
407
408
409 function getEvY(ev) {
410 var touches = ev.originalEvent.touches;
411
412 // on mobile FF, pageX for touch events is present, but incorrect,
413 // so, look at touch coordinates first.
414 if (touches && touches.length) {
415 return touches[0].pageY;
416 }
417
418 return ev.pageY;
419 }
420
421
422 function getEvIsTouch(ev) {
423 return /^touch/.test(ev.type);
424 }
425
426
427 function preventSelection(el) {
428 el.addClass('fc-unselectable')
429 .on('selectstart', preventDefault);
430 }
431
432
433 function allowSelection(el) {
434 el.removeClass('fc-unselectable')
435 .off('selectstart', preventDefault);
436 }
437
438
439 // Stops a mouse/touch event from doing it's native browser action
440 function preventDefault(ev) {
441 ev.preventDefault();
442 }
443
444
445 /* General Geometry Utils
446 ----------------------------------------------------------------------------------------------------------------------*/
447
448 FC.intersectRects = intersectRects;
449
450 // Returns a new rectangle that is the intersection of the two rectangles. If they don't intersect, returns false
451 function intersectRects(rect1, rect2) {
452 var res = {
453 left: Math.max(rect1.left, rect2.left),
454 right: Math.min(rect1.right, rect2.right),
455 top: Math.max(rect1.top, rect2.top),
456 bottom: Math.min(rect1.bottom, rect2.bottom)
457 };
458
459 if (res.left < res.right && res.top < res.bottom) {
460 return res;
461 }
462 return false;
463 }
464
465
466 // Returns a new point that will have been moved to reside within the given rectangle
467 function constrainPoint(point, rect) {
468 return {
469 left: Math.min(Math.max(point.left, rect.left), rect.right),
470 top: Math.min(Math.max(point.top, rect.top), rect.bottom)
471 };
472 }
473
474
475 // Returns a point that is the center of the given rectangle
476 function getRectCenter(rect) {
477 return {
478 left: (rect.left + rect.right) / 2,
479 top: (rect.top + rect.bottom) / 2
480 };
481 }
482
483
484 // Subtracts point2's coordinates from point1's coordinates, returning a delta
485 function diffPoints(point1, point2) {
486 return {
487 left: point1.left - point2.left,
488 top: point1.top - point2.top
489 };
490 }
491
492
493 /* Object Ordering by Field
494 ----------------------------------------------------------------------------------------------------------------------*/
495
496 FC.parseFieldSpecs = parseFieldSpecs;
497 FC.compareByFieldSpecs = compareByFieldSpecs;
498 FC.compareByFieldSpec = compareByFieldSpec;
499 FC.flexibleCompare = flexibleCompare;
500
501
502 function parseFieldSpecs(input) {
503 var specs = [];
504 var tokens = [];
505 var i, token;
506
507 if (typeof input === 'string') {
508 tokens = input.split(/\s*,\s*/);
509 }
510 else if (typeof input === 'function') {
511 tokens = [ input ];
512 }
513 else if ($.isArray(input)) {
514 tokens = input;
515 }
516
517 for (i = 0; i < tokens.length; i++) {
518 token = tokens[i];
519
520 if (typeof token === 'string') {
521 specs.push(
522 token.charAt(0) == '-' ?
523 { field: token.substring(1), order: -1 } :
524 { field: token, order: 1 }
525 );
526 }
527 else if (typeof token === 'function') {
528 specs.push({ func: token });
529 }
530 }
531
532 return specs;
533 }
534
535
536 function compareByFieldSpecs(obj1, obj2, fieldSpecs) {
537 var i;
538 var cmp;
539
540 for (i = 0; i < fieldSpecs.length; i++) {
541 cmp = compareByFieldSpec(obj1, obj2, fieldSpecs[i]);
542 if (cmp) {
543 return cmp;
544 }
545 }
546
547 return 0;
548 }
549
550
551 function compareByFieldSpec(obj1, obj2, fieldSpec) {
552 if (fieldSpec.func) {
553 return fieldSpec.func(obj1, obj2);
554 }
555 return flexibleCompare(obj1[fieldSpec.field], obj2[fieldSpec.field]) *
556 (fieldSpec.order || 1);
557 }
558
559
560 function flexibleCompare(a, b) {
561 if (!a && !b) {
562 return 0;
563 }
564 if (b == null) {
565 return -1;
566 }
567 if (a == null) {
568 return 1;
569 }
570 if ($.type(a) === 'string' || $.type(b) === 'string') {
571 return String(a).localeCompare(String(b));
572 }
573 return a - b;
574 }
575
576
577 /* FullCalendar-specific Misc Utilities
578 ----------------------------------------------------------------------------------------------------------------------*/
579
580
581 // Computes the intersection of the two ranges. Will return fresh date clones in a range.
582 // Returns undefined if no intersection.
583 // Expects all dates to be normalized to the same timezone beforehand.
584 // TODO: move to date section?
585 function intersectRanges(subjectRange, constraintRange) {
586 var subjectStart = subjectRange.start;
587 var subjectEnd = subjectRange.end;
588 var constraintStart = constraintRange.start;
589 var constraintEnd = constraintRange.end;
590 var segStart, segEnd;
591 var isStart, isEnd;
592
593 if (subjectEnd > constraintStart && subjectStart < constraintEnd) { // in bounds at all?
594
595 if (subjectStart >= constraintStart) {
596 segStart = subjectStart.clone();
597 isStart = true;
598 }
599 else {
600 segStart = constraintStart.clone();
601 isStart = false;
602 }
603
604 if (subjectEnd <= constraintEnd) {
605 segEnd = subjectEnd.clone();
606 isEnd = true;
607 }
608 else {
609 segEnd = constraintEnd.clone();
610 isEnd = false;
611 }
612
613 return {
614 start: segStart,
615 end: segEnd,
616 isStart: isStart,
617 isEnd: isEnd
618 };
619 }
620 }
621
622
623 /* Date Utilities
624 ----------------------------------------------------------------------------------------------------------------------*/
625
626 FC.computeIntervalUnit = computeIntervalUnit;
627 FC.divideRangeByDuration = divideRangeByDuration;
628 FC.divideDurationByDuration = divideDurationByDuration;
629 FC.multiplyDuration = multiplyDuration;
630 FC.durationHasTime = durationHasTime;
631
632 var dayIDs = [ 'sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat' ];
633 var intervalUnits = [ 'year', 'month', 'week', 'day', 'hour', 'minute', 'second', 'millisecond' ];
634
635
636 // Diffs the two moments into a Duration where full-days are recorded first, then the remaining time.
637 // Moments will have their timezones normalized.
638 function diffDayTime(a, b) {
639 return moment.duration({
640 days: a.clone().stripTime().diff(b.clone().stripTime(), 'days'),
641 ms: a.time() - b.time() // time-of-day from day start. disregards timezone
642 });
643 }
644
645
646 // Diffs the two moments via their start-of-day (regardless of timezone). Produces whole-day durations.
647 function diffDay(a, b) {
648 return moment.duration({
649 days: a.clone().stripTime().diff(b.clone().stripTime(), 'days')
650 });
651 }
652
653
654 // Diffs two moments, producing a duration, made of a whole-unit-increment of the given unit. Uses rounding.
655 function diffByUnit(a, b, unit) {
656 return moment.duration(
657 Math.round(a.diff(b, unit, true)), // returnFloat=true
658 unit
659 );
660 }
661
662
663 // Computes the unit name of the largest whole-unit period of time.
664 // For example, 48 hours will be "days" whereas 49 hours will be "hours".
665 // Accepts start/end, a range object, or an original duration object.
666 function computeIntervalUnit(start, end) {
667 var i, unit;
668 var val;
669
670 for (i = 0; i < intervalUnits.length; i++) {
671 unit = intervalUnits[i];
672 val = computeRangeAs(unit, start, end);
673
674 if (val >= 1 && isInt(val)) {
675 break;
676 }
677 }
678
679 return unit; // will be "milliseconds" if nothing else matches
680 }
681
682
683 // Computes the number of units (like "hours") in the given range.
684 // Range can be a {start,end} object, separate start/end args, or a Duration.
685 // Results are based on Moment's .as() and .diff() methods, so results can depend on internal handling
686 // of month-diffing logic (which tends to vary from version to version).
687 function computeRangeAs(unit, start, end) {
688
689 if (end != null) { // given start, end
690 return end.diff(start, unit, true);
691 }
692 else if (moment.isDuration(start)) { // given duration
693 return start.as(unit);
694 }
695 else { // given { start, end } range object
696 return start.end.diff(start.start, unit, true);
697 }
698 }
699
700
701 // Intelligently divides a range (specified by a start/end params) by a duration
702 function divideRangeByDuration(start, end, dur) {
703 var months;
704
705 if (durationHasTime(dur)) {
706 return (end - start) / dur;
707 }
708 months = dur.asMonths();
709 if (Math.abs(months) >= 1 && isInt(months)) {
710 return end.diff(start, 'months', true) / months;
711 }
712 return end.diff(start, 'days', true) / dur.asDays();
713 }
714
715
716 // Intelligently divides one duration by another
717 function divideDurationByDuration(dur1, dur2) {
718 var months1, months2;
719
720 if (durationHasTime(dur1) || durationHasTime(dur2)) {
721 return dur1 / dur2;
722 }
723 months1 = dur1.asMonths();
724 months2 = dur2.asMonths();
725 if (
726 Math.abs(months1) >= 1 && isInt(months1) &&
727 Math.abs(months2) >= 1 && isInt(months2)
728 ) {
729 return months1 / months2;
730 }
731 return dur1.asDays() / dur2.asDays();
732 }
733
734
735 // Intelligently multiplies a duration by a number
736 function multiplyDuration(dur, n) {
737 var months;
738
739 if (durationHasTime(dur)) {
740 return moment.duration(dur * n);
741 }
742 months = dur.asMonths();
743 if (Math.abs(months) >= 1 && isInt(months)) {
744 return moment.duration({ months: months * n });
745 }
746 return moment.duration({ days: dur.asDays() * n });
747 }
748
749
750 // Returns a boolean about whether the given duration has any time parts (hours/minutes/seconds/ms)
751 function durationHasTime(dur) {
752 return Boolean(dur.hours() || dur.minutes() || dur.seconds() || dur.milliseconds());
753 }
754
755
756 function isNativeDate(input) {
757 return Object.prototype.toString.call(input) === '[object Date]' || input instanceof Date;
758 }
759
760
761 // Returns a boolean about whether the given input is a time string, like "06:40:00" or "06:00"
762 function isTimeString(str) {
763 return /^\d+\:\d+(?:\:\d+\.?(?:\d{3})?)?$/.test(str);
764 }
765
766
767 /* Logging and Debug
768 ----------------------------------------------------------------------------------------------------------------------*/
769
770 FC.log = function() {
771 var console = window.console;
772
773 if (console && console.log) {
774 return console.log.apply(console, arguments);
775 }
776 };
777
778 FC.warn = function() {
779 var console = window.console;
780
781 if (console && console.warn) {
782 return console.warn.apply(console, arguments);
783 }
784 else {
785 return FC.log.apply(FC, arguments);
786 }
787 };
788
789
790 /* General Utilities
791 ----------------------------------------------------------------------------------------------------------------------*/
792
793 var hasOwnPropMethod = {}.hasOwnProperty;
794
795
796 // Merges an array of objects into a single object.
797 // The second argument allows for an array of property names who's object values will be merged together.
798 function mergeProps(propObjs, complexProps) {
799 var dest = {};
800 var i, name;
801 var complexObjs;
802 var j, val;
803 var props;
804
805 if (complexProps) {
806 for (i = 0; i < complexProps.length; i++) {
807 name = complexProps[i];
808 complexObjs = [];
809
810 // collect the trailing object values, stopping when a non-object is discovered
811 for (j = propObjs.length - 1; j >= 0; j--) {
812 val = propObjs[j][name];
813
814 if (typeof val === 'object') {
815 complexObjs.unshift(val);
816 }
817 else if (val !== undefined) {
818 dest[name] = val; // if there were no objects, this value will be used
819 break;
820 }
821 }
822
823 // if the trailing values were objects, use the merged value
824 if (complexObjs.length) {
825 dest[name] = mergeProps(complexObjs);
826 }
827 }
828 }
829
830 // copy values into the destination, going from last to first
831 for (i = propObjs.length - 1; i >= 0; i--) {
832 props = propObjs[i];
833
834 for (name in props) {
835 if (!(name in dest)) { // if already assigned by previous props or complex props, don't reassign
836 dest[name] = props[name];
837 }
838 }
839 }
840
841 return dest;
842 }
843
844
845 // Create an object that has the given prototype. Just like Object.create
846 function createObject(proto) {
847 var f = function() {};
848 f.prototype = proto;
849 return new f();
850 }
851 FC.createObject = createObject;
852
853
854 function copyOwnProps(src, dest) {
855 for (var name in src) {
856 if (hasOwnProp(src, name)) {
857 dest[name] = src[name];
858 }
859 }
860 }
861
862
863 function hasOwnProp(obj, name) {
864 return hasOwnPropMethod.call(obj, name);
865 }
866
867
868 // Is the given value a non-object non-function value?
869 function isAtomic(val) {
870 return /undefined|null|boolean|number|string/.test($.type(val));
871 }
872
873
874 function applyAll(functions, thisObj, args) {
875 if ($.isFunction(functions)) {
876 functions = [ functions ];
877 }
878 if (functions) {
879 var i;
880 var ret;
881 for (i=0; i<functions.length; i++) {
882 ret = functions[i].apply(thisObj, args) || ret;
883 }
884 return ret;
885 }
886 }
887
888
889 function firstDefined() {
890 for (var i=0; i<arguments.length; i++) {
891 if (arguments[i] !== undefined) {
892 return arguments[i];
893 }
894 }
895 }
896
897
898 function htmlEscape(s) {
899 return (s + '').replace(/&/g, '&amp;')
900 .replace(/</g, '&lt;')
901 .replace(/>/g, '&gt;')
902 .replace(/'/g, '&#039;')
903 .replace(/"/g, '&quot;')
904 .replace(/\n/g, '<br />');
905 }
906
907
908 function stripHtmlEntities(text) {
909 return text.replace(/&.*?;/g, '');
910 }
911
912
913 // Given a hash of CSS properties, returns a string of CSS.
914 // Uses property names as-is (no camel-case conversion). Will not make statements for null/undefined values.
915 function cssToStr(cssProps) {
916 var statements = [];
917
918 $.each(cssProps, function(name, val) {
919 if (val != null) {
920 statements.push(name + ':' + val);
921 }
922 });
923
924 return statements.join(';');
925 }
926
927
928 // Given an object hash of HTML attribute names to values,
929 // generates a string that can be injected between < > in HTML
930 function attrsToStr(attrs) {
931 var parts = [];
932
933 $.each(attrs, function(name, val) {
934 if (val != null) {
935 parts.push(name + '="' + htmlEscape(val) + '"');
936 }
937 });
938
939 return parts.join(' ');
940 }
941
942
943 function capitaliseFirstLetter(str) {
944 return str.charAt(0).toUpperCase() + str.slice(1);
945 }
946
947
948 function compareNumbers(a, b) { // for .sort()
949 return a - b;
950 }
951
952
953 function isInt(n) {
954 return n % 1 === 0;
955 }
956
957
958 // Returns a method bound to the given object context.
959 // Just like one of the jQuery.proxy signatures, but without the undesired behavior of treating the same method with
960 // different contexts as identical when binding/unbinding events.
961 function proxy(obj, methodName) {
962 var method = obj[methodName];
963
964 return function() {
965 return method.apply(obj, arguments);
966 };
967 }
968
969
970 // Returns a function, that, as long as it continues to be invoked, will not
971 // be triggered. The function will be called after it stops being called for
972 // N milliseconds. If `immediate` is passed, trigger the function on the
973 // leading edge, instead of the trailing.
974 // https://github.com/jashkenas/underscore/blob/1.6.0/underscore.js#L714
975 function debounce(func, wait, immediate) {
976 var timeout, args, context, timestamp, result;
977
978 var later = function() {
979 var last = +new Date() - timestamp;
980 if (last < wait) {
981 timeout = setTimeout(later, wait - last);
982 }
983 else {
984 timeout = null;
985 if (!immediate) {
986 result = func.apply(context, args);
987 context = args = null;
988 }
989 }
990 };
991
992 return function() {
993 context = this;
994 args = arguments;
995 timestamp = +new Date();
996 var callNow = immediate && !timeout;
997 if (!timeout) {
998 timeout = setTimeout(later, wait);
999 }
1000 if (callNow) {
1001 result = func.apply(context, args);
1002 context = args = null;
1003 }
1004 return result;
1005 };
1006 }
1007
1008 ;;
1009
1010 /*
1011 GENERAL NOTE on moments throughout the *entire rest* of the codebase:
1012 All moments are assumed to be ambiguously-zoned unless otherwise noted,
1013 with the NOTABLE EXCEOPTION of start/end dates that live on *Event Objects*.
1014 Ambiguously-TIMED moments are assumed to be ambiguously-zoned by nature.
1015 */
1016
1017 var ambigDateOfMonthRegex = /^\s*\d{4}-\d\d$/;
1018 var ambigTimeOrZoneRegex =
1019 /^\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+)?)?)?)?)?$/;
1020 var newMomentProto = moment.fn; // where we will attach our new methods
1021 var oldMomentProto = $.extend({}, newMomentProto); // copy of original moment methods
1022
1023 // tell momentjs to transfer these properties upon clone
1024 var momentProperties = moment.momentProperties;
1025 momentProperties.push('_fullCalendar');
1026 momentProperties.push('_ambigTime');
1027 momentProperties.push('_ambigZone');
1028
1029
1030 // Creating
1031 // -------------------------------------------------------------------------------------------------
1032
1033 // Creates a new moment, similar to the vanilla moment(...) constructor, but with
1034 // extra features (ambiguous time, enhanced formatting). When given an existing moment,
1035 // it will function as a clone (and retain the zone of the moment). Anything else will
1036 // result in a moment in the local zone.
1037 FC.moment = function() {
1038 return makeMoment(arguments);
1039 };
1040
1041 // Sames as FC.moment, but forces the resulting moment to be in the UTC timezone.
1042 FC.moment.utc = function() {
1043 var mom = makeMoment(arguments, true);
1044
1045 // Force it into UTC because makeMoment doesn't guarantee it
1046 // (if given a pre-existing moment for example)
1047 if (mom.hasTime()) { // don't give ambiguously-timed moments a UTC zone
1048 mom.utc();
1049 }
1050
1051 return mom;
1052 };
1053
1054 // Same as FC.moment, but when given an ISO8601 string, the timezone offset is preserved.
1055 // ISO8601 strings with no timezone offset will become ambiguously zoned.
1056 FC.moment.parseZone = function() {
1057 return makeMoment(arguments, true, true);
1058 };
1059
1060 // Builds an enhanced moment from args. When given an existing moment, it clones. When given a
1061 // native Date, or called with no arguments (the current time), the resulting moment will be local.
1062 // Anything else needs to be "parsed" (a string or an array), and will be affected by:
1063 // parseAsUTC - if there is no zone information, should we parse the input in UTC?
1064 // parseZone - if there is zone information, should we force the zone of the moment?
1065 function makeMoment(args, parseAsUTC, parseZone) {
1066 var input = args[0];
1067 var isSingleString = args.length == 1 && typeof input === 'string';
1068 var isAmbigTime;
1069 var isAmbigZone;
1070 var ambigMatch;
1071 var mom;
1072
1073 if (moment.isMoment(input) || isNativeDate(input) || input === undefined) {
1074 mom = moment.apply(null, args);
1075 }
1076 else { // "parsing" is required
1077 isAmbigTime = false;
1078 isAmbigZone = false;
1079
1080 if (isSingleString) {
1081 if (ambigDateOfMonthRegex.test(input)) {
1082 // accept strings like '2014-05', but convert to the first of the month
1083 input += '-01';
1084 args = [ input ]; // for when we pass it on to moment's constructor
1085 isAmbigTime = true;
1086 isAmbigZone = true;
1087 }
1088 else if ((ambigMatch = ambigTimeOrZoneRegex.exec(input))) {
1089 isAmbigTime = !ambigMatch[5]; // no time part?
1090 isAmbigZone = true;
1091 }
1092 }
1093 else if ($.isArray(input)) {
1094 // arrays have no timezone information, so assume ambiguous zone
1095 isAmbigZone = true;
1096 }
1097 // otherwise, probably a string with a format
1098
1099 if (parseAsUTC || isAmbigTime) {
1100 mom = moment.utc.apply(moment, args);
1101 }
1102 else {
1103 mom = moment.apply(null, args);
1104 }
1105
1106 if (isAmbigTime) {
1107 mom._ambigTime = true;
1108 mom._ambigZone = true; // ambiguous time always means ambiguous zone
1109 }
1110 else if (parseZone) { // let's record the inputted zone somehow
1111 if (isAmbigZone) {
1112 mom._ambigZone = true;
1113 }
1114 else if (isSingleString) {
1115 mom.utcOffset(input); // if not a valid zone, will assign UTC
1116 }
1117 }
1118 }
1119
1120 mom._fullCalendar = true; // flag for extended functionality
1121
1122 return mom;
1123 }
1124
1125
1126 // Week Number
1127 // -------------------------------------------------------------------------------------------------
1128
1129
1130 // Returns the week number, considering the locale's custom week number calcuation
1131 // `weeks` is an alias for `week`
1132 newMomentProto.week = newMomentProto.weeks = function(input) {
1133 var weekCalc = this._locale._fullCalendar_weekCalc;
1134
1135 if (input == null && typeof weekCalc === 'function') { // custom function only works for getter
1136 return weekCalc(this);
1137 }
1138 else if (weekCalc === 'ISO') {
1139 return oldMomentProto.isoWeek.apply(this, arguments); // ISO getter/setter
1140 }
1141
1142 return oldMomentProto.week.apply(this, arguments); // local getter/setter
1143 };
1144
1145
1146 // Time-of-day
1147 // -------------------------------------------------------------------------------------------------
1148
1149 // GETTER
1150 // Returns a Duration with the hours/minutes/seconds/ms values of the moment.
1151 // If the moment has an ambiguous time, a duration of 00:00 will be returned.
1152 //
1153 // SETTER
1154 // You can supply a Duration, a Moment, or a Duration-like argument.
1155 // When setting the time, and the moment has an ambiguous time, it then becomes unambiguous.
1156 newMomentProto.time = function(time) {
1157
1158 // Fallback to the original method (if there is one) if this moment wasn't created via FullCalendar.
1159 // `time` is a generic enough method name where this precaution is necessary to avoid collisions w/ other plugins.
1160 if (!this._fullCalendar) {
1161 return oldMomentProto.time.apply(this, arguments);
1162 }
1163
1164 if (time == null) { // getter
1165 return moment.duration({
1166 hours: this.hours(),
1167 minutes: this.minutes(),
1168 seconds: this.seconds(),
1169 milliseconds: this.milliseconds()
1170 });
1171 }
1172 else { // setter
1173
1174 this._ambigTime = false; // mark that the moment now has a time
1175
1176 if (!moment.isDuration(time) && !moment.isMoment(time)) {
1177 time = moment.duration(time);
1178 }
1179
1180 // The day value should cause overflow (so 24 hours becomes 00:00:00 of next day).
1181 // Only for Duration times, not Moment times.
1182 var dayHours = 0;
1183 if (moment.isDuration(time)) {
1184 dayHours = Math.floor(time.asDays()) * 24;
1185 }
1186
1187 // We need to set the individual fields.
1188 // Can't use startOf('day') then add duration. In case of DST at start of day.
1189 return this.hours(dayHours + time.hours())
1190 .minutes(time.minutes())
1191 .seconds(time.seconds())
1192 .milliseconds(time.milliseconds());
1193 }
1194 };
1195
1196 // Converts the moment to UTC, stripping out its time-of-day and timezone offset,
1197 // but preserving its YMD. A moment with a stripped time will display no time
1198 // nor timezone offset when .format() is called.
1199 newMomentProto.stripTime = function() {
1200
1201 if (!this._ambigTime) {
1202
1203 this.utc(true); // keepLocalTime=true (for keeping *date* value)
1204
1205 // set time to zero
1206 this.set({
1207 hours: 0,
1208 minutes: 0,
1209 seconds: 0,
1210 ms: 0
1211 });
1212
1213 // Mark the time as ambiguous. This needs to happen after the .utc() call, which might call .utcOffset(),
1214 // which clears all ambig flags.
1215 this._ambigTime = true;
1216 this._ambigZone = true; // if ambiguous time, also ambiguous timezone offset
1217 }
1218
1219 return this; // for chaining
1220 };
1221
1222 // Returns if the moment has a non-ambiguous time (boolean)
1223 newMomentProto.hasTime = function() {
1224 return !this._ambigTime;
1225 };
1226
1227
1228 // Timezone
1229 // -------------------------------------------------------------------------------------------------
1230
1231 // Converts the moment to UTC, stripping out its timezone offset, but preserving its
1232 // YMD and time-of-day. A moment with a stripped timezone offset will display no
1233 // timezone offset when .format() is called.
1234 newMomentProto.stripZone = function() {
1235 var wasAmbigTime;
1236
1237 if (!this._ambigZone) {
1238
1239 wasAmbigTime = this._ambigTime;
1240
1241 this.utc(true); // keepLocalTime=true (for keeping date and time values)
1242
1243 // the above call to .utc()/.utcOffset() unfortunately might clear the ambig flags, so restore
1244 this._ambigTime = wasAmbigTime || false;
1245
1246 // Mark the zone as ambiguous. This needs to happen after the .utc() call, which might call .utcOffset(),
1247 // which clears the ambig flags.
1248 this._ambigZone = true;
1249 }
1250
1251 return this; // for chaining
1252 };
1253
1254 // Returns of the moment has a non-ambiguous timezone offset (boolean)
1255 newMomentProto.hasZone = function() {
1256 return !this._ambigZone;
1257 };
1258
1259
1260 // implicitly marks a zone
1261 newMomentProto.local = function(keepLocalTime) {
1262
1263 // for when converting from ambiguously-zoned to local,
1264 // keep the time values when converting from UTC -> local
1265 oldMomentProto.local.call(this, this._ambigZone || keepLocalTime);
1266
1267 // ensure non-ambiguous
1268 // this probably already happened via local() -> utcOffset(), but don't rely on Moment's internals
1269 this._ambigTime = false;
1270 this._ambigZone = false;
1271
1272 return this; // for chaining
1273 };
1274
1275
1276 // implicitly marks a zone
1277 newMomentProto.utc = function(keepLocalTime) {
1278
1279 oldMomentProto.utc.call(this, keepLocalTime);
1280
1281 // ensure non-ambiguous
1282 // this probably already happened via utc() -> utcOffset(), but don't rely on Moment's internals
1283 this._ambigTime = false;
1284 this._ambigZone = false;
1285
1286 return this;
1287 };
1288
1289
1290 // implicitly marks a zone (will probably get called upon .utc() and .local())
1291 newMomentProto.utcOffset = function(tzo) {
1292
1293 if (tzo != null) { // setter
1294 // these assignments needs to happen before the original zone method is called.
1295 // I forget why, something to do with a browser crash.
1296 this._ambigTime = false;
1297 this._ambigZone = false;
1298 }
1299
1300 return oldMomentProto.utcOffset.apply(this, arguments);
1301 };
1302
1303
1304 // Formatting
1305 // -------------------------------------------------------------------------------------------------
1306
1307 newMomentProto.format = function() {
1308 if (this._fullCalendar && arguments[0]) { // an enhanced moment? and a format string provided?
1309 return formatDate(this, arguments[0]); // our extended formatting
1310 }
1311 if (this._ambigTime) {
1312 return oldMomentFormat(this, 'YYYY-MM-DD');
1313 }
1314 if (this._ambigZone) {
1315 return oldMomentFormat(this, 'YYYY-MM-DD[T]HH:mm:ss');
1316 }
1317 return oldMomentProto.format.apply(this, arguments);
1318 };
1319
1320 newMomentProto.toISOString = function() {
1321 if (this._ambigTime) {
1322 return oldMomentFormat(this, 'YYYY-MM-DD');
1323 }
1324 if (this._ambigZone) {
1325 return oldMomentFormat(this, 'YYYY-MM-DD[T]HH:mm:ss');
1326 }
1327 return oldMomentProto.toISOString.apply(this, arguments);
1328 };
1329
1330 ;;
1331 (function() {
1332
1333 // exports
1334 FC.formatDate = formatDate;
1335 FC.formatRange = formatRange;
1336 FC.oldMomentFormat = oldMomentFormat;
1337 FC.queryMostGranularFormatUnit = queryMostGranularFormatUnit;
1338
1339
1340 // Config
1341 // ---------------------------------------------------------------------------------------------------------------------
1342
1343 /*
1344 Inserted between chunks in the fake ("intermediate") formatting string.
1345 Important that it passes as whitespace (\s) because moment often identifies non-standalone months
1346 via a regexp with an \s.
1347 */
1348 var PART_SEPARATOR = '\u000b'; // vertical tab
1349
1350 /*
1351 Inserted as the first character of a literal-text chunk to indicate that the literal text is not actually literal text,
1352 but rather, a "special" token that has custom rendering (see specialTokens map).
1353 */
1354 var SPECIAL_TOKEN_MARKER = '\u001f'; // information separator 1
1355
1356 /*
1357 Inserted at the beginning and end of a span of text that must have non-zero numeric characters.
1358 Handling of these markers is done in a post-processing step at the very end of text rendering.
1359 */
1360 var MAYBE_MARKER = '\u001e'; // information separator 2
1361 var MAYBE_REGEXP = new RegExp(MAYBE_MARKER + '([^' + MAYBE_MARKER + ']*)' + MAYBE_MARKER, 'g'); // must be global
1362
1363 /*
1364 Addition formatting tokens we want recognized
1365 */
1366 var specialTokens = {
1367 t: function(date) { // "a" or "p"
1368 return oldMomentFormat(date, 'a').charAt(0);
1369 },
1370 T: function(date) { // "A" or "P"
1371 return oldMomentFormat(date, 'A').charAt(0);
1372 }
1373 };
1374
1375 /*
1376 The first characters of formatting tokens for units that are 1 day or larger.
1377 `value` is for ranking relative size (lower means bigger).
1378 `unit` is a normalized unit, used for comparing moments.
1379 */
1380 var largeTokenMap = {
1381 Y: { value: 1, unit: 'year' },
1382 M: { value: 2, unit: 'month' },
1383 W: { value: 3, unit: 'week' }, // ISO week
1384 w: { value: 3, unit: 'week' }, // local week
1385 D: { value: 4, unit: 'day' }, // day of month
1386 d: { value: 4, unit: 'day' } // day of week
1387 };
1388
1389
1390 // Single Date Formatting
1391 // ---------------------------------------------------------------------------------------------------------------------
1392
1393 /*
1394 Formats `date` with a Moment formatting string, but allow our non-zero areas and special token
1395 */
1396 function formatDate(date, formatStr) {
1397 return renderFakeFormatString(
1398 getParsedFormatString(formatStr).fakeFormatString,
1399 date
1400 );
1401 }
1402
1403 /*
1404 Call this if you want Moment's original format method to be used
1405 */
1406 function oldMomentFormat(mom, formatStr) {
1407 return oldMomentProto.format.call(mom, formatStr); // oldMomentProto defined in moment-ext.js
1408 }
1409
1410
1411 // Date Range Formatting
1412 // -------------------------------------------------------------------------------------------------
1413 // TODO: make it work with timezone offset
1414
1415 /*
1416 Using a formatting string meant for a single date, generate a range string, like
1417 "Sep 2 - 9 2013", that intelligently inserts a separator where the dates differ.
1418 If the dates are the same as far as the format string is concerned, just return a single
1419 rendering of one date, without any separator.
1420 */
1421 function formatRange(date1, date2, formatStr, separator, isRTL) {
1422 var localeData;
1423
1424 date1 = FC.moment.parseZone(date1);
1425 date2 = FC.moment.parseZone(date2);
1426
1427 localeData = date1.localeData();
1428
1429 // Expand localized format strings, like "LL" -> "MMMM D YYYY".
1430 // BTW, this is not important for `formatDate` because it is impossible to put custom tokens
1431 // or non-zero areas in Moment's localized format strings.
1432 formatStr = localeData.longDateFormat(formatStr) || formatStr;
1433
1434 return renderParsedFormat(
1435 getParsedFormatString(formatStr),
1436 date1,
1437 date2,
1438 separator || ' - ',
1439 isRTL
1440 );
1441 }
1442
1443 /*
1444 Renders a range with an already-parsed format string.
1445 */
1446 function renderParsedFormat(parsedFormat, date1, date2, separator, isRTL) {
1447 var sameUnits = parsedFormat.sameUnits;
1448 var unzonedDate1 = date1.clone().stripZone(); // for same-unit comparisons
1449 var unzonedDate2 = date2.clone().stripZone(); // "
1450
1451 var renderedParts1 = renderFakeFormatStringParts(parsedFormat.fakeFormatString, date1);
1452 var renderedParts2 = renderFakeFormatStringParts(parsedFormat.fakeFormatString, date2);
1453
1454 var leftI;
1455 var leftStr = '';
1456 var rightI;
1457 var rightStr = '';
1458 var middleI;
1459 var middleStr1 = '';
1460 var middleStr2 = '';
1461 var middleStr = '';
1462
1463 // Start at the leftmost side of the formatting string and continue until you hit a token
1464 // that is not the same between dates.
1465 for (
1466 leftI = 0;
1467 leftI < sameUnits.length && (!sameUnits[leftI] || unzonedDate1.isSame(unzonedDate2, sameUnits[leftI]));
1468 leftI++
1469 ) {
1470 leftStr += renderedParts1[leftI];
1471 }
1472
1473 // Similarly, start at the rightmost side of the formatting string and move left
1474 for (
1475 rightI = sameUnits.length - 1;
1476 rightI > leftI && (!sameUnits[rightI] || unzonedDate1.isSame(unzonedDate2, sameUnits[rightI]));
1477 rightI--
1478 ) {
1479 // If current chunk is on the boundary of unique date-content, and is a special-case
1480 // date-formatting postfix character, then don't consume it. Consider it unique date-content.
1481 // TODO: make configurable
1482 if (rightI - 1 === leftI && renderedParts1[rightI] === '.') {
1483 break;
1484 }
1485
1486 rightStr = renderedParts1[rightI] + rightStr;
1487 }
1488
1489 // The area in the middle is different for both of the dates.
1490 // Collect them distinctly so we can jam them together later.
1491 for (middleI = leftI; middleI <= rightI; middleI++) {
1492 middleStr1 += renderedParts1[middleI];
1493 middleStr2 += renderedParts2[middleI];
1494 }
1495
1496 if (middleStr1 || middleStr2) {
1497 if (isRTL) {
1498 middleStr = middleStr2 + separator + middleStr1;
1499 }
1500 else {
1501 middleStr = middleStr1 + separator + middleStr2;
1502 }
1503 }
1504
1505 return processMaybeMarkers(
1506 leftStr + middleStr + rightStr
1507 );
1508 }
1509
1510
1511 // Format String Parsing
1512 // ---------------------------------------------------------------------------------------------------------------------
1513
1514 var parsedFormatStrCache = {};
1515
1516 /*
1517 Returns a parsed format string, leveraging a cache.
1518 */
1519 function getParsedFormatString(formatStr) {
1520 return parsedFormatStrCache[formatStr] ||
1521 (parsedFormatStrCache[formatStr] = parseFormatString(formatStr));
1522 }
1523
1524 /*
1525 Parses a format string into the following:
1526 - fakeFormatString: a momentJS formatting string, littered with special control characters that get post-processed.
1527 - sameUnits: for every part in fakeFormatString, if the part is a token, the value will be a unit string (like "day"),
1528 that indicates how similar a range's start & end must be in order to share the same formatted text.
1529 If not a token, then the value is null.
1530 Always a flat array (not nested liked "chunks").
1531 */
1532 function parseFormatString(formatStr) {
1533 var chunks = chunkFormatString(formatStr);
1534
1535 return {
1536 fakeFormatString: buildFakeFormatString(chunks),
1537 sameUnits: buildSameUnits(chunks)
1538 };
1539 }
1540
1541 /*
1542 Break the formatting string into an array of chunks.
1543 A 'maybe' chunk will have nested chunks.
1544 */
1545 function chunkFormatString(formatStr) {
1546 var chunks = [];
1547 var match;
1548
1549 // TODO: more descrimination
1550 // \4 is a backreference to the first character of a multi-character set.
1551 var chunker = /\[([^\]]*)\]|\(([^\)]*)\)|(LTS|LT|(\w)\4*o?)|([^\w\[\(]+)/g;
1552
1553 while ((match = chunker.exec(formatStr))) {
1554 if (match[1]) { // a literal string inside [ ... ]
1555 chunks.push.apply(chunks, // append
1556 splitStringLiteral(match[1])
1557 );
1558 }
1559 else if (match[2]) { // non-zero formatting inside ( ... )
1560 chunks.push({ maybe: chunkFormatString(match[2]) });
1561 }
1562 else if (match[3]) { // a formatting token
1563 chunks.push({ token: match[3] });
1564 }
1565 else if (match[5]) { // an unenclosed literal string
1566 chunks.push.apply(chunks, // append
1567 splitStringLiteral(match[5])
1568 );
1569 }
1570 }
1571
1572 return chunks;
1573 }
1574
1575 /*
1576 Potentially splits a literal-text string into multiple parts. For special cases.
1577 */
1578 function splitStringLiteral(s) {
1579 if (s === '. ') {
1580 return [ '.', ' ' ]; // for locales with periods bound to the end of each year/month/date
1581 }
1582 else {
1583 return [ s ];
1584 }
1585 }
1586
1587 /*
1588 Given chunks parsed from a real format string, generate a fake (aka "intermediate") format string with special control
1589 characters that will eventually be given to moment for formatting, and then post-processed.
1590 */
1591 function buildFakeFormatString(chunks) {
1592 var parts = [];
1593 var i, chunk;
1594
1595 for (i = 0; i < chunks.length; i++) {
1596 chunk = chunks[i];
1597
1598 if (typeof chunk === 'string') {
1599 parts.push('[' + chunk + ']');
1600 }
1601 else if (chunk.token) {
1602 if (chunk.token in specialTokens) {
1603 parts.push(
1604 SPECIAL_TOKEN_MARKER + // useful during post-processing
1605 '[' + chunk.token + ']' // preserve as literal text
1606 );
1607 }
1608 else {
1609 parts.push(chunk.token); // unprotected text implies a format string
1610 }
1611 }
1612 else if (chunk.maybe) {
1613 parts.push(
1614 MAYBE_MARKER + // useful during post-processing
1615 buildFakeFormatString(chunk.maybe) +
1616 MAYBE_MARKER
1617 );
1618 }
1619 }
1620
1621 return parts.join(PART_SEPARATOR);
1622 }
1623
1624 /*
1625 Given parsed chunks from a real formatting string, generates an array of unit strings (like "day") that indicate
1626 in which regard two dates must be similar in order to share range formatting text.
1627 The `chunks` can be nested (because of "maybe" chunks), however, the returned array will be flat.
1628 */
1629 function buildSameUnits(chunks) {
1630 var units = [];
1631 var i, chunk;
1632 var tokenInfo;
1633
1634 for (i = 0; i < chunks.length; i++) {
1635 chunk = chunks[i];
1636
1637 if (chunk.token) {
1638 tokenInfo = largeTokenMap[chunk.token.charAt(0)];
1639 units.push(tokenInfo ? tokenInfo.unit : 'second'); // default to a very strict same-second
1640 }
1641 else if (chunk.maybe) {
1642 units.push.apply(units, // append
1643 buildSameUnits(chunk.maybe)
1644 );
1645 }
1646 else {
1647 units.push(null);
1648 }
1649 }
1650
1651 return units;
1652 }
1653
1654
1655 // Rendering to text
1656 // ---------------------------------------------------------------------------------------------------------------------
1657
1658 /*
1659 Formats a date with a fake format string, post-processes the control characters, then returns.
1660 */
1661 function renderFakeFormatString(fakeFormatString, date) {
1662 return processMaybeMarkers(
1663 renderFakeFormatStringParts(fakeFormatString, date).join('')
1664 );
1665 }
1666
1667 /*
1668 Formats a date into parts that will have been post-processed, EXCEPT for the "maybe" markers.
1669 */
1670 function renderFakeFormatStringParts(fakeFormatString, date) {
1671 var parts = [];
1672 var fakeRender = oldMomentFormat(date, fakeFormatString);
1673 var fakeParts = fakeRender.split(PART_SEPARATOR);
1674 var i, fakePart;
1675
1676 for (i = 0; i < fakeParts.length; i++) {
1677 fakePart = fakeParts[i];
1678
1679 if (fakePart.charAt(0) === SPECIAL_TOKEN_MARKER) {
1680 parts.push(
1681 // the literal string IS the token's name.
1682 // call special token's registered function.
1683 specialTokens[fakePart.substring(1)](date)
1684 );
1685 }
1686 else {
1687 parts.push(fakePart);
1688 }
1689 }
1690
1691 return parts;
1692 }
1693
1694 /*
1695 Accepts an almost-finally-formatted string and processes the "maybe" control characters, returning a new string.
1696 */
1697 function processMaybeMarkers(s) {
1698 return s.replace(MAYBE_REGEXP, function(m0, m1) { // regex assumed to have 'g' flag
1699 if (m1.match(/[1-9]/)) { // any non-zero numeric characters?
1700 return m1;
1701 }
1702 else {
1703 return '';
1704 }
1705 });
1706 }
1707
1708
1709 // Misc Utils
1710 // -------------------------------------------------------------------------------------------------
1711
1712 /*
1713 Returns a unit string, either 'year', 'month', 'day', or null for the most granular formatting token in the string.
1714 */
1715 function queryMostGranularFormatUnit(formatStr) {
1716 var chunks = chunkFormatString(formatStr);
1717 var i, chunk;
1718 var candidate;
1719 var best;
1720
1721 for (i = 0; i < chunks.length; i++) {
1722 chunk = chunks[i];
1723
1724 if (chunk.token) {
1725 candidate = largeTokenMap[chunk.token.charAt(0)];
1726 if (candidate) {
1727 if (!best || candidate.value > best.value) {
1728 best = candidate;
1729 }
1730 }
1731 }
1732 }
1733
1734 if (best) {
1735 return best.unit;
1736 }
1737
1738 return null;
1739 };
1740
1741 })();
1742
1743 // quick local references
1744 var formatDate = FC.formatDate;
1745 var formatRange = FC.formatRange;
1746 var oldMomentFormat = FC.oldMomentFormat;
1747
1748 ;;
1749
1750 FC.Class = Class; // export
1751
1752 // Class that all other classes will inherit from
1753 function Class() { }
1754
1755
1756 // Called on a class to create a subclass.
1757 // Last argument contains instance methods. Any argument before the last are considered mixins.
1758 Class.extend = function() {
1759 var len = arguments.length;
1760 var i;
1761 var members;
1762
1763 for (i = 0; i < len; i++) {
1764 members = arguments[i];
1765 if (i < len - 1) { // not the last argument?
1766 mixIntoClass(this, members);
1767 }
1768 }
1769
1770 return extendClass(this, members || {}); // members will be undefined if no arguments
1771 };
1772
1773
1774 // Adds new member variables/methods to the class's prototype.
1775 // Can be called with another class, or a plain object hash containing new members.
1776 Class.mixin = function(members) {
1777 mixIntoClass(this, members);
1778 };
1779
1780
1781 function extendClass(superClass, members) {
1782 var subClass;
1783
1784 // ensure a constructor for the subclass, forwarding all arguments to the super-constructor if it doesn't exist
1785 if (hasOwnProp(members, 'constructor')) {
1786 subClass = members.constructor;
1787 }
1788 if (typeof subClass !== 'function') {
1789 subClass = members.constructor = function() {
1790 superClass.apply(this, arguments);
1791 };
1792 }
1793
1794 // build the base prototype for the subclass, which is an new object chained to the superclass's prototype
1795 subClass.prototype = createObject(superClass.prototype);
1796
1797 // copy each member variable/method onto the the subclass's prototype
1798 copyOwnProps(members, subClass.prototype);
1799
1800 // copy over all class variables/methods to the subclass, such as `extend` and `mixin`
1801 copyOwnProps(superClass, subClass);
1802
1803 return subClass;
1804 }
1805
1806
1807 function mixIntoClass(theClass, members) {
1808 copyOwnProps(members, theClass.prototype);
1809 }
1810 ;;
1811
1812 /*
1813 Wrap jQuery's Deferred Promise object to be slightly more Promise/A+ compliant.
1814 With the added non-standard feature of synchronously executing handlers on resolved promises,
1815 which doesn't always happen otherwise (esp with nested .then handlers!?),
1816 so, this makes things a lot easier, esp because jQuery 3 changed the synchronicity for Deferred objects.
1817
1818 TODO: write tests and more comments
1819 */
1820
1821 function Promise(executor) {
1822 var deferred = $.Deferred();
1823 var promise = deferred.promise();
1824
1825 if (typeof executor === 'function') {
1826 executor(
1827 function(value) { // resolve
1828 if (Promise.immediate) {
1829 promise._value = value;
1830 }
1831 deferred.resolve(value);
1832 },
1833 function() { // reject
1834 deferred.reject();
1835 }
1836 );
1837 }
1838
1839 if (Promise.immediate) {
1840 var origThen = promise.then;
1841
1842 promise.then = function(onFulfilled, onRejected) {
1843 var state = promise.state();
1844
1845 if (state === 'resolved') {
1846 if (typeof onFulfilled === 'function') {
1847 return Promise.resolve(onFulfilled(promise._value));
1848 }
1849 }
1850 else if (state === 'rejected') {
1851 if (typeof onRejected === 'function') {
1852 onRejected();
1853 return promise; // already rejected
1854 }
1855 }
1856
1857 return origThen.call(promise, onFulfilled, onRejected);
1858 };
1859 }
1860
1861 return promise; // instanceof Promise will break :( TODO: make Promise a real class
1862 }
1863
1864 FC.Promise = Promise;
1865
1866 Promise.immediate = true;
1867
1868
1869 Promise.resolve = function(value) {
1870 if (value && typeof value.resolve === 'function') {
1871 return value.promise();
1872 }
1873 if (value && typeof value.then === 'function') {
1874 return value;
1875 }
1876 else {
1877 var deferred = $.Deferred().resolve(value);
1878 var promise = deferred.promise();
1879
1880 if (Promise.immediate) {
1881 var origThen = promise.then;
1882
1883 promise._value = value;
1884
1885 promise.then = function(onFulfilled, onRejected) {
1886 if (typeof onFulfilled === 'function') {
1887 return Promise.resolve(onFulfilled(value));
1888 }
1889 return origThen.call(promise, onFulfilled, onRejected);
1890 };
1891 }
1892
1893 return promise;
1894 }
1895 };
1896
1897
1898 Promise.reject = function() {
1899 return $.Deferred().reject().promise();
1900 };
1901
1902
1903 Promise.all = function(inputs) {
1904 var hasAllValues = false;
1905 var values;
1906 var i, input;
1907
1908 if (Promise.immediate) {
1909 hasAllValues = true;
1910 values = [];
1911
1912 for (i = 0; i < inputs.length; i++) {
1913 input = inputs[i];
1914
1915 if (input && typeof input.state === 'function' && input.state() === 'resolved' && ('_value' in input)) {
1916 values.push(input._value);
1917 }
1918 else if (input && typeof input.then === 'function') {
1919 hasAllValues = false;
1920 break;
1921 }
1922 else {
1923 values.push(input);
1924 }
1925 }
1926 }
1927
1928 if (hasAllValues) {
1929 return Promise.resolve(values);
1930 }
1931 else {
1932 return $.when.apply($.when, inputs).then(function() {
1933 return $.when($.makeArray(arguments));
1934 });
1935 }
1936 };
1937
1938 ;;
1939
1940 // TODO: write tests and clean up code
1941
1942 function TaskQueue(debounceWait) {
1943 var q = []; // array of runFuncs
1944
1945 function addTask(taskFunc) {
1946 return new Promise(function(resolve) {
1947
1948 // should run this function when it's taskFunc's turn to run.
1949 // responsible for popping itself off the queue.
1950 var runFunc = function() {
1951 Promise.resolve(taskFunc()) // result might be async, coerce to promise
1952 .then(resolve) // resolve TaskQueue::push's promise, for the caller. will receive result of taskFunc.
1953 .then(function() {
1954 q.shift(); // pop itself off
1955
1956 // run the next task, if any
1957 if (q.length) {
1958 q[0]();
1959 }
1960 });
1961 };
1962
1963 // always put the task at the end of the queue, BEFORE running the task
1964 q.push(runFunc);
1965
1966 // if it's the only task in the queue, run immediately
1967 if (q.length === 1) {
1968 runFunc();
1969 }
1970 });
1971 }
1972
1973 this.add = // potentially debounce, for the public method
1974 typeof debounceWait === 'number' ?
1975 debounce(addTask, debounceWait) :
1976 addTask; // if not a number (null/undefined/false), no debounce at all
1977
1978 this.addQuickly = addTask; // guaranteed no debounce
1979 }
1980
1981 FC.TaskQueue = TaskQueue;
1982
1983 /*
1984 q = new TaskQueue();
1985
1986 function work(i) {
1987 return q.push(function() {
1988 trigger();
1989 console.log('work' + i);
1990 });
1991 }
1992
1993 var cnt = 0;
1994
1995 function trigger() {
1996 if (cnt < 5) {
1997 cnt++;
1998 work(cnt);
1999 }
2000 }
2001
2002 work(9);
2003 */
2004
2005 ;;
2006
2007 var EmitterMixin = FC.EmitterMixin = {
2008
2009 // jQuery-ification via $(this) allows a non-DOM object to have
2010 // the same event handling capabilities (including namespaces).
2011
2012
2013 on: function(types, handler) {
2014 $(this).on(types, this._prepareIntercept(handler));
2015 return this; // for chaining
2016 },
2017
2018
2019 one: function(types, handler) {
2020 $(this).one(types, this._prepareIntercept(handler));
2021 return this; // for chaining
2022 },
2023
2024
2025 _prepareIntercept: function(handler) {
2026 // handlers are always called with an "event" object as their first param.
2027 // sneak the `this` context and arguments into the extra parameter object
2028 // and forward them on to the original handler.
2029 var intercept = function(ev, extra) {
2030 return handler.apply(
2031 extra.context || this,
2032 extra.args || []
2033 );
2034 };
2035
2036 // mimick jQuery's internal "proxy" system (risky, I know)
2037 // causing all functions with the same .guid to appear to be the same.
2038 // https://github.com/jquery/jquery/blob/2.2.4/src/core.js#L448
2039 // this is needed for calling .off with the original non-intercept handler.
2040 if (!handler.guid) {
2041 handler.guid = $.guid++;
2042 }
2043 intercept.guid = handler.guid;
2044
2045 return intercept;
2046 },
2047
2048
2049 off: function(types, handler) {
2050 $(this).off(types, handler);
2051
2052 return this; // for chaining
2053 },
2054
2055
2056 trigger: function(types) {
2057 var args = Array.prototype.slice.call(arguments, 1); // arguments after the first
2058
2059 // pass in "extra" info to the intercept
2060 $(this).triggerHandler(types, { args: args });
2061
2062 return this; // for chaining
2063 },
2064
2065
2066 triggerWith: function(types, context, args) {
2067
2068 // `triggerHandler` is less reliant on the DOM compared to `trigger`.
2069 // pass in "extra" info to the intercept.
2070 $(this).triggerHandler(types, { context: context, args: args });
2071
2072 return this; // for chaining
2073 }
2074
2075 };
2076
2077 ;;
2078
2079 /*
2080 Utility methods for easily listening to events on another object,
2081 and more importantly, easily unlistening from them.
2082 */
2083 var ListenerMixin = FC.ListenerMixin = (function() {
2084 var guid = 0;
2085 var ListenerMixin = {
2086
2087 listenerId: null,
2088
2089 /*
2090 Given an `other` object that has on/off methods, bind the given `callback` to an event by the given name.
2091 The `callback` will be called with the `this` context of the object that .listenTo is being called on.
2092 Can be called:
2093 .listenTo(other, eventName, callback)
2094 OR
2095 .listenTo(other, {
2096 eventName1: callback1,
2097 eventName2: callback2
2098 })
2099 */
2100 listenTo: function(other, arg, callback) {
2101 if (typeof arg === 'object') { // given dictionary of callbacks
2102 for (var eventName in arg) {
2103 if (arg.hasOwnProperty(eventName)) {
2104 this.listenTo(other, eventName, arg[eventName]);
2105 }
2106 }
2107 }
2108 else if (typeof arg === 'string') {
2109 other.on(
2110 arg + '.' + this.getListenerNamespace(), // use event namespacing to identify this object
2111 $.proxy(callback, this) // always use `this` context
2112 // the usually-undesired jQuery guid behavior doesn't matter,
2113 // because we always unbind via namespace
2114 );
2115 }
2116 },
2117
2118 /*
2119 Causes the current object to stop listening to events on the `other` object.
2120 `eventName` is optional. If omitted, will stop listening to ALL events on `other`.
2121 */
2122 stopListeningTo: function(other, eventName) {
2123 other.off((eventName || '') + '.' + this.getListenerNamespace());
2124 },
2125
2126 /*
2127 Returns a string, unique to this object, to be used for event namespacing
2128 */
2129 getListenerNamespace: function() {
2130 if (this.listenerId == null) {
2131 this.listenerId = guid++;
2132 }
2133 return '_listener' + this.listenerId;
2134 }
2135
2136 };
2137 return ListenerMixin;
2138 })();
2139 ;;
2140
2141 /* A rectangular panel that is absolutely positioned over other content
2142 ------------------------------------------------------------------------------------------------------------------------
2143 Options:
2144 - className (string)
2145 - content (HTML string or jQuery element set)
2146 - parentEl
2147 - top
2148 - left
2149 - right (the x coord of where the right edge should be. not a "CSS" right)
2150 - autoHide (boolean)
2151 - show (callback)
2152 - hide (callback)
2153 */
2154
2155 var Popover = Class.extend(ListenerMixin, {
2156
2157 isHidden: true,
2158 options: null,
2159 el: null, // the container element for the popover. generated by this object
2160 margin: 10, // the space required between the popover and the edges of the scroll container
2161
2162
2163 constructor: function(options) {
2164 this.options = options || {};
2165 },
2166
2167
2168 // Shows the popover on the specified position. Renders it if not already
2169 show: function() {
2170 if (this.isHidden) {
2171 if (!this.el) {
2172 this.render();
2173 }
2174 this.el.show();
2175 this.position();
2176 this.isHidden = false;
2177 this.trigger('show');
2178 }
2179 },
2180
2181
2182 // Hides the popover, through CSS, but does not remove it from the DOM
2183 hide: function() {
2184 if (!this.isHidden) {
2185 this.el.hide();
2186 this.isHidden = true;
2187 this.trigger('hide');
2188 }
2189 },
2190
2191
2192 // Creates `this.el` and renders content inside of it
2193 render: function() {
2194 var _this = this;
2195 var options = this.options;
2196
2197 this.el = $('<div class="fc-popover"/>')
2198 .addClass(options.className || '')
2199 .css({
2200 // position initially to the top left to avoid creating scrollbars
2201 top: 0,
2202 left: 0
2203 })
2204 .append(options.content)
2205 .appendTo(options.parentEl);
2206
2207 // when a click happens on anything inside with a 'fc-close' className, hide the popover
2208 this.el.on('click', '.fc-close', function() {
2209 _this.hide();
2210 });
2211
2212 if (options.autoHide) {
2213 this.listenTo($(document), 'mousedown', this.documentMousedown);
2214 }
2215 },
2216
2217
2218 // Triggered when the user clicks *anywhere* in the document, for the autoHide feature
2219 documentMousedown: function(ev) {
2220 // only hide the popover if the click happened outside the popover
2221 if (this.el && !$(ev.target).closest(this.el).length) {
2222 this.hide();
2223 }
2224 },
2225
2226
2227 // Hides and unregisters any handlers
2228 removeElement: function() {
2229 this.hide();
2230
2231 if (this.el) {
2232 this.el.remove();
2233 this.el = null;
2234 }
2235
2236 this.stopListeningTo($(document), 'mousedown');
2237 },
2238
2239
2240 // Positions the popover optimally, using the top/left/right options
2241 position: function() {
2242 var options = this.options;
2243 var origin = this.el.offsetParent().offset();
2244 var width = this.el.outerWidth();
2245 var height = this.el.outerHeight();
2246 var windowEl = $(window);
2247 var viewportEl = getScrollParent(this.el);
2248 var viewportTop;
2249 var viewportLeft;
2250 var viewportOffset;
2251 var top; // the "position" (not "offset") values for the popover
2252 var left; //
2253
2254 // compute top and left
2255 top = options.top || 0;
2256 if (options.left !== undefined) {
2257 left = options.left;
2258 }
2259 else if (options.right !== undefined) {
2260 left = options.right - width; // derive the left value from the right value
2261 }
2262 else {
2263 left = 0;
2264 }
2265
2266 if (viewportEl.is(window) || viewportEl.is(document)) { // normalize getScrollParent's result
2267 viewportEl = windowEl;
2268 viewportTop = 0; // the window is always at the top left
2269 viewportLeft = 0; // (and .offset() won't work if called here)
2270 }
2271 else {
2272 viewportOffset = viewportEl.offset();
2273 viewportTop = viewportOffset.top;
2274 viewportLeft = viewportOffset.left;
2275 }
2276
2277 // if the window is scrolled, it causes the visible area to be further down
2278 viewportTop += windowEl.scrollTop();
2279 viewportLeft += windowEl.scrollLeft();
2280
2281 // constrain to the view port. if constrained by two edges, give precedence to top/left
2282 if (options.viewportConstrain !== false) {
2283 top = Math.min(top, viewportTop + viewportEl.outerHeight() - height - this.margin);
2284 top = Math.max(top, viewportTop + this.margin);
2285 left = Math.min(left, viewportLeft + viewportEl.outerWidth() - width - this.margin);
2286 left = Math.max(left, viewportLeft + this.margin);
2287 }
2288
2289 this.el.css({
2290 top: top - origin.top,
2291 left: left - origin.left
2292 });
2293 },
2294
2295
2296 // Triggers a callback. Calls a function in the option hash of the same name.
2297 // Arguments beyond the first `name` are forwarded on.
2298 // TODO: better code reuse for this. Repeat code
2299 trigger: function(name) {
2300 if (this.options[name]) {
2301 this.options[name].apply(this, Array.prototype.slice.call(arguments, 1));
2302 }
2303 }
2304
2305 });
2306
2307 ;;
2308
2309 /*
2310 A cache for the left/right/top/bottom/width/height values for one or more elements.
2311 Works with both offset (from topleft document) and position (from offsetParent).
2312
2313 options:
2314 - els
2315 - isHorizontal
2316 - isVertical
2317 */
2318 var CoordCache = FC.CoordCache = Class.extend({
2319
2320 els: null, // jQuery set (assumed to be siblings)
2321 forcedOffsetParentEl: null, // options can override the natural offsetParent
2322 origin: null, // {left,top} position of offsetParent of els
2323 boundingRect: null, // constrain cordinates to this rectangle. {left,right,top,bottom} or null
2324 isHorizontal: false, // whether to query for left/right/width
2325 isVertical: false, // whether to query for top/bottom/height
2326
2327 // arrays of coordinates (offsets from topleft of document)
2328 lefts: null,
2329 rights: null,
2330 tops: null,
2331 bottoms: null,
2332
2333
2334 constructor: function(options) {
2335 this.els = $(options.els);
2336 this.isHorizontal = options.isHorizontal;
2337 this.isVertical = options.isVertical;
2338 this.forcedOffsetParentEl = options.offsetParent ? $(options.offsetParent) : null;
2339 },
2340
2341
2342 // Queries the els for coordinates and stores them.
2343 // Call this method before using and of the get* methods below.
2344 build: function() {
2345 var offsetParentEl = this.forcedOffsetParentEl;
2346 if (!offsetParentEl && this.els.length > 0) {
2347 offsetParentEl = this.els.eq(0).offsetParent();
2348 }
2349
2350 this.origin = offsetParentEl ?
2351 offsetParentEl.offset() :
2352 null;
2353
2354 this.boundingRect = this.queryBoundingRect();
2355
2356 if (this.isHorizontal) {
2357 this.buildElHorizontals();
2358 }
2359 if (this.isVertical) {
2360 this.buildElVerticals();
2361 }
2362 },
2363
2364
2365 // Destroys all internal data about coordinates, freeing memory
2366 clear: function() {
2367 this.origin = null;
2368 this.boundingRect = null;
2369 this.lefts = null;
2370 this.rights = null;
2371 this.tops = null;
2372 this.bottoms = null;
2373 },
2374
2375
2376 // When called, if coord caches aren't built, builds them
2377 ensureBuilt: function() {
2378 if (!this.origin) {
2379 this.build();
2380 }
2381 },
2382
2383
2384 // Populates the left/right internal coordinate arrays
2385 buildElHorizontals: function() {
2386 var lefts = [];
2387 var rights = [];
2388
2389 this.els.each(function(i, node) {
2390 var el = $(node);
2391 var left = el.offset().left;
2392 var width = el.outerWidth();
2393
2394 lefts.push(left);
2395 rights.push(left + width);
2396 });
2397
2398 this.lefts = lefts;
2399 this.rights = rights;
2400 },
2401
2402
2403 // Populates the top/bottom internal coordinate arrays
2404 buildElVerticals: function() {
2405 var tops = [];
2406 var bottoms = [];
2407
2408 this.els.each(function(i, node) {
2409 var el = $(node);
2410 var top = el.offset().top;
2411 var height = el.outerHeight();
2412
2413 tops.push(top);
2414 bottoms.push(top + height);
2415 });
2416
2417 this.tops = tops;
2418 this.bottoms = bottoms;
2419 },
2420
2421
2422 // Given a left offset (from document left), returns the index of the el that it horizontally intersects.
2423 // If no intersection is made, returns undefined.
2424 getHorizontalIndex: function(leftOffset) {
2425 this.ensureBuilt();
2426
2427 var lefts = this.lefts;
2428 var rights = this.rights;
2429 var len = lefts.length;
2430 var i;
2431
2432 for (i = 0; i < len; i++) {
2433 if (leftOffset >= lefts[i] && leftOffset < rights[i]) {
2434 return i;
2435 }
2436 }
2437 },
2438
2439
2440 // Given a top offset (from document top), returns the index of the el that it vertically intersects.
2441 // If no intersection is made, returns undefined.
2442 getVerticalIndex: function(topOffset) {
2443 this.ensureBuilt();
2444
2445 var tops = this.tops;
2446 var bottoms = this.bottoms;
2447 var len = tops.length;
2448 var i;
2449
2450 for (i = 0; i < len; i++) {
2451 if (topOffset >= tops[i] && topOffset < bottoms[i]) {
2452 return i;
2453 }
2454 }
2455 },
2456
2457
2458 // Gets the left offset (from document left) of the element at the given index
2459 getLeftOffset: function(leftIndex) {
2460 this.ensureBuilt();
2461 return this.lefts[leftIndex];
2462 },
2463
2464
2465 // Gets the left position (from offsetParent left) of the element at the given index
2466 getLeftPosition: function(leftIndex) {
2467 this.ensureBuilt();
2468 return this.lefts[leftIndex] - this.origin.left;
2469 },
2470
2471
2472 // Gets the right offset (from document left) of the element at the given index.
2473 // This value is NOT relative to the document's right edge, like the CSS concept of "right" would be.
2474 getRightOffset: function(leftIndex) {
2475 this.ensureBuilt();
2476 return this.rights[leftIndex];
2477 },
2478
2479
2480 // Gets the right position (from offsetParent left) of the element at the given index.
2481 // This value is NOT relative to the offsetParent's right edge, like the CSS concept of "right" would be.
2482 getRightPosition: function(leftIndex) {
2483 this.ensureBuilt();
2484 return this.rights[leftIndex] - this.origin.left;
2485 },
2486
2487
2488 // Gets the width of the element at the given index
2489 getWidth: function(leftIndex) {
2490 this.ensureBuilt();
2491 return this.rights[leftIndex] - this.lefts[leftIndex];
2492 },
2493
2494
2495 // Gets the top offset (from document top) of the element at the given index
2496 getTopOffset: function(topIndex) {
2497 this.ensureBuilt();
2498 return this.tops[topIndex];
2499 },
2500
2501
2502 // Gets the top position (from offsetParent top) of the element at the given position
2503 getTopPosition: function(topIndex) {
2504 this.ensureBuilt();
2505 return this.tops[topIndex] - this.origin.top;
2506 },
2507
2508 // Gets the bottom offset (from the document top) of the element at the given index.
2509 // This value is NOT relative to the offsetParent's bottom edge, like the CSS concept of "bottom" would be.
2510 getBottomOffset: function(topIndex) {
2511 this.ensureBuilt();
2512 return this.bottoms[topIndex];
2513 },
2514
2515
2516 // Gets the bottom position (from the offsetParent top) of the element at the given index.
2517 // This value is NOT relative to the offsetParent's bottom edge, like the CSS concept of "bottom" would be.
2518 getBottomPosition: function(topIndex) {
2519 this.ensureBuilt();
2520 return this.bottoms[topIndex] - this.origin.top;
2521 },
2522
2523
2524 // Gets the height of the element at the given index
2525 getHeight: function(topIndex) {
2526 this.ensureBuilt();
2527 return this.bottoms[topIndex] - this.tops[topIndex];
2528 },
2529
2530
2531 // Bounding Rect
2532 // TODO: decouple this from CoordCache
2533
2534 // Compute and return what the elements' bounding rectangle is, from the user's perspective.
2535 // Right now, only returns a rectangle if constrained by an overflow:scroll element.
2536 // Returns null if there are no elements
2537 queryBoundingRect: function() {
2538 var scrollParentEl;
2539
2540 if (this.els.length > 0) {
2541 scrollParentEl = getScrollParent(this.els.eq(0));
2542
2543 if (!scrollParentEl.is(document)) {
2544 return getClientRect(scrollParentEl);
2545 }
2546 }
2547
2548 return null;
2549 },
2550
2551 isPointInBounds: function(leftOffset, topOffset) {
2552 return this.isLeftInBounds(leftOffset) && this.isTopInBounds(topOffset);
2553 },
2554
2555 isLeftInBounds: function(leftOffset) {
2556 return !this.boundingRect || (leftOffset >= this.boundingRect.left && leftOffset < this.boundingRect.right);
2557 },
2558
2559 isTopInBounds: function(topOffset) {
2560 return !this.boundingRect || (topOffset >= this.boundingRect.top && topOffset < this.boundingRect.bottom);
2561 }
2562
2563 });
2564
2565 ;;
2566
2567 /* Tracks a drag's mouse movement, firing various handlers
2568 ----------------------------------------------------------------------------------------------------------------------*/
2569 // TODO: use Emitter
2570
2571 var DragListener = FC.DragListener = Class.extend(ListenerMixin, {
2572
2573 options: null,
2574 subjectEl: null,
2575
2576 // coordinates of the initial mousedown
2577 originX: null,
2578 originY: null,
2579
2580 // the wrapping element that scrolls, or MIGHT scroll if there's overflow.
2581 // TODO: do this for wrappers that have overflow:hidden as well.
2582 scrollEl: null,
2583
2584 isInteracting: false,
2585 isDistanceSurpassed: false,
2586 isDelayEnded: false,
2587 isDragging: false,
2588 isTouch: false,
2589
2590 delay: null,
2591 delayTimeoutId: null,
2592 minDistance: null,
2593
2594 shouldCancelTouchScroll: true,
2595 scrollAlwaysKills: false,
2596
2597
2598 constructor: function(options) {
2599 this.options = options || {};
2600 },
2601
2602
2603 // Interaction (high-level)
2604 // -----------------------------------------------------------------------------------------------------------------
2605
2606
2607 startInteraction: function(ev, extraOptions) {
2608 var isTouch = getEvIsTouch(ev);
2609
2610 if (ev.type === 'mousedown') {
2611 if (GlobalEmitter.get().shouldIgnoreMouse()) {
2612 return;
2613 }
2614 else if (!isPrimaryMouseButton(ev)) {
2615 return;
2616 }
2617 else {
2618 ev.preventDefault(); // prevents native selection in most browsers
2619 }
2620 }
2621
2622 if (!this.isInteracting) {
2623
2624 // process options
2625 extraOptions = extraOptions || {};
2626 this.delay = firstDefined(extraOptions.delay, this.options.delay, 0);
2627 this.minDistance = firstDefined(extraOptions.distance, this.options.distance, 0);
2628 this.subjectEl = this.options.subjectEl;
2629
2630 preventSelection($('body'));
2631
2632 this.isInteracting = true;
2633 this.isTouch = isTouch;
2634 this.isDelayEnded = false;
2635 this.isDistanceSurpassed = false;
2636
2637 this.originX = getEvX(ev);
2638 this.originY = getEvY(ev);
2639 this.scrollEl = getScrollParent($(ev.target));
2640
2641 this.bindHandlers();
2642 this.initAutoScroll();
2643 this.handleInteractionStart(ev);
2644 this.startDelay(ev);
2645
2646 if (!this.minDistance) {
2647 this.handleDistanceSurpassed(ev);
2648 }
2649 }
2650 },
2651
2652
2653 handleInteractionStart: function(ev) {
2654 this.trigger('interactionStart', ev);
2655 },
2656
2657
2658 endInteraction: function(ev, isCancelled) {
2659 if (this.isInteracting) {
2660 this.endDrag(ev);
2661
2662 if (this.delayTimeoutId) {
2663 clearTimeout(this.delayTimeoutId);
2664 this.delayTimeoutId = null;
2665 }
2666
2667 this.destroyAutoScroll();
2668 this.unbindHandlers();
2669
2670 this.isInteracting = false;
2671 this.handleInteractionEnd(ev, isCancelled);
2672
2673 allowSelection($('body'));
2674 }
2675 },
2676
2677
2678 handleInteractionEnd: function(ev, isCancelled) {
2679 this.trigger('interactionEnd', ev, isCancelled || false);
2680 },
2681
2682
2683 // Binding To DOM
2684 // -----------------------------------------------------------------------------------------------------------------
2685
2686
2687 bindHandlers: function() {
2688 // some browsers (Safari in iOS 10) don't allow preventDefault on touch events that are bound after touchstart,
2689 // so listen to the GlobalEmitter singleton, which is always bound, instead of the document directly.
2690 var globalEmitter = GlobalEmitter.get();
2691
2692 if (this.isTouch) {
2693 this.listenTo(globalEmitter, {
2694 touchmove: this.handleTouchMove,
2695 touchend: this.endInteraction,
2696 scroll: this.handleTouchScroll
2697 });
2698 }
2699 else {
2700 this.listenTo(globalEmitter, {
2701 mousemove: this.handleMouseMove,
2702 mouseup: this.endInteraction
2703 });
2704 }
2705
2706 this.listenTo(globalEmitter, {
2707 selectstart: preventDefault, // don't allow selection while dragging
2708 contextmenu: preventDefault // long taps would open menu on Chrome dev tools
2709 });
2710 },
2711
2712
2713 unbindHandlers: function() {
2714 this.stopListeningTo(GlobalEmitter.get());
2715 },
2716
2717
2718 // Drag (high-level)
2719 // -----------------------------------------------------------------------------------------------------------------
2720
2721
2722 // extraOptions ignored if drag already started
2723 startDrag: function(ev, extraOptions) {
2724 this.startInteraction(ev, extraOptions); // ensure interaction began
2725
2726 if (!this.isDragging) {
2727 this.isDragging = true;
2728 this.handleDragStart(ev);
2729 }
2730 },
2731
2732
2733 handleDragStart: function(ev) {
2734 this.trigger('dragStart', ev);
2735 },
2736
2737
2738 handleMove: function(ev) {
2739 var dx = getEvX(ev) - this.originX;
2740 var dy = getEvY(ev) - this.originY;
2741 var minDistance = this.minDistance;
2742 var distanceSq; // current distance from the origin, squared
2743
2744 if (!this.isDistanceSurpassed) {
2745 distanceSq = dx * dx + dy * dy;
2746 if (distanceSq >= minDistance * minDistance) { // use pythagorean theorem
2747 this.handleDistanceSurpassed(ev);
2748 }
2749 }
2750
2751 if (this.isDragging) {
2752 this.handleDrag(dx, dy, ev);
2753 }
2754 },
2755
2756
2757 // Called while the mouse is being moved and when we know a legitimate drag is taking place
2758 handleDrag: function(dx, dy, ev) {
2759 this.trigger('drag', dx, dy, ev);
2760 this.updateAutoScroll(ev); // will possibly cause scrolling
2761 },
2762
2763
2764 endDrag: function(ev) {
2765 if (this.isDragging) {
2766 this.isDragging = false;
2767 this.handleDragEnd(ev);
2768 }
2769 },
2770
2771
2772 handleDragEnd: function(ev) {
2773 this.trigger('dragEnd', ev);
2774 },
2775
2776
2777 // Delay
2778 // -----------------------------------------------------------------------------------------------------------------
2779
2780
2781 startDelay: function(initialEv) {
2782 var _this = this;
2783
2784 if (this.delay) {
2785 this.delayTimeoutId = setTimeout(function() {
2786 _this.handleDelayEnd(initialEv);
2787 }, this.delay);
2788 }
2789 else {
2790 this.handleDelayEnd(initialEv);
2791 }
2792 },
2793
2794
2795 handleDelayEnd: function(initialEv) {
2796 this.isDelayEnded = true;
2797
2798 if (this.isDistanceSurpassed) {
2799 this.startDrag(initialEv);
2800 }
2801 },
2802
2803
2804 // Distance
2805 // -----------------------------------------------------------------------------------------------------------------
2806
2807
2808 handleDistanceSurpassed: function(ev) {
2809 this.isDistanceSurpassed = true;
2810
2811 if (this.isDelayEnded) {
2812 this.startDrag(ev);
2813 }
2814 },
2815
2816
2817 // Mouse / Touch
2818 // -----------------------------------------------------------------------------------------------------------------
2819
2820
2821 handleTouchMove: function(ev) {
2822
2823 // prevent inertia and touchmove-scrolling while dragging
2824 if (this.isDragging && this.shouldCancelTouchScroll) {
2825 ev.preventDefault();
2826 }
2827
2828 this.handleMove(ev);
2829 },
2830
2831
2832 handleMouseMove: function(ev) {
2833 this.handleMove(ev);
2834 },
2835
2836
2837 // Scrolling (unrelated to auto-scroll)
2838 // -----------------------------------------------------------------------------------------------------------------
2839
2840
2841 handleTouchScroll: function(ev) {
2842 // if the drag is being initiated by touch, but a scroll happens before
2843 // the drag-initiating delay is over, cancel the drag
2844 if (!this.isDragging || this.scrollAlwaysKills) {
2845 this.endInteraction(ev, true); // isCancelled=true
2846 }
2847 },
2848
2849
2850 // Utils
2851 // -----------------------------------------------------------------------------------------------------------------
2852
2853
2854 // Triggers a callback. Calls a function in the option hash of the same name.
2855 // Arguments beyond the first `name` are forwarded on.
2856 trigger: function(name) {
2857 if (this.options[name]) {
2858 this.options[name].apply(this, Array.prototype.slice.call(arguments, 1));
2859 }
2860 // makes _methods callable by event name. TODO: kill this
2861 if (this['_' + name]) {
2862 this['_' + name].apply(this, Array.prototype.slice.call(arguments, 1));
2863 }
2864 }
2865
2866
2867 });
2868
2869 ;;
2870 /*
2871 this.scrollEl is set in DragListener
2872 */
2873 DragListener.mixin({
2874
2875 isAutoScroll: false,
2876
2877 scrollBounds: null, // { top, bottom, left, right }
2878 scrollTopVel: null, // pixels per second
2879 scrollLeftVel: null, // pixels per second
2880 scrollIntervalId: null, // ID of setTimeout for scrolling animation loop
2881
2882 // defaults
2883 scrollSensitivity: 30, // pixels from edge for scrolling to start
2884 scrollSpeed: 200, // pixels per second, at maximum speed
2885 scrollIntervalMs: 50, // millisecond wait between scroll increment
2886
2887
2888 initAutoScroll: function() {
2889 var scrollEl = this.scrollEl;
2890
2891 this.isAutoScroll =
2892 this.options.scroll &&
2893 scrollEl &&
2894 !scrollEl.is(window) &&
2895 !scrollEl.is(document);
2896
2897 if (this.isAutoScroll) {
2898 // debounce makes sure rapid calls don't happen
2899 this.listenTo(scrollEl, 'scroll', debounce(this.handleDebouncedScroll, 100));
2900 }
2901 },
2902
2903
2904 destroyAutoScroll: function() {
2905 this.endAutoScroll(); // kill any animation loop
2906
2907 // remove the scroll handler if there is a scrollEl
2908 if (this.isAutoScroll) {
2909 this.stopListeningTo(this.scrollEl, 'scroll'); // will probably get removed by unbindHandlers too :(
2910 }
2911 },
2912
2913
2914 // Computes and stores the bounding rectangle of scrollEl
2915 computeScrollBounds: function() {
2916 if (this.isAutoScroll) {
2917 this.scrollBounds = getOuterRect(this.scrollEl);
2918 // TODO: use getClientRect in future. but prevents auto scrolling when on top of scrollbars
2919 }
2920 },
2921
2922
2923 // Called when the dragging is in progress and scrolling should be updated
2924 updateAutoScroll: function(ev) {
2925 var sensitivity = this.scrollSensitivity;
2926 var bounds = this.scrollBounds;
2927 var topCloseness, bottomCloseness;
2928 var leftCloseness, rightCloseness;
2929 var topVel = 0;
2930 var leftVel = 0;
2931
2932 if (bounds) { // only scroll if scrollEl exists
2933
2934 // compute closeness to edges. valid range is from 0.0 - 1.0
2935 topCloseness = (sensitivity - (getEvY(ev) - bounds.top)) / sensitivity;
2936 bottomCloseness = (sensitivity - (bounds.bottom - getEvY(ev))) / sensitivity;
2937 leftCloseness = (sensitivity - (getEvX(ev) - bounds.left)) / sensitivity;
2938 rightCloseness = (sensitivity - (bounds.right - getEvX(ev))) / sensitivity;
2939
2940 // translate vertical closeness into velocity.
2941 // mouse must be completely in bounds for velocity to happen.
2942 if (topCloseness >= 0 && topCloseness <= 1) {
2943 topVel = topCloseness * this.scrollSpeed * -1; // negative. for scrolling up
2944 }
2945 else if (bottomCloseness >= 0 && bottomCloseness <= 1) {
2946 topVel = bottomCloseness * this.scrollSpeed;
2947 }
2948
2949 // translate horizontal closeness into velocity
2950 if (leftCloseness >= 0 && leftCloseness <= 1) {
2951 leftVel = leftCloseness * this.scrollSpeed * -1; // negative. for scrolling left
2952 }
2953 else if (rightCloseness >= 0 && rightCloseness <= 1) {
2954 leftVel = rightCloseness * this.scrollSpeed;
2955 }
2956 }
2957
2958 this.setScrollVel(topVel, leftVel);
2959 },
2960
2961
2962 // Sets the speed-of-scrolling for the scrollEl
2963 setScrollVel: function(topVel, leftVel) {
2964
2965 this.scrollTopVel = topVel;
2966 this.scrollLeftVel = leftVel;
2967
2968 this.constrainScrollVel(); // massages into realistic values
2969
2970 // if there is non-zero velocity, and an animation loop hasn't already started, then START
2971 if ((this.scrollTopVel || this.scrollLeftVel) && !this.scrollIntervalId) {
2972 this.scrollIntervalId = setInterval(
2973 proxy(this, 'scrollIntervalFunc'), // scope to `this`
2974 this.scrollIntervalMs
2975 );
2976 }
2977 },
2978
2979
2980 // Forces scrollTopVel and scrollLeftVel to be zero if scrolling has already gone all the way
2981 constrainScrollVel: function() {
2982 var el = this.scrollEl;
2983
2984 if (this.scrollTopVel < 0) { // scrolling up?
2985 if (el.scrollTop() <= 0) { // already scrolled all the way up?
2986 this.scrollTopVel = 0;
2987 }
2988 }
2989 else if (this.scrollTopVel > 0) { // scrolling down?
2990 if (el.scrollTop() + el[0].clientHeight >= el[0].scrollHeight) { // already scrolled all the way down?
2991 this.scrollTopVel = 0;
2992 }
2993 }
2994
2995 if (this.scrollLeftVel < 0) { // scrolling left?
2996 if (el.scrollLeft() <= 0) { // already scrolled all the left?
2997 this.scrollLeftVel = 0;
2998 }
2999 }
3000 else if (this.scrollLeftVel > 0) { // scrolling right?
3001 if (el.scrollLeft() + el[0].clientWidth >= el[0].scrollWidth) { // already scrolled all the way right?
3002 this.scrollLeftVel = 0;
3003 }
3004 }
3005 },
3006
3007
3008 // This function gets called during every iteration of the scrolling animation loop
3009 scrollIntervalFunc: function() {
3010 var el = this.scrollEl;
3011 var frac = this.scrollIntervalMs / 1000; // considering animation frequency, what the vel should be mult'd by
3012
3013 // change the value of scrollEl's scroll
3014 if (this.scrollTopVel) {
3015 el.scrollTop(el.scrollTop() + this.scrollTopVel * frac);
3016 }
3017 if (this.scrollLeftVel) {
3018 el.scrollLeft(el.scrollLeft() + this.scrollLeftVel * frac);
3019 }
3020
3021 this.constrainScrollVel(); // since the scroll values changed, recompute the velocities
3022
3023 // if scrolled all the way, which causes the vels to be zero, stop the animation loop
3024 if (!this.scrollTopVel && !this.scrollLeftVel) {
3025 this.endAutoScroll();
3026 }
3027 },
3028
3029
3030 // Kills any existing scrolling animation loop
3031 endAutoScroll: function() {
3032 if (this.scrollIntervalId) {
3033 clearInterval(this.scrollIntervalId);
3034 this.scrollIntervalId = null;
3035
3036 this.handleScrollEnd();
3037 }
3038 },
3039
3040
3041 // Get called when the scrollEl is scrolled (NOTE: this is delayed via debounce)
3042 handleDebouncedScroll: function() {
3043 // recompute all coordinates, but *only* if this is *not* part of our scrolling animation
3044 if (!this.scrollIntervalId) {
3045 this.handleScrollEnd();
3046 }
3047 },
3048
3049
3050 // Called when scrolling has stopped, whether through auto scroll, or the user scrolling
3051 handleScrollEnd: function() {
3052 }
3053
3054 });
3055 ;;
3056
3057 /* Tracks mouse movements over a component and raises events about which hit the mouse is over.
3058 ------------------------------------------------------------------------------------------------------------------------
3059 options:
3060 - subjectEl
3061 - subjectCenter
3062 */
3063
3064 var HitDragListener = DragListener.extend({
3065
3066 component: null, // converts coordinates to hits
3067 // methods: hitsNeeded, hitsNotNeeded, queryHit
3068
3069 origHit: null, // the hit the mouse was over when listening started
3070 hit: null, // the hit the mouse is over
3071 coordAdjust: null, // delta that will be added to the mouse coordinates when computing collisions
3072
3073
3074 constructor: function(component, options) {
3075 DragListener.call(this, options); // call the super-constructor
3076
3077 this.component = component;
3078 },
3079
3080
3081 // Called when drag listening starts (but a real drag has not necessarily began).
3082 // ev might be undefined if dragging was started manually.
3083 handleInteractionStart: function(ev) {
3084 var subjectEl = this.subjectEl;
3085 var subjectRect;
3086 var origPoint;
3087 var point;
3088
3089 this.component.hitsNeeded();
3090 this.computeScrollBounds(); // for autoscroll
3091
3092 if (ev) {
3093 origPoint = { left: getEvX(ev), top: getEvY(ev) };
3094 point = origPoint;
3095
3096 // constrain the point to bounds of the element being dragged
3097 if (subjectEl) {
3098 subjectRect = getOuterRect(subjectEl); // used for centering as well
3099 point = constrainPoint(point, subjectRect);
3100 }
3101
3102 this.origHit = this.queryHit(point.left, point.top);
3103
3104 // treat the center of the subject as the collision point?
3105 if (subjectEl && this.options.subjectCenter) {
3106
3107 // only consider the area the subject overlaps the hit. best for large subjects.
3108 // TODO: skip this if hit didn't supply left/right/top/bottom
3109 if (this.origHit) {
3110 subjectRect = intersectRects(this.origHit, subjectRect) ||
3111 subjectRect; // in case there is no intersection
3112 }
3113
3114 point = getRectCenter(subjectRect);
3115 }
3116
3117 this.coordAdjust = diffPoints(point, origPoint); // point - origPoint
3118 }
3119 else {
3120 this.origHit = null;
3121 this.coordAdjust = null;
3122 }
3123
3124 // call the super-method. do it after origHit has been computed
3125 DragListener.prototype.handleInteractionStart.apply(this, arguments);
3126 },
3127
3128
3129 // Called when the actual drag has started
3130 handleDragStart: function(ev) {
3131 var hit;
3132
3133 DragListener.prototype.handleDragStart.apply(this, arguments); // call the super-method
3134
3135 // might be different from this.origHit if the min-distance is large
3136 hit = this.queryHit(getEvX(ev), getEvY(ev));
3137
3138 // report the initial hit the mouse is over
3139 // especially important if no min-distance and drag starts immediately
3140 if (hit) {
3141 this.handleHitOver(hit);
3142 }
3143 },
3144
3145
3146 // Called when the drag moves
3147 handleDrag: function(dx, dy, ev) {
3148 var hit;
3149
3150 DragListener.prototype.handleDrag.apply(this, arguments); // call the super-method
3151
3152 hit = this.queryHit(getEvX(ev), getEvY(ev));
3153
3154 if (!isHitsEqual(hit, this.hit)) { // a different hit than before?
3155 if (this.hit) {
3156 this.handleHitOut();
3157 }
3158 if (hit) {
3159 this.handleHitOver(hit);
3160 }
3161 }
3162 },
3163
3164
3165 // Called when dragging has been stopped
3166 handleDragEnd: function() {
3167 this.handleHitDone();
3168 DragListener.prototype.handleDragEnd.apply(this, arguments); // call the super-method
3169 },
3170
3171
3172 // Called when a the mouse has just moved over a new hit
3173 handleHitOver: function(hit) {
3174 var isOrig = isHitsEqual(hit, this.origHit);
3175
3176 this.hit = hit;
3177
3178 this.trigger('hitOver', this.hit, isOrig, this.origHit);
3179 },
3180
3181
3182 // Called when the mouse has just moved out of a hit
3183 handleHitOut: function() {
3184 if (this.hit) {
3185 this.trigger('hitOut', this.hit);
3186 this.handleHitDone();
3187 this.hit = null;
3188 }
3189 },
3190
3191
3192 // Called after a hitOut. Also called before a dragStop
3193 handleHitDone: function() {
3194 if (this.hit) {
3195 this.trigger('hitDone', this.hit);
3196 }
3197 },
3198
3199
3200 // Called when the interaction ends, whether there was a real drag or not
3201 handleInteractionEnd: function() {
3202 DragListener.prototype.handleInteractionEnd.apply(this, arguments); // call the super-method
3203
3204 this.origHit = null;
3205 this.hit = null;
3206
3207 this.component.hitsNotNeeded();
3208 },
3209
3210
3211 // Called when scrolling has stopped, whether through auto scroll, or the user scrolling
3212 handleScrollEnd: function() {
3213 DragListener.prototype.handleScrollEnd.apply(this, arguments); // call the super-method
3214
3215 // hits' absolute positions will be in new places after a user's scroll.
3216 // HACK for recomputing.
3217 if (this.isDragging) {
3218 this.component.releaseHits();
3219 this.component.prepareHits();
3220 }
3221 },
3222
3223
3224 // Gets the hit underneath the coordinates for the given mouse event
3225 queryHit: function(left, top) {
3226
3227 if (this.coordAdjust) {
3228 left += this.coordAdjust.left;
3229 top += this.coordAdjust.top;
3230 }
3231
3232 return this.component.queryHit(left, top);
3233 }
3234
3235 });
3236
3237
3238 // Returns `true` if the hits are identically equal. `false` otherwise. Must be from the same component.
3239 // Two null values will be considered equal, as two "out of the component" states are the same.
3240 function isHitsEqual(hit0, hit1) {
3241
3242 if (!hit0 && !hit1) {
3243 return true;
3244 }
3245
3246 if (hit0 && hit1) {
3247 return hit0.component === hit1.component &&
3248 isHitPropsWithin(hit0, hit1) &&
3249 isHitPropsWithin(hit1, hit0); // ensures all props are identical
3250 }
3251
3252 return false;
3253 }
3254
3255
3256 // Returns true if all of subHit's non-standard properties are within superHit
3257 function isHitPropsWithin(subHit, superHit) {
3258 for (var propName in subHit) {
3259 if (!/^(component|left|right|top|bottom)$/.test(propName)) {
3260 if (subHit[propName] !== superHit[propName]) {
3261 return false;
3262 }
3263 }
3264 }
3265 return true;
3266 }
3267
3268 ;;
3269
3270 /*
3271 Listens to document and window-level user-interaction events, like touch events and mouse events,
3272 and fires these events as-is to whoever is observing a GlobalEmitter.
3273 Best when used as a singleton via GlobalEmitter.get()
3274
3275 Normalizes mouse/touch events. For examples:
3276 - ignores the the simulated mouse events that happen after a quick tap: mousemove+mousedown+mouseup+click
3277 - compensates for various buggy scenarios where a touchend does not fire
3278 */
3279
3280 FC.touchMouseIgnoreWait = 500;
3281
3282 var GlobalEmitter = Class.extend(ListenerMixin, EmitterMixin, {
3283
3284 isTouching: false,
3285 mouseIgnoreDepth: 0,
3286 handleScrollProxy: null,
3287
3288
3289 bind: function() {
3290 var _this = this;
3291
3292 this.listenTo($(document), {
3293 touchstart: this.handleTouchStart,
3294 touchcancel: this.handleTouchCancel,
3295 touchend: this.handleTouchEnd,
3296 mousedown: this.handleMouseDown,
3297 mousemove: this.handleMouseMove,
3298 mouseup: this.handleMouseUp,
3299 click: this.handleClick,
3300 selectstart: this.handleSelectStart,
3301 contextmenu: this.handleContextMenu
3302 });
3303
3304 // because we need to call preventDefault
3305 // because https://www.chromestatus.com/features/5093566007214080
3306 // TODO: investigate performance because this is a global handler
3307 window.addEventListener(
3308 'touchmove',
3309 this.handleTouchMoveProxy = function(ev) {
3310 _this.handleTouchMove($.Event(ev));
3311 },
3312 { passive: false } // allows preventDefault()
3313 );
3314
3315 // attach a handler to get called when ANY scroll action happens on the page.
3316 // this was impossible to do with normal on/off because 'scroll' doesn't bubble.
3317 // http://stackoverflow.com/a/32954565/96342
3318 window.addEventListener(
3319 'scroll',
3320 this.handleScrollProxy = function(ev) {
3321 _this.handleScroll($.Event(ev));
3322 },
3323 true // useCapture
3324 );
3325 },
3326
3327 unbind: function() {
3328 this.stopListeningTo($(document));
3329
3330 window.removeEventListener(
3331 'touchmove',
3332 this.handleTouchMoveProxy
3333 );
3334
3335 window.removeEventListener(
3336 'scroll',
3337 this.handleScrollProxy,
3338 true // useCapture
3339 );
3340 },
3341
3342
3343 // Touch Handlers
3344 // -----------------------------------------------------------------------------------------------------------------
3345
3346 handleTouchStart: function(ev) {
3347
3348 // if a previous touch interaction never ended with a touchend, then implicitly end it,
3349 // but since a new touch interaction is about to begin, don't start the mouse ignore period.
3350 this.stopTouch(ev, true); // skipMouseIgnore=true
3351
3352 this.isTouching = true;
3353 this.trigger('touchstart', ev);
3354 },
3355
3356 handleTouchMove: function(ev) {
3357 if (this.isTouching) {
3358 this.trigger('touchmove', ev);
3359 }
3360 },
3361
3362 handleTouchCancel: function(ev) {
3363 if (this.isTouching) {
3364 this.trigger('touchcancel', ev);
3365
3366 // Have touchcancel fire an artificial touchend. That way, handlers won't need to listen to both.
3367 // If touchend fires later, it won't have any effect b/c isTouching will be false.
3368 this.stopTouch(ev);
3369 }
3370 },
3371
3372 handleTouchEnd: function(ev) {
3373 this.stopTouch(ev);
3374 },
3375
3376
3377 // Mouse Handlers
3378 // -----------------------------------------------------------------------------------------------------------------
3379
3380 handleMouseDown: function(ev) {
3381 if (!this.shouldIgnoreMouse()) {
3382 this.trigger('mousedown', ev);
3383 }
3384 },
3385
3386 handleMouseMove: function(ev) {
3387 if (!this.shouldIgnoreMouse()) {
3388 this.trigger('mousemove', ev);
3389 }
3390 },
3391
3392 handleMouseUp: function(ev) {
3393 if (!this.shouldIgnoreMouse()) {
3394 this.trigger('mouseup', ev);
3395 }
3396 },
3397
3398 handleClick: function(ev) {
3399 if (!this.shouldIgnoreMouse()) {
3400 this.trigger('click', ev);
3401 }
3402 },
3403
3404
3405 // Misc Handlers
3406 // -----------------------------------------------------------------------------------------------------------------
3407
3408 handleSelectStart: function(ev) {
3409 this.trigger('selectstart', ev);
3410 },
3411
3412 handleContextMenu: function(ev) {
3413 this.trigger('contextmenu', ev);
3414 },
3415
3416 handleScroll: function(ev) {
3417 this.trigger('scroll', ev);
3418 },
3419
3420
3421 // Utils
3422 // -----------------------------------------------------------------------------------------------------------------
3423
3424 stopTouch: function(ev, skipMouseIgnore) {
3425 if (this.isTouching) {
3426 this.isTouching = false;
3427 this.trigger('touchend', ev);
3428
3429 if (!skipMouseIgnore) {
3430 this.startTouchMouseIgnore();
3431 }
3432 }
3433 },
3434
3435 startTouchMouseIgnore: function() {
3436 var _this = this;
3437 var wait = FC.touchMouseIgnoreWait;
3438
3439 if (wait) {
3440 this.mouseIgnoreDepth++;
3441 setTimeout(function() {
3442 _this.mouseIgnoreDepth--;
3443 }, wait);
3444 }
3445 },
3446
3447 shouldIgnoreMouse: function() {
3448 return this.isTouching || Boolean(this.mouseIgnoreDepth);
3449 }
3450
3451 });
3452
3453
3454 // Singleton
3455 // ---------------------------------------------------------------------------------------------------------------------
3456
3457 (function() {
3458 var globalEmitter = null;
3459 var neededCount = 0;
3460
3461
3462 // gets the singleton
3463 GlobalEmitter.get = function() {
3464
3465 if (!globalEmitter) {
3466 globalEmitter = new GlobalEmitter();
3467 globalEmitter.bind();
3468 }
3469
3470 return globalEmitter;
3471 };
3472
3473
3474 // called when an object knows it will need a GlobalEmitter in the near future.
3475 GlobalEmitter.needed = function() {
3476 GlobalEmitter.get(); // ensures globalEmitter
3477 neededCount++;
3478 };
3479
3480
3481 // called when the object that originally called needed() doesn't need a GlobalEmitter anymore.
3482 GlobalEmitter.unneeded = function() {
3483 neededCount--;
3484
3485 if (!neededCount) { // nobody else needs it
3486 globalEmitter.unbind();
3487 globalEmitter = null;
3488 }
3489 };
3490
3491 })();
3492
3493 ;;
3494
3495 /* Creates a clone of an element and lets it track the mouse as it moves
3496 ----------------------------------------------------------------------------------------------------------------------*/
3497
3498 var MouseFollower = Class.extend(ListenerMixin, {
3499
3500 options: null,
3501
3502 sourceEl: null, // the element that will be cloned and made to look like it is dragging
3503 el: null, // the clone of `sourceEl` that will track the mouse
3504 parentEl: null, // the element that `el` (the clone) will be attached to
3505
3506 // the initial position of el, relative to the offset parent. made to match the initial offset of sourceEl
3507 top0: null,
3508 left0: null,
3509
3510 // the absolute coordinates of the initiating touch/mouse action
3511 y0: null,
3512 x0: null,
3513
3514 // the number of pixels the mouse has moved from its initial position
3515 topDelta: null,
3516 leftDelta: null,
3517
3518 isFollowing: false,
3519 isHidden: false,
3520 isAnimating: false, // doing the revert animation?
3521
3522 constructor: function(sourceEl, options) {
3523 this.options = options = options || {};
3524 this.sourceEl = sourceEl;
3525 this.parentEl = options.parentEl ? $(options.parentEl) : sourceEl.parent(); // default to sourceEl's parent
3526 },
3527
3528
3529 // Causes the element to start following the mouse
3530 start: function(ev) {
3531 if (!this.isFollowing) {
3532 this.isFollowing = true;
3533
3534 this.y0 = getEvY(ev);
3535 this.x0 = getEvX(ev);
3536 this.topDelta = 0;
3537 this.leftDelta = 0;
3538
3539 if (!this.isHidden) {
3540 this.updatePosition();
3541 }
3542
3543 if (getEvIsTouch(ev)) {
3544 this.listenTo($(document), 'touchmove', this.handleMove);
3545 }
3546 else {
3547 this.listenTo($(document), 'mousemove', this.handleMove);
3548 }
3549 }
3550 },
3551
3552
3553 // Causes the element to stop following the mouse. If shouldRevert is true, will animate back to original position.
3554 // `callback` gets invoked when the animation is complete. If no animation, it is invoked immediately.
3555 stop: function(shouldRevert, callback) {
3556 var _this = this;
3557 var revertDuration = this.options.revertDuration;
3558
3559 function complete() { // might be called by .animate(), which might change `this` context
3560 _this.isAnimating = false;
3561 _this.removeElement();
3562
3563 _this.top0 = _this.left0 = null; // reset state for future updatePosition calls
3564
3565 if (callback) {
3566 callback();
3567 }
3568 }
3569
3570 if (this.isFollowing && !this.isAnimating) { // disallow more than one stop animation at a time
3571 this.isFollowing = false;
3572
3573 this.stopListeningTo($(document));
3574
3575 if (shouldRevert && revertDuration && !this.isHidden) { // do a revert animation?
3576 this.isAnimating = true;
3577 this.el.animate({
3578 top: this.top0,
3579 left: this.left0
3580 }, {
3581 duration: revertDuration,
3582 complete: complete
3583 });
3584 }
3585 else {
3586 complete();
3587 }
3588 }
3589 },
3590
3591
3592 // Gets the tracking element. Create it if necessary
3593 getEl: function() {
3594 var el = this.el;
3595
3596 if (!el) {
3597 el = this.el = this.sourceEl.clone()
3598 .addClass(this.options.additionalClass || '')
3599 .css({
3600 position: 'absolute',
3601 visibility: '', // in case original element was hidden (commonly through hideEvents())
3602 display: this.isHidden ? 'none' : '', // for when initially hidden
3603 margin: 0,
3604 right: 'auto', // erase and set width instead
3605 bottom: 'auto', // erase and set height instead
3606 width: this.sourceEl.width(), // explicit height in case there was a 'right' value
3607 height: this.sourceEl.height(), // explicit width in case there was a 'bottom' value
3608 opacity: this.options.opacity || '',
3609 zIndex: this.options.zIndex
3610 });
3611
3612 // we don't want long taps or any mouse interaction causing selection/menus.
3613 // would use preventSelection(), but that prevents selectstart, causing problems.
3614 el.addClass('fc-unselectable');
3615
3616 el.appendTo(this.parentEl);
3617 }
3618
3619 return el;
3620 },
3621
3622
3623 // Removes the tracking element if it has already been created
3624 removeElement: function() {
3625 if (this.el) {
3626 this.el.remove();
3627 this.el = null;
3628 }
3629 },
3630
3631
3632 // Update the CSS position of the tracking element
3633 updatePosition: function() {
3634 var sourceOffset;
3635 var origin;
3636
3637 this.getEl(); // ensure this.el
3638
3639 // make sure origin info was computed
3640 if (this.top0 === null) {
3641 sourceOffset = this.sourceEl.offset();
3642 origin = this.el.offsetParent().offset();
3643 this.top0 = sourceOffset.top - origin.top;
3644 this.left0 = sourceOffset.left - origin.left;
3645 }
3646
3647 this.el.css({
3648 top: this.top0 + this.topDelta,
3649 left: this.left0 + this.leftDelta
3650 });
3651 },
3652
3653
3654 // Gets called when the user moves the mouse
3655 handleMove: function(ev) {
3656 this.topDelta = getEvY(ev) - this.y0;
3657 this.leftDelta = getEvX(ev) - this.x0;
3658
3659 if (!this.isHidden) {
3660 this.updatePosition();
3661 }
3662 },
3663
3664
3665 // Temporarily makes the tracking element invisible. Can be called before following starts
3666 hide: function() {
3667 if (!this.isHidden) {
3668 this.isHidden = true;
3669 if (this.el) {
3670 this.el.hide();
3671 }
3672 }
3673 },
3674
3675
3676 // Show the tracking element after it has been temporarily hidden
3677 show: function() {
3678 if (this.isHidden) {
3679 this.isHidden = false;
3680 this.updatePosition();
3681 this.getEl().show();
3682 }
3683 }
3684
3685 });
3686
3687 ;;
3688
3689 /* An abstract class comprised of a "grid" of areas that each represent a specific datetime
3690 ----------------------------------------------------------------------------------------------------------------------*/
3691
3692 var Grid = FC.Grid = Class.extend(ListenerMixin, {
3693
3694 // self-config, overridable by subclasses
3695 hasDayInteractions: true, // can user click/select ranges of time?
3696
3697 view: null, // a View object
3698 isRTL: null, // shortcut to the view's isRTL option
3699
3700 start: null,
3701 end: null,
3702
3703 el: null, // the containing element
3704 elsByFill: null, // a hash of jQuery element sets used for rendering each fill. Keyed by fill name.
3705
3706 // derived from options
3707 eventTimeFormat: null,
3708 displayEventTime: null,
3709 displayEventEnd: null,
3710
3711 minResizeDuration: null, // TODO: hack. set by subclasses. minumum event resize duration
3712
3713 // if defined, holds the unit identified (ex: "year" or "month") that determines the level of granularity
3714 // of the date areas. if not defined, assumes to be day and time granularity.
3715 // TODO: port isTimeScale into same system?
3716 largeUnit: null,
3717
3718 dayClickListener: null,
3719 daySelectListener: null,
3720 segDragListener: null,
3721 segResizeListener: null,
3722 externalDragListener: null,
3723
3724
3725 constructor: function(view) {
3726 this.view = view;
3727 this.isRTL = view.opt('isRTL');
3728 this.elsByFill = {};
3729
3730 this.dayClickListener = this.buildDayClickListener();
3731 this.daySelectListener = this.buildDaySelectListener();
3732 },
3733
3734
3735 /* Options
3736 ------------------------------------------------------------------------------------------------------------------*/
3737
3738
3739 // Generates the format string used for event time text, if not explicitly defined by 'timeFormat'
3740 computeEventTimeFormat: function() {
3741 return this.view.opt('smallTimeFormat');
3742 },
3743
3744
3745 // Determines whether events should have their end times displayed, if not explicitly defined by 'displayEventTime'.
3746 // Only applies to non-all-day events.
3747 computeDisplayEventTime: function() {
3748 return true;
3749 },
3750
3751
3752 // Determines whether events should have their end times displayed, if not explicitly defined by 'displayEventEnd'
3753 computeDisplayEventEnd: function() {
3754 return true;
3755 },
3756
3757
3758 /* Dates
3759 ------------------------------------------------------------------------------------------------------------------*/
3760
3761
3762 // Tells the grid about what period of time to display.
3763 // Any date-related internal data should be generated.
3764 setRange: function(range) {
3765 this.start = range.start.clone();
3766 this.end = range.end.clone();
3767
3768 this.rangeUpdated();
3769 this.processRangeOptions();
3770 },
3771
3772
3773 // Called when internal variables that rely on the range should be updated
3774 rangeUpdated: function() {
3775 },
3776
3777
3778 // Updates values that rely on options and also relate to range
3779 processRangeOptions: function() {
3780 var view = this.view;
3781 var displayEventTime;
3782 var displayEventEnd;
3783
3784 this.eventTimeFormat =
3785 view.opt('eventTimeFormat') ||
3786 view.opt('timeFormat') || // deprecated
3787 this.computeEventTimeFormat();
3788
3789 displayEventTime = view.opt('displayEventTime');
3790 if (displayEventTime == null) {
3791 displayEventTime = this.computeDisplayEventTime(); // might be based off of range
3792 }
3793
3794 displayEventEnd = view.opt('displayEventEnd');
3795 if (displayEventEnd == null) {
3796 displayEventEnd = this.computeDisplayEventEnd(); // might be based off of range
3797 }
3798
3799 this.displayEventTime = displayEventTime;
3800 this.displayEventEnd = displayEventEnd;
3801 },
3802
3803
3804 // Converts a span (has unzoned start/end and any other grid-specific location information)
3805 // into an array of segments (pieces of events whose format is decided by the grid).
3806 spanToSegs: function(span) {
3807 // subclasses must implement
3808 },
3809
3810
3811 // Diffs the two dates, returning a duration, based on granularity of the grid
3812 // TODO: port isTimeScale into this system?
3813 diffDates: function(a, b) {
3814 if (this.largeUnit) {
3815 return diffByUnit(a, b, this.largeUnit);
3816 }
3817 else {
3818 return diffDayTime(a, b);
3819 }
3820 },
3821
3822
3823 /* Hit Area
3824 ------------------------------------------------------------------------------------------------------------------*/
3825
3826 hitsNeededDepth: 0, // necessary because multiple callers might need the same hits
3827
3828 hitsNeeded: function() {
3829 if (!(this.hitsNeededDepth++)) {
3830 this.prepareHits();
3831 }
3832 },
3833
3834 hitsNotNeeded: function() {
3835 if (this.hitsNeededDepth && !(--this.hitsNeededDepth)) {
3836 this.releaseHits();
3837 }
3838 },
3839
3840
3841 // Called before one or more queryHit calls might happen. Should prepare any cached coordinates for queryHit
3842 prepareHits: function() {
3843 },
3844
3845
3846 // Called when queryHit calls have subsided. Good place to clear any coordinate caches.
3847 releaseHits: function() {
3848 },
3849
3850
3851 // Given coordinates from the topleft of the document, return data about the date-related area underneath.
3852 // Can return an object with arbitrary properties (although top/right/left/bottom are encouraged).
3853 // Must have a `grid` property, a reference to this current grid. TODO: avoid this
3854 // The returned object will be processed by getHitSpan and getHitEl.
3855 queryHit: function(leftOffset, topOffset) {
3856 },
3857
3858
3859 // Given position-level information about a date-related area within the grid,
3860 // should return an object with at least a start/end date. Can provide other information as well.
3861 getHitSpan: function(hit) {
3862 },
3863
3864
3865 // Given position-level information about a date-related area within the grid,
3866 // should return a jQuery element that best represents it. passed to dayClick callback.
3867 getHitEl: function(hit) {
3868 },
3869
3870
3871 /* Rendering
3872 ------------------------------------------------------------------------------------------------------------------*/
3873
3874
3875 // Sets the container element that the grid should render inside of.
3876 // Does other DOM-related initializations.
3877 setElement: function(el) {
3878 this.el = el;
3879
3880 if (this.hasDayInteractions) {
3881 preventSelection(el);
3882
3883 this.bindDayHandler('touchstart', this.dayTouchStart);
3884 this.bindDayHandler('mousedown', this.dayMousedown);
3885 }
3886
3887 // attach event-element-related handlers. in Grid.events
3888 // same garbage collection note as above.
3889 this.bindSegHandlers();
3890
3891 this.bindGlobalHandlers();
3892 },
3893
3894
3895 bindDayHandler: function(name, handler) {
3896 var _this = this;
3897
3898 // attach a handler to the grid's root element.
3899 // jQuery will take care of unregistering them when removeElement gets called.
3900 this.el.on(name, function(ev) {
3901 if (
3902 !$(ev.target).is(
3903 _this.segSelector + ',' + // directly on an event element
3904 _this.segSelector + ' *,' + // within an event element
3905 '.fc-more,' + // a "more.." link
3906 'a[data-goto]' // a clickable nav link
3907 )
3908 ) {
3909 return handler.call(_this, ev);
3910 }
3911 });
3912 },
3913
3914
3915 // Removes the grid's container element from the DOM. Undoes any other DOM-related attachments.
3916 // DOES NOT remove any content beforehand (doesn't clear events or call unrenderDates), unlike View
3917 removeElement: function() {
3918 this.unbindGlobalHandlers();
3919 this.clearDragListeners();
3920
3921 this.el.remove();
3922
3923 // NOTE: we don't null-out this.el for the same reasons we don't do it within View::removeElement
3924 },
3925
3926
3927 // Renders the basic structure of grid view before any content is rendered
3928 renderSkeleton: function() {
3929 // subclasses should implement
3930 },
3931
3932
3933 // Renders the grid's date-related content (like areas that represent days/times).
3934 // Assumes setRange has already been called and the skeleton has already been rendered.
3935 renderDates: function() {
3936 // subclasses should implement
3937 },
3938
3939
3940 // Unrenders the grid's date-related content
3941 unrenderDates: function() {
3942 // subclasses should implement
3943 },
3944
3945
3946 /* Handlers
3947 ------------------------------------------------------------------------------------------------------------------*/
3948
3949
3950 // Binds DOM handlers to elements that reside outside the grid, such as the document
3951 bindGlobalHandlers: function() {
3952 this.listenTo($(document), {
3953 dragstart: this.externalDragStart, // jqui
3954 sortstart: this.externalDragStart // jqui
3955 });
3956 },
3957
3958
3959 // Unbinds DOM handlers from elements that reside outside the grid
3960 unbindGlobalHandlers: function() {
3961 this.stopListeningTo($(document));
3962 },
3963
3964
3965 // Process a mousedown on an element that represents a day. For day clicking and selecting.
3966 dayMousedown: function(ev) {
3967 var view = this.view;
3968
3969 // prevent a user's clickaway for unselecting a range or an event from
3970 // causing a dayClick or starting an immediate new selection.
3971 if (view.isSelected || view.selectedEvent) {
3972 return;
3973 }
3974
3975 this.dayClickListener.startInteraction(ev);
3976
3977 if (view.opt('selectable')) {
3978 this.daySelectListener.startInteraction(ev, {
3979 distance: view.opt('selectMinDistance')
3980 });
3981 }
3982 },
3983
3984
3985 dayTouchStart: function(ev) {
3986 var view = this.view;
3987 var selectLongPressDelay;
3988
3989 // prevent a user's clickaway for unselecting a range or an event from
3990 // causing a dayClick or starting an immediate new selection.
3991 if (view.isSelected || view.selectedEvent) {
3992 return;
3993 }
3994
3995 selectLongPressDelay = view.opt('selectLongPressDelay');
3996 if (selectLongPressDelay == null) {
3997 selectLongPressDelay = view.opt('longPressDelay'); // fallback
3998 }
3999
4000 this.dayClickListener.startInteraction(ev);
4001
4002 if (view.opt('selectable')) {
4003 this.daySelectListener.startInteraction(ev, {
4004 delay: selectLongPressDelay
4005 });
4006 }
4007 },
4008
4009
4010 // Creates a listener that tracks the user's drag across day elements, for day clicking.
4011 buildDayClickListener: function() {
4012 var _this = this;
4013 var view = this.view;
4014 var dayClickHit; // null if invalid dayClick
4015
4016 var dragListener = new HitDragListener(this, {
4017 scroll: view.opt('dragScroll'),
4018 interactionStart: function() {
4019 dayClickHit = dragListener.origHit;
4020 },
4021 hitOver: function(hit, isOrig, origHit) {
4022 // if user dragged to another cell at any point, it can no longer be a dayClick
4023 if (!isOrig) {
4024 dayClickHit = null;
4025 }
4026 },
4027 hitOut: function() { // called before mouse moves to a different hit OR moved out of all hits
4028 dayClickHit = null;
4029 },
4030 interactionEnd: function(ev, isCancelled) {
4031 if (!isCancelled && dayClickHit) {
4032 view.triggerDayClick(
4033 _this.getHitSpan(dayClickHit),
4034 _this.getHitEl(dayClickHit),
4035 ev
4036 );
4037 }
4038 }
4039 });
4040
4041 // because dayClickListener won't be called with any time delay, "dragging" will begin immediately,
4042 // which will kill any touchmoving/scrolling. Prevent this.
4043 dragListener.shouldCancelTouchScroll = false;
4044
4045 dragListener.scrollAlwaysKills = true;
4046
4047 return dragListener;
4048 },
4049
4050
4051 // Creates a listener that tracks the user's drag across day elements, for day selecting.
4052 buildDaySelectListener: function() {
4053 var _this = this;
4054 var view = this.view;
4055 var selectionSpan; // null if invalid selection
4056
4057 var dragListener = new HitDragListener(this, {
4058 scroll: view.opt('dragScroll'),
4059 interactionStart: function() {
4060 selectionSpan = null;
4061 },
4062 dragStart: function() {
4063 view.unselect(); // since we could be rendering a new selection, we want to clear any old one
4064 },
4065 hitOver: function(hit, isOrig, origHit) {
4066 if (origHit) { // click needs to have started on a hit
4067
4068 selectionSpan = _this.computeSelection(
4069 _this.getHitSpan(origHit),
4070 _this.getHitSpan(hit)
4071 );
4072
4073 if (selectionSpan) {
4074 _this.renderSelection(selectionSpan);
4075 }
4076 else if (selectionSpan === false) {
4077 disableCursor();
4078 }
4079 }
4080 },
4081 hitOut: function() { // called before mouse moves to a different hit OR moved out of all hits
4082 selectionSpan = null;
4083 _this.unrenderSelection();
4084 },
4085 hitDone: function() { // called after a hitOut OR before a dragEnd
4086 enableCursor();
4087 },
4088 interactionEnd: function(ev, isCancelled) {
4089 if (!isCancelled && selectionSpan) {
4090 // the selection will already have been rendered. just report it
4091 view.reportSelection(selectionSpan, ev);
4092 }
4093 }
4094 });
4095
4096 return dragListener;
4097 },
4098
4099
4100 // Kills all in-progress dragging.
4101 // Useful for when public API methods that result in re-rendering are invoked during a drag.
4102 // Also useful for when touch devices misbehave and don't fire their touchend.
4103 clearDragListeners: function() {
4104 this.dayClickListener.endInteraction();
4105 this.daySelectListener.endInteraction();
4106
4107 if (this.segDragListener) {
4108 this.segDragListener.endInteraction(); // will clear this.segDragListener
4109 }
4110 if (this.segResizeListener) {
4111 this.segResizeListener.endInteraction(); // will clear this.segResizeListener
4112 }
4113 if (this.externalDragListener) {
4114 this.externalDragListener.endInteraction(); // will clear this.externalDragListener
4115 }
4116 },
4117
4118
4119 /* Event Helper
4120 ------------------------------------------------------------------------------------------------------------------*/
4121 // TODO: should probably move this to Grid.events, like we did event dragging / resizing
4122
4123
4124 // Renders a mock event at the given event location, which contains zoned start/end properties.
4125 // Returns all mock event elements.
4126 renderEventLocationHelper: function(eventLocation, sourceSeg) {
4127 var fakeEvent = this.fabricateHelperEvent(eventLocation, sourceSeg);
4128
4129 return this.renderHelper(fakeEvent, sourceSeg); // do the actual rendering
4130 },
4131
4132
4133 // Builds a fake event given zoned event date properties and a segment is should be inspired from.
4134 // The range's end can be null, in which case the mock event that is rendered will have a null end time.
4135 // `sourceSeg` is the internal segment object involved in the drag. If null, something external is dragging.
4136 fabricateHelperEvent: function(eventLocation, sourceSeg) {
4137 var fakeEvent = sourceSeg ? createObject(sourceSeg.event) : {}; // mask the original event object if possible
4138
4139 fakeEvent.start = eventLocation.start.clone();
4140 fakeEvent.end = eventLocation.end ? eventLocation.end.clone() : null;
4141 fakeEvent.allDay = null; // force it to be freshly computed by normalizeEventDates
4142 this.view.calendar.normalizeEventDates(fakeEvent);
4143
4144 // this extra className will be useful for differentiating real events from mock events in CSS
4145 fakeEvent.className = (fakeEvent.className || []).concat('fc-helper');
4146
4147 // if something external is being dragged in, don't render a resizer
4148 if (!sourceSeg) {
4149 fakeEvent.editable = false;
4150 }
4151
4152 return fakeEvent;
4153 },
4154
4155
4156 // Renders a mock event. Given zoned event date properties.
4157 // Must return all mock event elements.
4158 renderHelper: function(eventLocation, sourceSeg) {
4159 // subclasses must implement
4160 },
4161
4162
4163 // Unrenders a mock event
4164 unrenderHelper: function() {
4165 // subclasses must implement
4166 },
4167
4168
4169 /* Selection
4170 ------------------------------------------------------------------------------------------------------------------*/
4171
4172
4173 // Renders a visual indication of a selection. Will highlight by default but can be overridden by subclasses.
4174 // Given a span (unzoned start/end and other misc data)
4175 renderSelection: function(span) {
4176 this.renderHighlight(span);
4177 },
4178
4179
4180 // Unrenders any visual indications of a selection. Will unrender a highlight by default.
4181 unrenderSelection: function() {
4182 this.unrenderHighlight();
4183 },
4184
4185
4186 // Given the first and last date-spans of a selection, returns another date-span object.
4187 // Subclasses can override and provide additional data in the span object. Will be passed to renderSelection().
4188 // Will return false if the selection is invalid and this should be indicated to the user.
4189 // Will return null/undefined if a selection invalid but no error should be reported.
4190 computeSelection: function(span0, span1) {
4191 var span = this.computeSelectionSpan(span0, span1);
4192
4193 if (span && !this.view.calendar.isSelectionSpanAllowed(span)) {
4194 return false;
4195 }
4196
4197 return span;
4198 },
4199
4200
4201 // Given two spans, must return the combination of the two.
4202 // TODO: do this separation of concerns (combining VS validation) for event dnd/resize too.
4203 computeSelectionSpan: function(span0, span1) {
4204 var dates = [ span0.start, span0.end, span1.start, span1.end ];
4205
4206 dates.sort(compareNumbers); // sorts chronologically. works with Moments
4207
4208 return { start: dates[0].clone(), end: dates[3].clone() };
4209 },
4210
4211
4212 /* Highlight
4213 ------------------------------------------------------------------------------------------------------------------*/
4214
4215
4216 // Renders an emphasis on the given date range. Given a span (unzoned start/end and other misc data)
4217 renderHighlight: function(span) {
4218 this.renderFill('highlight', this.spanToSegs(span));
4219 },
4220
4221
4222 // Unrenders the emphasis on a date range
4223 unrenderHighlight: function() {
4224 this.unrenderFill('highlight');
4225 },
4226
4227
4228 // Generates an array of classNames for rendering the highlight. Used by the fill system.
4229 highlightSegClasses: function() {
4230 return [ 'fc-highlight' ];
4231 },
4232
4233
4234 /* Business Hours
4235 ------------------------------------------------------------------------------------------------------------------*/
4236
4237
4238 renderBusinessHours: function() {
4239 },
4240
4241
4242 unrenderBusinessHours: function() {
4243 },
4244
4245
4246 /* Now Indicator
4247 ------------------------------------------------------------------------------------------------------------------*/
4248
4249
4250 getNowIndicatorUnit: function() {
4251 },
4252
4253
4254 renderNowIndicator: function(date) {
4255 },
4256
4257
4258 unrenderNowIndicator: function() {
4259 },
4260
4261
4262 /* Fill System (highlight, background events, business hours)
4263 --------------------------------------------------------------------------------------------------------------------
4264 TODO: remove this system. like we did in TimeGrid
4265 */
4266
4267
4268 // Renders a set of rectangles over the given segments of time.
4269 // MUST RETURN a subset of segs, the segs that were actually rendered.
4270 // Responsible for populating this.elsByFill. TODO: better API for expressing this requirement
4271 renderFill: function(type, segs) {
4272 // subclasses must implement
4273 },
4274
4275
4276 // Unrenders a specific type of fill that is currently rendered on the grid
4277 unrenderFill: function(type) {
4278 var el = this.elsByFill[type];
4279
4280 if (el) {
4281 el.remove();
4282 delete this.elsByFill[type];
4283 }
4284 },
4285
4286
4287 // Renders and assigns an `el` property for each fill segment. Generic enough to work with different types.
4288 // Only returns segments that successfully rendered.
4289 // To be harnessed by renderFill (implemented by subclasses).
4290 // Analagous to renderFgSegEls.
4291 renderFillSegEls: function(type, segs) {
4292 var _this = this;
4293 var segElMethod = this[type + 'SegEl'];
4294 var html = '';
4295 var renderedSegs = [];
4296 var i;
4297
4298 if (segs.length) {
4299
4300 // build a large concatenation of segment HTML
4301 for (i = 0; i < segs.length; i++) {
4302 html += this.fillSegHtml(type, segs[i]);
4303 }
4304
4305 // Grab individual elements from the combined HTML string. Use each as the default rendering.
4306 // Then, compute the 'el' for each segment.
4307 $(html).each(function(i, node) {
4308 var seg = segs[i];
4309 var el = $(node);
4310
4311 // allow custom filter methods per-type
4312 if (segElMethod) {
4313 el = segElMethod.call(_this, seg, el);
4314 }
4315
4316 if (el) { // custom filters did not cancel the render
4317 el = $(el); // allow custom filter to return raw DOM node
4318
4319 // correct element type? (would be bad if a non-TD were inserted into a table for example)
4320 if (el.is(_this.fillSegTag)) {
4321 seg.el = el;
4322 renderedSegs.push(seg);
4323 }
4324 }
4325 });
4326 }
4327
4328 return renderedSegs;
4329 },
4330
4331
4332 fillSegTag: 'div', // subclasses can override
4333
4334
4335 // Builds the HTML needed for one fill segment. Generic enough to work with different types.
4336 fillSegHtml: function(type, seg) {
4337
4338 // custom hooks per-type
4339 var classesMethod = this[type + 'SegClasses'];
4340 var cssMethod = this[type + 'SegCss'];
4341
4342 var classes = classesMethod ? classesMethod.call(this, seg) : [];
4343 var css = cssToStr(cssMethod ? cssMethod.call(this, seg) : {});
4344
4345 return '<' + this.fillSegTag +
4346 (classes.length ? ' class="' + classes.join(' ') + '"' : '') +
4347 (css ? ' style="' + css + '"' : '') +
4348 ' />';
4349 },
4350
4351
4352
4353 /* Generic rendering utilities for subclasses
4354 ------------------------------------------------------------------------------------------------------------------*/
4355
4356
4357 // Computes HTML classNames for a single-day element
4358 getDayClasses: function(date, noThemeHighlight) {
4359 var view = this.view;
4360 var today = view.calendar.getNow();
4361 var classes = [ 'fc-' + dayIDs[date.day()] ];
4362
4363 if (
4364 view.intervalDuration.as('months') == 1 &&
4365 date.month() != view.intervalStart.month()
4366 ) {
4367 classes.push('fc-other-month');
4368 }
4369
4370 if (date.isSame(today, 'day')) {
4371 classes.push('fc-today');
4372
4373 if (noThemeHighlight !== true) {
4374 classes.push(view.highlightStateClass);
4375 }
4376 }
4377 else if (date < today) {
4378 classes.push('fc-past');
4379 }
4380 else {
4381 classes.push('fc-future');
4382 }
4383
4384 return classes;
4385 }
4386
4387 });
4388
4389 ;;
4390
4391 /* Event-rendering and event-interaction methods for the abstract Grid class
4392 ----------------------------------------------------------------------------------------------------------------------*/
4393
4394 Grid.mixin({
4395
4396 // self-config, overridable by subclasses
4397 segSelector: '.fc-event-container > *', // what constitutes an event element?
4398
4399 mousedOverSeg: null, // the segment object the user's mouse is over. null if over nothing
4400 isDraggingSeg: false, // is a segment being dragged? boolean
4401 isResizingSeg: false, // is a segment being resized? boolean
4402 isDraggingExternal: false, // jqui-dragging an external element? boolean
4403 segs: null, // the *event* segments currently rendered in the grid. TODO: rename to `eventSegs`
4404
4405
4406 // Renders the given events onto the grid
4407 renderEvents: function(events) {
4408 var bgEvents = [];
4409 var fgEvents = [];
4410 var i;
4411
4412 for (i = 0; i < events.length; i++) {
4413 (isBgEvent(events[i]) ? bgEvents : fgEvents).push(events[i]);
4414 }
4415
4416 this.segs = [].concat( // record all segs
4417 this.renderBgEvents(bgEvents),
4418 this.renderFgEvents(fgEvents)
4419 );
4420 },
4421
4422
4423 renderBgEvents: function(events) {
4424 var segs = this.eventsToSegs(events);
4425
4426 // renderBgSegs might return a subset of segs, segs that were actually rendered
4427 return this.renderBgSegs(segs) || segs;
4428 },
4429
4430
4431 renderFgEvents: function(events) {
4432 var segs = this.eventsToSegs(events);
4433
4434 // renderFgSegs might return a subset of segs, segs that were actually rendered
4435 return this.renderFgSegs(segs) || segs;
4436 },
4437
4438
4439 // Unrenders all events currently rendered on the grid
4440 unrenderEvents: function() {
4441 this.handleSegMouseout(); // trigger an eventMouseout if user's mouse is over an event
4442 this.clearDragListeners();
4443
4444 this.unrenderFgSegs();
4445 this.unrenderBgSegs();
4446
4447 this.segs = null;
4448 },
4449
4450
4451 // Retrieves all rendered segment objects currently rendered on the grid
4452 getEventSegs: function() {
4453 return this.segs || [];
4454 },
4455
4456
4457 /* Foreground Segment Rendering
4458 ------------------------------------------------------------------------------------------------------------------*/
4459
4460
4461 // Renders foreground event segments onto the grid. May return a subset of segs that were rendered.
4462 renderFgSegs: function(segs) {
4463 // subclasses must implement
4464 },
4465
4466
4467 // Unrenders all currently rendered foreground segments
4468 unrenderFgSegs: function() {
4469 // subclasses must implement
4470 },
4471
4472
4473 // Renders and assigns an `el` property for each foreground event segment.
4474 // Only returns segments that successfully rendered.
4475 // A utility that subclasses may use.
4476 renderFgSegEls: function(segs, disableResizing) {
4477 var view = this.view;
4478 var html = '';
4479 var renderedSegs = [];
4480 var i;
4481
4482 if (segs.length) { // don't build an empty html string
4483
4484 // build a large concatenation of event segment HTML
4485 for (i = 0; i < segs.length; i++) {
4486 html += this.fgSegHtml(segs[i], disableResizing);
4487 }
4488
4489 // Grab individual elements from the combined HTML string. Use each as the default rendering.
4490 // Then, compute the 'el' for each segment. An el might be null if the eventRender callback returned false.
4491 $(html).each(function(i, node) {
4492 var seg = segs[i];
4493 var el = view.resolveEventEl(seg.event, $(node));
4494
4495 if (el) {
4496 el.data('fc-seg', seg); // used by handlers
4497 seg.el = el;
4498 renderedSegs.push(seg);
4499 }
4500 });
4501 }
4502
4503 return renderedSegs;
4504 },
4505
4506
4507 // Generates the HTML for the default rendering of a foreground event segment. Used by renderFgSegEls()
4508 fgSegHtml: function(seg, disableResizing) {
4509 // subclasses should implement
4510 },
4511
4512
4513 /* Background Segment Rendering
4514 ------------------------------------------------------------------------------------------------------------------*/
4515
4516
4517 // Renders the given background event segments onto the grid.
4518 // Returns a subset of the segs that were actually rendered.
4519 renderBgSegs: function(segs) {
4520 return this.renderFill('bgEvent', segs);
4521 },
4522
4523
4524 // Unrenders all the currently rendered background event segments
4525 unrenderBgSegs: function() {
4526 this.unrenderFill('bgEvent');
4527 },
4528
4529
4530 // Renders a background event element, given the default rendering. Called by the fill system.
4531 bgEventSegEl: function(seg, el) {
4532 return this.view.resolveEventEl(seg.event, el); // will filter through eventRender
4533 },
4534
4535
4536 // Generates an array of classNames to be used for the default rendering of a background event.
4537 // Called by fillSegHtml.
4538 bgEventSegClasses: function(seg) {
4539 var event = seg.event;
4540 var source = event.source || {};
4541
4542 return [ 'fc-bgevent' ].concat(
4543 event.className,
4544 source.className || []
4545 );
4546 },
4547
4548
4549 // Generates a semicolon-separated CSS string to be used for the default rendering of a background event.
4550 // Called by fillSegHtml.
4551 bgEventSegCss: function(seg) {
4552 return {
4553 'background-color': this.getSegSkinCss(seg)['background-color']
4554 };
4555 },
4556
4557
4558 // Generates an array of classNames to be used for the rendering business hours overlay. Called by the fill system.
4559 // Called by fillSegHtml.
4560 businessHoursSegClasses: function(seg) {
4561 return [ 'fc-nonbusiness', 'fc-bgevent' ];
4562 },
4563
4564
4565 /* Business Hours
4566 ------------------------------------------------------------------------------------------------------------------*/
4567
4568
4569 // Compute business hour segs for the grid's current date range.
4570 // Caller must ask if whole-day business hours are needed.
4571 // If no `businessHours` configuration value is specified, assumes the calendar default.
4572 buildBusinessHourSegs: function(wholeDay, businessHours) {
4573 return this.eventsToSegs(
4574 this.buildBusinessHourEvents(wholeDay, businessHours)
4575 );
4576 },
4577
4578
4579 // Compute business hour *events* for the grid's current date range.
4580 // Caller must ask if whole-day business hours are needed.
4581 // If no `businessHours` configuration value is specified, assumes the calendar default.
4582 buildBusinessHourEvents: function(wholeDay, businessHours) {
4583 var calendar = this.view.calendar;
4584 var events;
4585
4586 if (businessHours == null) {
4587 // fallback
4588 // access from calendawr. don't access from view. doesn't update with dynamic options.
4589 businessHours = calendar.options.businessHours;
4590 }
4591
4592 events = calendar.computeBusinessHourEvents(wholeDay, businessHours);
4593
4594 // HACK. Eventually refactor business hours "events" system.
4595 // If no events are given, but businessHours is activated, this means the entire visible range should be
4596 // marked as *not* business-hours, via inverse-background rendering.
4597 if (!events.length && businessHours) {
4598 events = [
4599 $.extend({}, BUSINESS_HOUR_EVENT_DEFAULTS, {
4600 start: this.view.end, // guaranteed out-of-range
4601 end: this.view.end, // "
4602 dow: null
4603 })
4604 ];
4605 }
4606
4607 return events;
4608 },
4609
4610
4611 /* Handlers
4612 ------------------------------------------------------------------------------------------------------------------*/
4613
4614
4615 // Attaches event-element-related handlers for *all* rendered event segments of the view.
4616 bindSegHandlers: function() {
4617 this.bindSegHandlersToEl(this.el);
4618 },
4619
4620
4621 // Attaches event-element-related handlers to an arbitrary container element. leverages bubbling.
4622 bindSegHandlersToEl: function(el) {
4623 this.bindSegHandlerToEl(el, 'touchstart', this.handleSegTouchStart);
4624 this.bindSegHandlerToEl(el, 'mouseenter', this.handleSegMouseover);
4625 this.bindSegHandlerToEl(el, 'mouseleave', this.handleSegMouseout);
4626 this.bindSegHandlerToEl(el, 'mousedown', this.handleSegMousedown);
4627 this.bindSegHandlerToEl(el, 'click', this.handleSegClick);
4628 },
4629
4630
4631 // Executes a handler for any a user-interaction on a segment.
4632 // Handler gets called with (seg, ev), and with the `this` context of the Grid
4633 bindSegHandlerToEl: function(el, name, handler) {
4634 var _this = this;
4635
4636 el.on(name, this.segSelector, function(ev) {
4637 var seg = $(this).data('fc-seg'); // grab segment data. put there by View::renderEvents
4638
4639 // only call the handlers if there is not a drag/resize in progress
4640 if (seg && !_this.isDraggingSeg && !_this.isResizingSeg) {
4641 return handler.call(_this, seg, ev); // context will be the Grid
4642 }
4643 });
4644 },
4645
4646
4647 handleSegClick: function(seg, ev) {
4648 var res = this.view.publiclyTrigger('eventClick', seg.el[0], seg.event, ev); // can return `false` to cancel
4649 if (res === false) {
4650 ev.preventDefault();
4651 }
4652 },
4653
4654
4655 // Updates internal state and triggers handlers for when an event element is moused over
4656 handleSegMouseover: function(seg, ev) {
4657 if (
4658 !GlobalEmitter.get().shouldIgnoreMouse() &&
4659 !this.mousedOverSeg
4660 ) {
4661 this.mousedOverSeg = seg;
4662 if (this.view.isEventResizable(seg.event)) {
4663 seg.el.addClass('fc-allow-mouse-resize');
4664 }
4665 this.view.publiclyTrigger('eventMouseover', seg.el[0], seg.event, ev);
4666 }
4667 },
4668
4669
4670 // Updates internal state and triggers handlers for when an event element is moused out.
4671 // Can be given no arguments, in which case it will mouseout the segment that was previously moused over.
4672 handleSegMouseout: function(seg, ev) {
4673 ev = ev || {}; // if given no args, make a mock mouse event
4674
4675 if (this.mousedOverSeg) {
4676 seg = seg || this.mousedOverSeg; // if given no args, use the currently moused-over segment
4677 this.mousedOverSeg = null;
4678 if (this.view.isEventResizable(seg.event)) {
4679 seg.el.removeClass('fc-allow-mouse-resize');
4680 }
4681 this.view.publiclyTrigger('eventMouseout', seg.el[0], seg.event, ev);
4682 }
4683 },
4684
4685
4686 handleSegMousedown: function(seg, ev) {
4687 var isResizing = this.startSegResize(seg, ev, { distance: 5 });
4688
4689 if (!isResizing && this.view.isEventDraggable(seg.event)) {
4690 this.buildSegDragListener(seg)
4691 .startInteraction(ev, {
4692 distance: 5
4693 });
4694 }
4695 },
4696
4697
4698 handleSegTouchStart: function(seg, ev) {
4699 var view = this.view;
4700 var event = seg.event;
4701 var isSelected = view.isEventSelected(event);
4702 var isDraggable = view.isEventDraggable(event);
4703 var isResizable = view.isEventResizable(event);
4704 var isResizing = false;
4705 var dragListener;
4706 var eventLongPressDelay;
4707
4708 if (isSelected && isResizable) {
4709 // only allow resizing of the event is selected
4710 isResizing = this.startSegResize(seg, ev);
4711 }
4712
4713 if (!isResizing && (isDraggable || isResizable)) { // allowed to be selected?
4714
4715 eventLongPressDelay = view.opt('eventLongPressDelay');
4716 if (eventLongPressDelay == null) {
4717 eventLongPressDelay = view.opt('longPressDelay'); // fallback
4718 }
4719
4720 dragListener = isDraggable ?
4721 this.buildSegDragListener(seg) :
4722 this.buildSegSelectListener(seg); // seg isn't draggable, but still needs to be selected
4723
4724 dragListener.startInteraction(ev, { // won't start if already started
4725 delay: isSelected ? 0 : eventLongPressDelay // do delay if not already selected
4726 });
4727 }
4728 },
4729
4730
4731 // returns boolean whether resizing actually started or not.
4732 // assumes the seg allows resizing.
4733 // `dragOptions` are optional.
4734 startSegResize: function(seg, ev, dragOptions) {
4735 if ($(ev.target).is('.fc-resizer')) {
4736 this.buildSegResizeListener(seg, $(ev.target).is('.fc-start-resizer'))
4737 .startInteraction(ev, dragOptions);
4738 return true;
4739 }
4740 return false;
4741 },
4742
4743
4744
4745 /* Event Dragging
4746 ------------------------------------------------------------------------------------------------------------------*/
4747
4748
4749 // Builds a listener that will track user-dragging on an event segment.
4750 // Generic enough to work with any type of Grid.
4751 // Has side effect of setting/unsetting `segDragListener`
4752 buildSegDragListener: function(seg) {
4753 var _this = this;
4754 var view = this.view;
4755 var calendar = view.calendar;
4756 var el = seg.el;
4757 var event = seg.event;
4758 var isDragging;
4759 var mouseFollower; // A clone of the original element that will move with the mouse
4760 var dropLocation; // zoned event date properties
4761
4762 if (this.segDragListener) {
4763 return this.segDragListener;
4764 }
4765
4766 // Tracks mouse movement over the *view's* coordinate map. Allows dragging and dropping between subcomponents
4767 // of the view.
4768 var dragListener = this.segDragListener = new HitDragListener(view, {
4769 scroll: view.opt('dragScroll'),
4770 subjectEl: el,
4771 subjectCenter: true,
4772 interactionStart: function(ev) {
4773 seg.component = _this; // for renderDrag
4774 isDragging = false;
4775 mouseFollower = new MouseFollower(seg.el, {
4776 additionalClass: 'fc-dragging',
4777 parentEl: view.el,
4778 opacity: dragListener.isTouch ? null : view.opt('dragOpacity'),
4779 revertDuration: view.opt('dragRevertDuration'),
4780 zIndex: 2 // one above the .fc-view
4781 });
4782 mouseFollower.hide(); // don't show until we know this is a real drag
4783 mouseFollower.start(ev);
4784 },
4785 dragStart: function(ev) {
4786 if (dragListener.isTouch && !view.isEventSelected(event)) {
4787 // if not previously selected, will fire after a delay. then, select the event
4788 view.selectEvent(event);
4789 }
4790 isDragging = true;
4791 _this.handleSegMouseout(seg, ev); // ensure a mouseout on the manipulated event has been reported
4792 _this.segDragStart(seg, ev);
4793 view.hideEvent(event); // hide all event segments. our mouseFollower will take over
4794 },
4795 hitOver: function(hit, isOrig, origHit) {
4796 var dragHelperEls;
4797
4798 // starting hit could be forced (DayGrid.limit)
4799 if (seg.hit) {
4800 origHit = seg.hit;
4801 }
4802
4803 // since we are querying the parent view, might not belong to this grid
4804 dropLocation = _this.computeEventDrop(
4805 origHit.component.getHitSpan(origHit),
4806 hit.component.getHitSpan(hit),
4807 event
4808 );
4809
4810 if (dropLocation && !calendar.isEventSpanAllowed(_this.eventToSpan(dropLocation), event)) {
4811 disableCursor();
4812 dropLocation = null;
4813 }
4814
4815 // if a valid drop location, have the subclass render a visual indication
4816 if (dropLocation && (dragHelperEls = view.renderDrag(dropLocation, seg))) {
4817
4818 dragHelperEls.addClass('fc-dragging');
4819 if (!dragListener.isTouch) {
4820 _this.applyDragOpacity(dragHelperEls);
4821 }
4822
4823 mouseFollower.hide(); // if the subclass is already using a mock event "helper", hide our own
4824 }
4825 else {
4826 mouseFollower.show(); // otherwise, have the helper follow the mouse (no snapping)
4827 }
4828
4829 if (isOrig) {
4830 dropLocation = null; // needs to have moved hits to be a valid drop
4831 }
4832 },
4833 hitOut: function() { // called before mouse moves to a different hit OR moved out of all hits
4834 view.unrenderDrag(); // unrender whatever was done in renderDrag
4835 mouseFollower.show(); // show in case we are moving out of all hits
4836 dropLocation = null;
4837 },
4838 hitDone: function() { // Called after a hitOut OR before a dragEnd
4839 enableCursor();
4840 },
4841 interactionEnd: function(ev) {
4842 delete seg.component; // prevent side effects
4843
4844 // do revert animation if hasn't changed. calls a callback when finished (whether animation or not)
4845 mouseFollower.stop(!dropLocation, function() {
4846 if (isDragging) {
4847 view.unrenderDrag();
4848 _this.segDragStop(seg, ev);
4849 }
4850
4851 if (dropLocation) {
4852 // no need to re-show original, will rerender all anyways. esp important if eventRenderWait
4853 view.reportSegDrop(seg, dropLocation, _this.largeUnit, el, ev);
4854 }
4855 else {
4856 view.showEvent(event);
4857 }
4858 });
4859 _this.segDragListener = null;
4860 }
4861 });
4862
4863 return dragListener;
4864 },
4865
4866
4867 // seg isn't draggable, but let's use a generic DragListener
4868 // simply for the delay, so it can be selected.
4869 // Has side effect of setting/unsetting `segDragListener`
4870 buildSegSelectListener: function(seg) {
4871 var _this = this;
4872 var view = this.view;
4873 var event = seg.event;
4874
4875 if (this.segDragListener) {
4876 return this.segDragListener;
4877 }
4878
4879 var dragListener = this.segDragListener = new DragListener({
4880 dragStart: function(ev) {
4881 if (dragListener.isTouch && !view.isEventSelected(event)) {
4882 // if not previously selected, will fire after a delay. then, select the event
4883 view.selectEvent(event);
4884 }
4885 },
4886 interactionEnd: function(ev) {
4887 _this.segDragListener = null;
4888 }
4889 });
4890
4891 return dragListener;
4892 },
4893
4894
4895 // Called before event segment dragging starts
4896 segDragStart: function(seg, ev) {
4897 this.isDraggingSeg = true;
4898 this.view.publiclyTrigger('eventDragStart', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy
4899 },
4900
4901
4902 // Called after event segment dragging stops
4903 segDragStop: function(seg, ev) {
4904 this.isDraggingSeg = false;
4905 this.view.publiclyTrigger('eventDragStop', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy
4906 },
4907
4908
4909 // Given the spans an event drag began, and the span event was dropped, calculates the new zoned start/end/allDay
4910 // values for the event. Subclasses may override and set additional properties to be used by renderDrag.
4911 // A falsy returned value indicates an invalid drop.
4912 // DOES NOT consider overlap/constraint.
4913 computeEventDrop: function(startSpan, endSpan, event) {
4914 var calendar = this.view.calendar;
4915 var dragStart = startSpan.start;
4916 var dragEnd = endSpan.start;
4917 var delta;
4918 var dropLocation; // zoned event date properties
4919
4920 if (dragStart.hasTime() === dragEnd.hasTime()) {
4921 delta = this.diffDates(dragEnd, dragStart);
4922
4923 // if an all-day event was in a timed area and it was dragged to a different time,
4924 // guarantee an end and adjust start/end to have times
4925 if (event.allDay && durationHasTime(delta)) {
4926 dropLocation = {
4927 start: event.start.clone(),
4928 end: calendar.getEventEnd(event), // will be an ambig day
4929 allDay: false // for normalizeEventTimes
4930 };
4931 calendar.normalizeEventTimes(dropLocation);
4932 }
4933 // othewise, work off existing values
4934 else {
4935 dropLocation = pluckEventDateProps(event);
4936 }
4937
4938 dropLocation.start.add(delta);
4939 if (dropLocation.end) {
4940 dropLocation.end.add(delta);
4941 }
4942 }
4943 else {
4944 // if switching from day <-> timed, start should be reset to the dropped date, and the end cleared
4945 dropLocation = {
4946 start: dragEnd.clone(),
4947 end: null, // end should be cleared
4948 allDay: !dragEnd.hasTime()
4949 };
4950 }
4951
4952 return dropLocation;
4953 },
4954
4955
4956 // Utility for apply dragOpacity to a jQuery set
4957 applyDragOpacity: function(els) {
4958 var opacity = this.view.opt('dragOpacity');
4959
4960 if (opacity != null) {
4961 els.css('opacity', opacity);
4962 }
4963 },
4964
4965
4966 /* External Element Dragging
4967 ------------------------------------------------------------------------------------------------------------------*/
4968
4969
4970 // Called when a jQuery UI drag is initiated anywhere in the DOM
4971 externalDragStart: function(ev, ui) {
4972 var view = this.view;
4973 var el;
4974 var accept;
4975
4976 if (view.opt('droppable')) { // only listen if this setting is on
4977 el = $((ui ? ui.item : null) || ev.target);
4978
4979 // Test that the dragged element passes the dropAccept selector or filter function.
4980 // FYI, the default is "*" (matches all)
4981 accept = view.opt('dropAccept');
4982 if ($.isFunction(accept) ? accept.call(el[0], el) : el.is(accept)) {
4983 if (!this.isDraggingExternal) { // prevent double-listening if fired twice
4984 this.listenToExternalDrag(el, ev, ui);
4985 }
4986 }
4987 }
4988 },
4989
4990
4991 // Called when a jQuery UI drag starts and it needs to be monitored for dropping
4992 listenToExternalDrag: function(el, ev, ui) {
4993 var _this = this;
4994 var calendar = this.view.calendar;
4995 var meta = getDraggedElMeta(el); // extra data about event drop, including possible event to create
4996 var dropLocation; // a null value signals an unsuccessful drag
4997
4998 // listener that tracks mouse movement over date-associated pixel regions
4999 var dragListener = _this.externalDragListener = new HitDragListener(this, {
5000 interactionStart: function() {
5001 _this.isDraggingExternal = true;
5002 },
5003 hitOver: function(hit) {
5004 dropLocation = _this.computeExternalDrop(
5005 hit.component.getHitSpan(hit), // since we are querying the parent view, might not belong to this grid
5006 meta
5007 );
5008
5009 if ( // invalid hit?
5010 dropLocation &&
5011 !calendar.isExternalSpanAllowed(_this.eventToSpan(dropLocation), dropLocation, meta.eventProps)
5012 ) {
5013 disableCursor();
5014 dropLocation = null;
5015 }
5016
5017 if (dropLocation) {
5018 _this.renderDrag(dropLocation); // called without a seg parameter
5019 }
5020 },
5021 hitOut: function() {
5022 dropLocation = null; // signal unsuccessful
5023 },
5024 hitDone: function() { // Called after a hitOut OR before a dragEnd
5025 enableCursor();
5026 _this.unrenderDrag();
5027 },
5028 interactionEnd: function(ev) {
5029 if (dropLocation) { // element was dropped on a valid hit
5030 _this.view.reportExternalDrop(meta, dropLocation, el, ev, ui);
5031 }
5032 _this.isDraggingExternal = false;
5033 _this.externalDragListener = null;
5034 }
5035 });
5036
5037 dragListener.startDrag(ev); // start listening immediately
5038 },
5039
5040
5041 // Given a hit to be dropped upon, and misc data associated with the jqui drag (guaranteed to be a plain object),
5042 // returns the zoned start/end dates for the event that would result from the hypothetical drop. end might be null.
5043 // Returning a null value signals an invalid drop hit.
5044 // DOES NOT consider overlap/constraint.
5045 computeExternalDrop: function(span, meta) {
5046 var calendar = this.view.calendar;
5047 var dropLocation = {
5048 start: calendar.applyTimezone(span.start), // simulate a zoned event start date
5049 end: null
5050 };
5051
5052 // if dropped on an all-day span, and element's metadata specified a time, set it
5053 if (meta.startTime && !dropLocation.start.hasTime()) {
5054 dropLocation.start.time(meta.startTime);
5055 }
5056
5057 if (meta.duration) {
5058 dropLocation.end = dropLocation.start.clone().add(meta.duration);
5059 }
5060
5061 return dropLocation;
5062 },
5063
5064
5065
5066 /* Drag Rendering (for both events and an external elements)
5067 ------------------------------------------------------------------------------------------------------------------*/
5068
5069
5070 // Renders a visual indication of an event or external element being dragged.
5071 // `dropLocation` contains hypothetical start/end/allDay values the event would have if dropped. end can be null.
5072 // `seg` is the internal segment object that is being dragged. If dragging an external element, `seg` is null.
5073 // A truthy returned value indicates this method has rendered a helper element.
5074 // Must return elements used for any mock events.
5075 renderDrag: function(dropLocation, seg) {
5076 // subclasses must implement
5077 },
5078
5079
5080 // Unrenders a visual indication of an event or external element being dragged
5081 unrenderDrag: function() {
5082 // subclasses must implement
5083 },
5084
5085
5086 /* Resizing
5087 ------------------------------------------------------------------------------------------------------------------*/
5088
5089
5090 // Creates a listener that tracks the user as they resize an event segment.
5091 // Generic enough to work with any type of Grid.
5092 buildSegResizeListener: function(seg, isStart) {
5093 var _this = this;
5094 var view = this.view;
5095 var calendar = view.calendar;
5096 var el = seg.el;
5097 var event = seg.event;
5098 var eventEnd = calendar.getEventEnd(event);
5099 var isDragging;
5100 var resizeLocation; // zoned event date properties. falsy if invalid resize
5101
5102 // Tracks mouse movement over the *grid's* coordinate map
5103 var dragListener = this.segResizeListener = new HitDragListener(this, {
5104 scroll: view.opt('dragScroll'),
5105 subjectEl: el,
5106 interactionStart: function() {
5107 isDragging = false;
5108 },
5109 dragStart: function(ev) {
5110 isDragging = true;
5111 _this.handleSegMouseout(seg, ev); // ensure a mouseout on the manipulated event has been reported
5112 _this.segResizeStart(seg, ev);
5113 },
5114 hitOver: function(hit, isOrig, origHit) {
5115 var origHitSpan = _this.getHitSpan(origHit);
5116 var hitSpan = _this.getHitSpan(hit);
5117
5118 resizeLocation = isStart ?
5119 _this.computeEventStartResize(origHitSpan, hitSpan, event) :
5120 _this.computeEventEndResize(origHitSpan, hitSpan, event);
5121
5122 if (resizeLocation) {
5123 if (!calendar.isEventSpanAllowed(_this.eventToSpan(resizeLocation), event)) {
5124 disableCursor();
5125 resizeLocation = null;
5126 }
5127 // no change? (FYI, event dates might have zones)
5128 else if (
5129 resizeLocation.start.isSame(event.start.clone().stripZone()) &&
5130 resizeLocation.end.isSame(eventEnd.clone().stripZone())
5131 ) {
5132 resizeLocation = null;
5133 }
5134 }
5135
5136 if (resizeLocation) {
5137 view.hideEvent(event);
5138 _this.renderEventResize(resizeLocation, seg);
5139 }
5140 },
5141 hitOut: function() { // called before mouse moves to a different hit OR moved out of all hits
5142 resizeLocation = null;
5143 view.showEvent(event); // for when out-of-bounds. show original
5144 },
5145 hitDone: function() { // resets the rendering to show the original event
5146 _this.unrenderEventResize();
5147 enableCursor();
5148 },
5149 interactionEnd: function(ev) {
5150 if (isDragging) {
5151 _this.segResizeStop(seg, ev);
5152 }
5153
5154 if (resizeLocation) { // valid date to resize to?
5155 // no need to re-show original, will rerender all anyways. esp important if eventRenderWait
5156 view.reportSegResize(seg, resizeLocation, _this.largeUnit, el, ev);
5157 }
5158 else {
5159 view.showEvent(event);
5160 }
5161 _this.segResizeListener = null;
5162 }
5163 });
5164
5165 return dragListener;
5166 },
5167
5168
5169 // Called before event segment resizing starts
5170 segResizeStart: function(seg, ev) {
5171 this.isResizingSeg = true;
5172 this.view.publiclyTrigger('eventResizeStart', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy
5173 },
5174
5175
5176 // Called after event segment resizing stops
5177 segResizeStop: function(seg, ev) {
5178 this.isResizingSeg = false;
5179 this.view.publiclyTrigger('eventResizeStop', seg.el[0], seg.event, ev, {}); // last argument is jqui dummy
5180 },
5181
5182
5183 // Returns new date-information for an event segment being resized from its start
5184 computeEventStartResize: function(startSpan, endSpan, event) {
5185 return this.computeEventResize('start', startSpan, endSpan, event);
5186 },
5187
5188
5189 // Returns new date-information for an event segment being resized from its end
5190 computeEventEndResize: function(startSpan, endSpan, event) {
5191 return this.computeEventResize('end', startSpan, endSpan, event);
5192 },
5193
5194
5195 // Returns new zoned date information for an event segment being resized from its start OR end
5196 // `type` is either 'start' or 'end'.
5197 // DOES NOT consider overlap/constraint.
5198 computeEventResize: function(type, startSpan, endSpan, event) {
5199 var calendar = this.view.calendar;
5200 var delta = this.diffDates(endSpan[type], startSpan[type]);
5201 var resizeLocation; // zoned event date properties
5202 var defaultDuration;
5203
5204 // build original values to work from, guaranteeing a start and end
5205 resizeLocation = {
5206 start: event.start.clone(),
5207 end: calendar.getEventEnd(event),
5208 allDay: event.allDay
5209 };
5210
5211 // if an all-day event was in a timed area and was resized to a time, adjust start/end to have times
5212 if (resizeLocation.allDay && durationHasTime(delta)) {
5213 resizeLocation.allDay = false;
5214 calendar.normalizeEventTimes(resizeLocation);
5215 }
5216
5217 resizeLocation[type].add(delta); // apply delta to start or end
5218
5219 // if the event was compressed too small, find a new reasonable duration for it
5220 if (!resizeLocation.start.isBefore(resizeLocation.end)) {
5221
5222 defaultDuration =
5223 this.minResizeDuration || // TODO: hack
5224 (event.allDay ?
5225 calendar.defaultAllDayEventDuration :
5226 calendar.defaultTimedEventDuration);
5227
5228 if (type == 'start') { // resizing the start?
5229 resizeLocation.start = resizeLocation.end.clone().subtract(defaultDuration);
5230 }
5231 else { // resizing the end?
5232 resizeLocation.end = resizeLocation.start.clone().add(defaultDuration);
5233 }
5234 }
5235
5236 return resizeLocation;
5237 },
5238
5239
5240 // Renders a visual indication of an event being resized.
5241 // `range` has the updated dates of the event. `seg` is the original segment object involved in the drag.
5242 // Must return elements used for any mock events.
5243 renderEventResize: function(range, seg) {
5244 // subclasses must implement
5245 },
5246
5247
5248 // Unrenders a visual indication of an event being resized.
5249 unrenderEventResize: function() {
5250 // subclasses must implement
5251 },
5252
5253
5254 /* Rendering Utils
5255 ------------------------------------------------------------------------------------------------------------------*/
5256
5257
5258 // Compute the text that should be displayed on an event's element.
5259 // `range` can be the Event object itself, or something range-like, with at least a `start`.
5260 // If event times are disabled, or the event has no time, will return a blank string.
5261 // If not specified, formatStr will default to the eventTimeFormat setting,
5262 // and displayEnd will default to the displayEventEnd setting.
5263 getEventTimeText: function(range, formatStr, displayEnd) {
5264
5265 if (formatStr == null) {
5266 formatStr = this.eventTimeFormat;
5267 }
5268
5269 if (displayEnd == null) {
5270 displayEnd = this.displayEventEnd;
5271 }
5272
5273 if (this.displayEventTime && range.start.hasTime()) {
5274 if (displayEnd && range.end) {
5275 return this.view.formatRange(range, formatStr);
5276 }
5277 else {
5278 return range.start.format(formatStr);
5279 }
5280 }
5281
5282 return '';
5283 },
5284
5285
5286 // Generic utility for generating the HTML classNames for an event segment's element
5287 getSegClasses: function(seg, isDraggable, isResizable) {
5288 var view = this.view;
5289 var classes = [
5290 'fc-event',
5291 seg.isStart ? 'fc-start' : 'fc-not-start',
5292 seg.isEnd ? 'fc-end' : 'fc-not-end'
5293 ].concat(this.getSegCustomClasses(seg));
5294
5295 if (isDraggable) {
5296 classes.push('fc-draggable');
5297 }
5298 if (isResizable) {
5299 classes.push('fc-resizable');
5300 }
5301
5302 // event is currently selected? attach a className.
5303 if (view.isEventSelected(seg.event)) {
5304 classes.push('fc-selected');
5305 }
5306
5307 return classes;
5308 },
5309
5310
5311 // List of classes that were defined by the caller of the API in some way
5312 getSegCustomClasses: function(seg) {
5313 var event = seg.event;
5314
5315 return [].concat(
5316 event.className, // guaranteed to be an array
5317 event.source ? event.source.className : []
5318 );
5319 },
5320
5321
5322 // Utility for generating event skin-related CSS properties
5323 getSegSkinCss: function(seg) {
5324 return {
5325 'background-color': this.getSegBackgroundColor(seg),
5326 'border-color': this.getSegBorderColor(seg),
5327 color: this.getSegTextColor(seg)
5328 };
5329 },
5330
5331
5332 // Queries for caller-specified color, then falls back to default
5333 getSegBackgroundColor: function(seg) {
5334 return seg.event.backgroundColor ||
5335 seg.event.color ||
5336 this.getSegDefaultBackgroundColor(seg);
5337 },
5338
5339
5340 getSegDefaultBackgroundColor: function(seg) {
5341 var source = seg.event.source || {};
5342
5343 return source.backgroundColor ||
5344 source.color ||
5345 this.view.opt('eventBackgroundColor') ||
5346 this.view.opt('eventColor');
5347 },
5348
5349
5350 // Queries for caller-specified color, then falls back to default
5351 getSegBorderColor: function(seg) {
5352 return seg.event.borderColor ||
5353 seg.event.color ||
5354 this.getSegDefaultBorderColor(seg);
5355 },
5356
5357
5358 getSegDefaultBorderColor: function(seg) {
5359 var source = seg.event.source || {};
5360
5361 return source.borderColor ||
5362 source.color ||
5363 this.view.opt('eventBorderColor') ||
5364 this.view.opt('eventColor');
5365 },
5366
5367
5368 // Queries for caller-specified color, then falls back to default
5369 getSegTextColor: function(seg) {
5370 return seg.event.textColor ||
5371 this.getSegDefaultTextColor(seg);
5372 },
5373
5374
5375 getSegDefaultTextColor: function(seg) {
5376 var source = seg.event.source || {};
5377
5378 return source.textColor ||
5379 this.view.opt('eventTextColor');
5380 },
5381
5382
5383 /* Converting events -> eventRange -> eventSpan -> eventSegs
5384 ------------------------------------------------------------------------------------------------------------------*/
5385
5386
5387 // Generates an array of segments for the given single event
5388 // Can accept an event "location" as well (which only has start/end and no allDay)
5389 eventToSegs: function(event) {
5390 return this.eventsToSegs([ event ]);
5391 },
5392
5393
5394 eventToSpan: function(event) {
5395 return this.eventToSpans(event)[0];
5396 },
5397
5398
5399 // Generates spans (always unzoned) for the given event.
5400 // Does not do any inverting for inverse-background events.
5401 // Can accept an event "location" as well (which only has start/end and no allDay)
5402 eventToSpans: function(event) {
5403 var range = this.eventToRange(event);
5404 return this.eventRangeToSpans(range, event);
5405 },
5406
5407
5408
5409 // Converts an array of event objects into an array of event segment objects.
5410 // A custom `segSliceFunc` may be given for arbitrarily slicing up events.
5411 // Doesn't guarantee an order for the resulting array.
5412 eventsToSegs: function(allEvents, segSliceFunc) {
5413 var _this = this;
5414 var eventsById = groupEventsById(allEvents);
5415 var segs = [];
5416
5417 $.each(eventsById, function(id, events) {
5418 var ranges = [];
5419 var i;
5420
5421 for (i = 0; i < events.length; i++) {
5422 ranges.push(_this.eventToRange(events[i]));
5423 }
5424
5425 // inverse-background events (utilize only the first event in calculations)
5426 if (isInverseBgEvent(events[0])) {
5427 ranges = _this.invertRanges(ranges);
5428
5429 for (i = 0; i < ranges.length; i++) {
5430 segs.push.apply(segs, // append to
5431 _this.eventRangeToSegs(ranges[i], events[0], segSliceFunc));
5432 }
5433 }
5434 // normal event ranges
5435 else {
5436 for (i = 0; i < ranges.length; i++) {
5437 segs.push.apply(segs, // append to
5438 _this.eventRangeToSegs(ranges[i], events[i], segSliceFunc));
5439 }
5440 }
5441 });
5442
5443 return segs;
5444 },
5445
5446
5447 // Generates the unzoned start/end dates an event appears to occupy
5448 // Can accept an event "location" as well (which only has start/end and no allDay)
5449 eventToRange: function(event) {
5450 var calendar = this.view.calendar;
5451 var start = event.start.clone().stripZone();
5452 var end = (
5453 event.end ?
5454 event.end.clone() :
5455 // derive the end from the start and allDay. compute allDay if necessary
5456 calendar.getDefaultEventEnd(
5457 event.allDay != null ?
5458 event.allDay :
5459 !event.start.hasTime(),
5460 event.start
5461 )
5462 ).stripZone();
5463
5464 // hack: dynamic locale change forgets to upate stored event localed
5465 calendar.localizeMoment(start);
5466 calendar.localizeMoment(end);
5467
5468 return { start: start, end: end };
5469 },
5470
5471
5472 // Given an event's range (unzoned start/end), and the event itself,
5473 // slice into segments (using the segSliceFunc function if specified)
5474 eventRangeToSegs: function(range, event, segSliceFunc) {
5475 var spans = this.eventRangeToSpans(range, event);
5476 var segs = [];
5477 var i;
5478
5479 for (i = 0; i < spans.length; i++) {
5480 segs.push.apply(segs, // append to
5481 this.eventSpanToSegs(spans[i], event, segSliceFunc));
5482 }
5483
5484 return segs;
5485 },
5486
5487
5488 // Given an event's unzoned date range, return an array of "span" objects.
5489 // Subclasses can override.
5490 eventRangeToSpans: function(range, event) {
5491 return [ $.extend({}, range) ]; // copy into a single-item array
5492 },
5493
5494
5495 // Given an event's span (unzoned start/end and other misc data), and the event itself,
5496 // slices into segments and attaches event-derived properties to them.
5497 eventSpanToSegs: function(span, event, segSliceFunc) {
5498 var segs = segSliceFunc ? segSliceFunc(span) : this.spanToSegs(span);
5499 var i, seg;
5500
5501 for (i = 0; i < segs.length; i++) {
5502 seg = segs[i];
5503 seg.event = event;
5504 seg.eventStartMS = +span.start; // TODO: not the best name after making spans unzoned
5505 seg.eventDurationMS = span.end - span.start;
5506 }
5507
5508 return segs;
5509 },
5510
5511
5512 // Produces a new array of range objects that will cover all the time NOT covered by the given ranges.
5513 // SIDE EFFECT: will mutate the given array and will use its date references.
5514 invertRanges: function(ranges) {
5515 var view = this.view;
5516 var viewStart = view.start.clone(); // need a copy
5517 var viewEnd = view.end.clone(); // need a copy
5518 var inverseRanges = [];
5519 var start = viewStart; // the end of the previous range. the start of the new range
5520 var i, range;
5521
5522 // ranges need to be in order. required for our date-walking algorithm
5523 ranges.sort(compareRanges);
5524
5525 for (i = 0; i < ranges.length; i++) {
5526 range = ranges[i];
5527
5528 // add the span of time before the event (if there is any)
5529 if (range.start > start) { // compare millisecond time (skip any ambig logic)
5530 inverseRanges.push({
5531 start: start,
5532 end: range.start
5533 });
5534 }
5535
5536 start = range.end;
5537 }
5538
5539 // add the span of time after the last event (if there is any)
5540 if (start < viewEnd) { // compare millisecond time (skip any ambig logic)
5541 inverseRanges.push({
5542 start: start,
5543 end: viewEnd
5544 });
5545 }
5546
5547 return inverseRanges;
5548 },
5549
5550
5551 sortEventSegs: function(segs) {
5552 segs.sort(proxy(this, 'compareEventSegs'));
5553 },
5554
5555
5556 // A cmp function for determining which segments should take visual priority
5557 compareEventSegs: function(seg1, seg2) {
5558 return seg1.eventStartMS - seg2.eventStartMS || // earlier events go first
5559 seg2.eventDurationMS - seg1.eventDurationMS || // tie? longer events go first
5560 seg2.event.allDay - seg1.event.allDay || // tie? put all-day events first (booleans cast to 0/1)
5561 compareByFieldSpecs(seg1.event, seg2.event, this.view.eventOrderSpecs);
5562 }
5563
5564 });
5565
5566
5567 /* Utilities
5568 ----------------------------------------------------------------------------------------------------------------------*/
5569
5570
5571 function pluckEventDateProps(event) {
5572 return {
5573 start: event.start.clone(),
5574 end: event.end ? event.end.clone() : null,
5575 allDay: event.allDay // keep it the same
5576 };
5577 }
5578 FC.pluckEventDateProps = pluckEventDateProps;
5579
5580
5581 function isBgEvent(event) { // returns true if background OR inverse-background
5582 var rendering = getEventRendering(event);
5583 return rendering === 'background' || rendering === 'inverse-background';
5584 }
5585 FC.isBgEvent = isBgEvent; // export
5586
5587
5588 function isInverseBgEvent(event) {
5589 return getEventRendering(event) === 'inverse-background';
5590 }
5591
5592
5593 function getEventRendering(event) {
5594 return firstDefined((event.source || {}).rendering, event.rendering);
5595 }
5596
5597
5598 function groupEventsById(events) {
5599 var eventsById = {};
5600 var i, event;
5601
5602 for (i = 0; i < events.length; i++) {
5603 event = events[i];
5604 (eventsById[event._id] || (eventsById[event._id] = [])).push(event);
5605 }
5606
5607 return eventsById;
5608 }
5609
5610
5611 // A cmp function for determining which non-inverted "ranges" (see above) happen earlier
5612 function compareRanges(range1, range2) {
5613 return range1.start - range2.start; // earlier ranges go first
5614 }
5615
5616
5617 /* External-Dragging-Element Data
5618 ----------------------------------------------------------------------------------------------------------------------*/
5619
5620 // Require all HTML5 data-* attributes used by FullCalendar to have this prefix.
5621 // A value of '' will query attributes like data-event. A value of 'fc' will query attributes like data-fc-event.
5622 FC.dataAttrPrefix = '';
5623
5624 // Given a jQuery element that might represent a dragged FullCalendar event, returns an intermediate data structure
5625 // to be used for Event Object creation.
5626 // A defined `.eventProps`, even when empty, indicates that an event should be created.
5627 function getDraggedElMeta(el) {
5628 var prefix = FC.dataAttrPrefix;
5629 var eventProps; // properties for creating the event, not related to date/time
5630 var startTime; // a Duration
5631 var duration;
5632 var stick;
5633
5634 if (prefix) { prefix += '-'; }
5635 eventProps = el.data(prefix + 'event') || null;
5636
5637 if (eventProps) {
5638 if (typeof eventProps === 'object') {
5639 eventProps = $.extend({}, eventProps); // make a copy
5640 }
5641 else { // something like 1 or true. still signal event creation
5642 eventProps = {};
5643 }
5644
5645 // pluck special-cased date/time properties
5646 startTime = eventProps.start;
5647 if (startTime == null) { startTime = eventProps.time; } // accept 'time' as well
5648 duration = eventProps.duration;
5649 stick = eventProps.stick;
5650 delete eventProps.start;
5651 delete eventProps.time;
5652 delete eventProps.duration;
5653 delete eventProps.stick;
5654 }
5655
5656 // fallback to standalone attribute values for each of the date/time properties
5657 if (startTime == null) { startTime = el.data(prefix + 'start'); }
5658 if (startTime == null) { startTime = el.data(prefix + 'time'); } // accept 'time' as well
5659 if (duration == null) { duration = el.data(prefix + 'duration'); }
5660 if (stick == null) { stick = el.data(prefix + 'stick'); }
5661
5662 // massage into correct data types
5663 startTime = startTime != null ? moment.duration(startTime) : null;
5664 duration = duration != null ? moment.duration(duration) : null;
5665 stick = Boolean(stick);
5666
5667 return { eventProps: eventProps, startTime: startTime, duration: duration, stick: stick };
5668 }
5669
5670
5671 ;;
5672
5673 /*
5674 A set of rendering and date-related methods for a visual component comprised of one or more rows of day columns.
5675 Prerequisite: the object being mixed into needs to be a *Grid*
5676 */
5677 var DayTableMixin = FC.DayTableMixin = {
5678
5679 breakOnWeeks: false, // should create a new row for each week?
5680 dayDates: null, // whole-day dates for each column. left to right
5681 dayIndices: null, // for each day from start, the offset
5682 daysPerRow: null,
5683 rowCnt: null,
5684 colCnt: null,
5685 colHeadFormat: null,
5686
5687
5688 // Populates internal variables used for date calculation and rendering
5689 updateDayTable: function() {
5690 var view = this.view;
5691 var date = this.start.clone();
5692 var dayIndex = -1;
5693 var dayIndices = [];
5694 var dayDates = [];
5695 var daysPerRow;
5696 var firstDay;
5697 var rowCnt;
5698
5699 while (date.isBefore(this.end)) { // loop each day from start to end
5700 if (view.isHiddenDay(date)) {
5701 dayIndices.push(dayIndex + 0.5); // mark that it's between indices
5702 }
5703 else {
5704 dayIndex++;
5705 dayIndices.push(dayIndex);
5706 dayDates.push(date.clone());
5707 }
5708 date.add(1, 'days');
5709 }
5710
5711 if (this.breakOnWeeks) {
5712 // count columns until the day-of-week repeats
5713 firstDay = dayDates[0].day();
5714 for (daysPerRow = 1; daysPerRow < dayDates.length; daysPerRow++) {
5715 if (dayDates[daysPerRow].day() == firstDay) {
5716 break;
5717 }
5718 }
5719 rowCnt = Math.ceil(dayDates.length / daysPerRow);
5720 }
5721 else {
5722 rowCnt = 1;
5723 daysPerRow = dayDates.length;
5724 }
5725
5726 this.dayDates = dayDates;
5727 this.dayIndices = dayIndices;
5728 this.daysPerRow = daysPerRow;
5729 this.rowCnt = rowCnt;
5730
5731 this.updateDayTableCols();
5732 },
5733
5734
5735 // Computes and assigned the colCnt property and updates any options that may be computed from it
5736 updateDayTableCols: function() {
5737 this.colCnt = this.computeColCnt();
5738 this.colHeadFormat = this.view.opt('columnFormat') || this.computeColHeadFormat();
5739 },
5740
5741
5742 // Determines how many columns there should be in the table
5743 computeColCnt: function() {
5744 return this.daysPerRow;
5745 },
5746
5747
5748 // Computes the ambiguously-timed moment for the given cell
5749 getCellDate: function(row, col) {
5750 return this.dayDates[
5751 this.getCellDayIndex(row, col)
5752 ].clone();
5753 },
5754
5755
5756 // Computes the ambiguously-timed date range for the given cell
5757 getCellRange: function(row, col) {
5758 var start = this.getCellDate(row, col);
5759 var end = start.clone().add(1, 'days');
5760
5761 return { start: start, end: end };
5762 },
5763
5764
5765 // Returns the number of day cells, chronologically, from the first of the grid (0-based)
5766 getCellDayIndex: function(row, col) {
5767 return row * this.daysPerRow + this.getColDayIndex(col);
5768 },
5769
5770
5771 // Returns the numner of day cells, chronologically, from the first cell in *any given row*
5772 getColDayIndex: function(col) {
5773 if (this.isRTL) {
5774 return this.colCnt - 1 - col;
5775 }
5776 else {
5777 return col;
5778 }
5779 },
5780
5781
5782 // Given a date, returns its chronolocial cell-index from the first cell of the grid.
5783 // If the date lies between cells (because of hiddenDays), returns a floating-point value between offsets.
5784 // If before the first offset, returns a negative number.
5785 // If after the last offset, returns an offset past the last cell offset.
5786 // Only works for *start* dates of cells. Will not work for exclusive end dates for cells.
5787 getDateDayIndex: function(date) {
5788 var dayIndices = this.dayIndices;
5789 var dayOffset = date.diff(this.start, 'days');
5790
5791 if (dayOffset < 0) {
5792 return dayIndices[0] - 1;
5793 }
5794 else if (dayOffset >= dayIndices.length) {
5795 return dayIndices[dayIndices.length - 1] + 1;
5796 }
5797 else {
5798 return dayIndices[dayOffset];
5799 }
5800 },
5801
5802
5803 /* Options
5804 ------------------------------------------------------------------------------------------------------------------*/
5805
5806
5807 // Computes a default column header formatting string if `colFormat` is not explicitly defined
5808 computeColHeadFormat: function() {
5809 // if more than one week row, or if there are a lot of columns with not much space,
5810 // put just the day numbers will be in each cell
5811 if (this.rowCnt > 1 || this.colCnt > 10) {
5812 return 'ddd'; // "Sat"
5813 }
5814 // multiple days, so full single date string WON'T be in title text
5815 else if (this.colCnt > 1) {
5816 return this.view.opt('dayOfMonthFormat'); // "Sat 12/10"
5817 }
5818 // single day, so full single date string will probably be in title text
5819 else {
5820 return 'dddd'; // "Saturday"
5821 }
5822 },
5823
5824
5825 /* Slicing
5826 ------------------------------------------------------------------------------------------------------------------*/
5827
5828
5829 // Slices up a date range into a segment for every week-row it intersects with
5830 sliceRangeByRow: function(range) {
5831 var daysPerRow = this.daysPerRow;
5832 var normalRange = this.view.computeDayRange(range); // make whole-day range, considering nextDayThreshold
5833 var rangeFirst = this.getDateDayIndex(normalRange.start); // inclusive first index
5834 var rangeLast = this.getDateDayIndex(normalRange.end.clone().subtract(1, 'days')); // inclusive last index
5835 var segs = [];
5836 var row;
5837 var rowFirst, rowLast; // inclusive day-index range for current row
5838 var segFirst, segLast; // inclusive day-index range for segment
5839
5840 for (row = 0; row < this.rowCnt; row++) {
5841 rowFirst = row * daysPerRow;
5842 rowLast = rowFirst + daysPerRow - 1;
5843
5844 // intersect segment's offset range with the row's
5845 segFirst = Math.max(rangeFirst, rowFirst);
5846 segLast = Math.min(rangeLast, rowLast);
5847
5848 // deal with in-between indices
5849 segFirst = Math.ceil(segFirst); // in-between starts round to next cell
5850 segLast = Math.floor(segLast); // in-between ends round to prev cell
5851
5852 if (segFirst <= segLast) { // was there any intersection with the current row?
5853 segs.push({
5854 row: row,
5855
5856 // normalize to start of row
5857 firstRowDayIndex: segFirst - rowFirst,
5858 lastRowDayIndex: segLast - rowFirst,
5859
5860 // must be matching integers to be the segment's start/end
5861 isStart: segFirst === rangeFirst,
5862 isEnd: segLast === rangeLast
5863 });
5864 }
5865 }
5866
5867 return segs;
5868 },
5869
5870
5871 // Slices up a date range into a segment for every day-cell it intersects with.
5872 // TODO: make more DRY with sliceRangeByRow somehow.
5873 sliceRangeByDay: function(range) {
5874 var daysPerRow = this.daysPerRow;
5875 var normalRange = this.view.computeDayRange(range); // make whole-day range, considering nextDayThreshold
5876 var rangeFirst = this.getDateDayIndex(normalRange.start); // inclusive first index
5877 var rangeLast = this.getDateDayIndex(normalRange.end.clone().subtract(1, 'days')); // inclusive last index
5878 var segs = [];
5879 var row;
5880 var rowFirst, rowLast; // inclusive day-index range for current row
5881 var i;
5882 var segFirst, segLast; // inclusive day-index range for segment
5883
5884 for (row = 0; row < this.rowCnt; row++) {
5885 rowFirst = row * daysPerRow;
5886 rowLast = rowFirst + daysPerRow - 1;
5887
5888 for (i = rowFirst; i <= rowLast; i++) {
5889
5890 // intersect segment's offset range with the row's
5891 segFirst = Math.max(rangeFirst, i);
5892 segLast = Math.min(rangeLast, i);
5893
5894 // deal with in-between indices
5895 segFirst = Math.ceil(segFirst); // in-between starts round to next cell
5896 segLast = Math.floor(segLast); // in-between ends round to prev cell
5897
5898 if (segFirst <= segLast) { // was there any intersection with the current row?
5899 segs.push({
5900 row: row,
5901
5902 // normalize to start of row
5903 firstRowDayIndex: segFirst - rowFirst,
5904 lastRowDayIndex: segLast - rowFirst,
5905
5906 // must be matching integers to be the segment's start/end
5907 isStart: segFirst === rangeFirst,
5908 isEnd: segLast === rangeLast
5909 });
5910 }
5911 }
5912 }
5913
5914 return segs;
5915 },
5916
5917
5918 /* Header Rendering
5919 ------------------------------------------------------------------------------------------------------------------*/
5920
5921
5922 renderHeadHtml: function() {
5923 var view = this.view;
5924
5925 return '' +
5926 '<div class="fc-row ' + view.widgetHeaderClass + '">' +
5927 '<table>' +
5928 '<thead>' +
5929 this.renderHeadTrHtml() +
5930 '</thead>' +
5931 '</table>' +
5932 '</div>';
5933 },
5934
5935
5936 renderHeadIntroHtml: function() {
5937 return this.renderIntroHtml(); // fall back to generic
5938 },
5939
5940
5941 renderHeadTrHtml: function() {
5942 return '' +
5943 '<tr>' +
5944 (this.isRTL ? '' : this.renderHeadIntroHtml()) +
5945 this.renderHeadDateCellsHtml() +
5946 (this.isRTL ? this.renderHeadIntroHtml() : '') +
5947 '</tr>';
5948 },
5949
5950
5951 renderHeadDateCellsHtml: function() {
5952 var htmls = [];
5953 var col, date;
5954
5955 for (col = 0; col < this.colCnt; col++) {
5956 date = this.getCellDate(0, col);
5957 htmls.push(this.renderHeadDateCellHtml(date));
5958 }
5959
5960 return htmls.join('');
5961 },
5962
5963
5964 // TODO: when internalApiVersion, accept an object for HTML attributes
5965 // (colspan should be no different)
5966 renderHeadDateCellHtml: function(date, colspan, otherAttrs) {
5967 var view = this.view;
5968 var classNames = [
5969 'fc-day-header',
5970 view.widgetHeaderClass
5971 ];
5972
5973 // if only one row of days, the classNames on the header can represent the specific days beneath
5974 if (this.rowCnt === 1) {
5975 classNames = classNames.concat(
5976 // includes the day-of-week class
5977 // noThemeHighlight=true (don't highlight the header)
5978 this.getDayClasses(date, true)
5979 );
5980 }
5981 else {
5982 classNames.push('fc-' + dayIDs[date.day()]); // only add the day-of-week class
5983 }
5984
5985 return '' +
5986 '<th class="' + classNames.join(' ') + '"' +
5987 (this.rowCnt === 1 ?
5988 ' data-date="' + date.format('YYYY-MM-DD') + '"' :
5989 '') +
5990 (colspan > 1 ?
5991 ' colspan="' + colspan + '"' :
5992 '') +
5993 (otherAttrs ?
5994 ' ' + otherAttrs :
5995 '') +
5996 '>' +
5997 // don't make a link if the heading could represent multiple days, or if there's only one day (forceOff)
5998 view.buildGotoAnchorHtml(
5999 { date: date, forceOff: this.rowCnt > 1 || this.colCnt === 1 },
6000 htmlEscape(date.format(this.colHeadFormat)) // inner HTML
6001 ) +
6002 '</th>';
6003 },
6004
6005
6006 /* Background Rendering
6007 ------------------------------------------------------------------------------------------------------------------*/
6008
6009
6010 renderBgTrHtml: function(row) {
6011 return '' +
6012 '<tr>' +
6013 (this.isRTL ? '' : this.renderBgIntroHtml(row)) +
6014 this.renderBgCellsHtml(row) +
6015 (this.isRTL ? this.renderBgIntroHtml(row) : '') +
6016 '</tr>';
6017 },
6018
6019
6020 renderBgIntroHtml: function(row) {
6021 return this.renderIntroHtml(); // fall back to generic
6022 },
6023
6024
6025 renderBgCellsHtml: function(row) {
6026 var htmls = [];
6027 var col, date;
6028
6029 for (col = 0; col < this.colCnt; col++) {
6030 date = this.getCellDate(row, col);
6031 htmls.push(this.renderBgCellHtml(date));
6032 }
6033
6034 return htmls.join('');
6035 },
6036
6037
6038 renderBgCellHtml: function(date, otherAttrs) {
6039 var view = this.view;
6040 var classes = this.getDayClasses(date);
6041
6042 classes.unshift('fc-day', view.widgetContentClass);
6043
6044 return '<td class="' + classes.join(' ') + '"' +
6045 ' data-date="' + date.format('YYYY-MM-DD') + '"' + // if date has a time, won't format it
6046 (otherAttrs ?
6047 ' ' + otherAttrs :
6048 '') +
6049 '></td>';
6050 },
6051
6052
6053 /* Generic
6054 ------------------------------------------------------------------------------------------------------------------*/
6055
6056
6057 // Generates the default HTML intro for any row. User classes should override
6058 renderIntroHtml: function() {
6059 },
6060
6061
6062 // TODO: a generic method for dealing with <tr>, RTL, intro
6063 // when increment internalApiVersion
6064 // wrapTr (scheduler)
6065
6066
6067 /* Utils
6068 ------------------------------------------------------------------------------------------------------------------*/
6069
6070
6071 // Applies the generic "intro" and "outro" HTML to the given cells.
6072 // Intro means the leftmost cell when the calendar is LTR and the rightmost cell when RTL. Vice-versa for outro.
6073 bookendCells: function(trEl) {
6074 var introHtml = this.renderIntroHtml();
6075
6076 if (introHtml) {
6077 if (this.isRTL) {
6078 trEl.append(introHtml);
6079 }
6080 else {
6081 trEl.prepend(introHtml);
6082 }
6083 }
6084 }
6085
6086 };
6087
6088 ;;
6089
6090 /* A component that renders a grid of whole-days that runs horizontally. There can be multiple rows, one per week.
6091 ----------------------------------------------------------------------------------------------------------------------*/
6092
6093 var DayGrid = FC.DayGrid = Grid.extend(DayTableMixin, {
6094
6095 numbersVisible: false, // should render a row for day/week numbers? set by outside view. TODO: make internal
6096 bottomCoordPadding: 0, // hack for extending the hit area for the last row of the coordinate grid
6097
6098 rowEls: null, // set of fake row elements
6099 cellEls: null, // set of whole-day elements comprising the row's background
6100 helperEls: null, // set of cell skeleton elements for rendering the mock event "helper"
6101
6102 rowCoordCache: null,
6103 colCoordCache: null,
6104
6105
6106 // Renders the rows and columns into the component's `this.el`, which should already be assigned.
6107 // isRigid determins whether the individual rows should ignore the contents and be a constant height.
6108 // Relies on the view's colCnt and rowCnt. In the future, this component should probably be self-sufficient.
6109 renderDates: function(isRigid) {
6110 var view = this.view;
6111 var rowCnt = this.rowCnt;
6112 var colCnt = this.colCnt;
6113 var html = '';
6114 var row;
6115 var col;
6116
6117 for (row = 0; row < rowCnt; row++) {
6118 html += this.renderDayRowHtml(row, isRigid);
6119 }
6120 this.el.html(html);
6121
6122 this.rowEls = this.el.find('.fc-row');
6123 this.cellEls = this.el.find('.fc-day');
6124
6125 this.rowCoordCache = new CoordCache({
6126 els: this.rowEls,
6127 isVertical: true
6128 });
6129 this.colCoordCache = new CoordCache({
6130 els: this.cellEls.slice(0, this.colCnt), // only the first row
6131 isHorizontal: true
6132 });
6133
6134 // trigger dayRender with each cell's element
6135 for (row = 0; row < rowCnt; row++) {
6136 for (col = 0; col < colCnt; col++) {
6137 view.publiclyTrigger(
6138 'dayRender',
6139 null,
6140 this.getCellDate(row, col),
6141 this.getCellEl(row, col)
6142 );
6143 }
6144 }
6145 },
6146
6147
6148 unrenderDates: function() {
6149 this.removeSegPopover();
6150 },
6151
6152
6153 renderBusinessHours: function() {
6154 var segs = this.buildBusinessHourSegs(true); // wholeDay=true
6155 this.renderFill('businessHours', segs, 'bgevent');
6156 },
6157
6158
6159 unrenderBusinessHours: function() {
6160 this.unrenderFill('businessHours');
6161 },
6162
6163
6164 // Generates the HTML for a single row, which is a div that wraps a table.
6165 // `row` is the row number.
6166 renderDayRowHtml: function(row, isRigid) {
6167 var view = this.view;
6168 var classes = [ 'fc-row', 'fc-week', view.widgetContentClass ];
6169
6170 if (isRigid) {
6171 classes.push('fc-rigid');
6172 }
6173
6174 return '' +
6175 '<div class="' + classes.join(' ') + '">' +
6176 '<div class="fc-bg">' +
6177 '<table>' +
6178 this.renderBgTrHtml(row) +
6179 '</table>' +
6180 '</div>' +
6181 '<div class="fc-content-skeleton">' +
6182 '<table>' +
6183 (this.numbersVisible ?
6184 '<thead>' +
6185 this.renderNumberTrHtml(row) +
6186 '</thead>' :
6187 ''
6188 ) +
6189 '</table>' +
6190 '</div>' +
6191 '</div>';
6192 },
6193
6194
6195 /* Grid Number Rendering
6196 ------------------------------------------------------------------------------------------------------------------*/
6197
6198
6199 renderNumberTrHtml: function(row) {
6200 return '' +
6201 '<tr>' +
6202 (this.isRTL ? '' : this.renderNumberIntroHtml(row)) +
6203 this.renderNumberCellsHtml(row) +
6204 (this.isRTL ? this.renderNumberIntroHtml(row) : '') +
6205 '</tr>';
6206 },
6207
6208
6209 renderNumberIntroHtml: function(row) {
6210 return this.renderIntroHtml();
6211 },
6212
6213
6214 renderNumberCellsHtml: function(row) {
6215 var htmls = [];
6216 var col, date;
6217
6218 for (col = 0; col < this.colCnt; col++) {
6219 date = this.getCellDate(row, col);
6220 htmls.push(this.renderNumberCellHtml(date));
6221 }
6222
6223 return htmls.join('');
6224 },
6225
6226
6227 // Generates the HTML for the <td>s of the "number" row in the DayGrid's content skeleton.
6228 // The number row will only exist if either day numbers or week numbers are turned on.
6229 renderNumberCellHtml: function(date) {
6230 var html = '';
6231 var classes;
6232 var weekCalcFirstDoW;
6233
6234 if (!this.view.dayNumbersVisible && !this.view.cellWeekNumbersVisible) {
6235 // no numbers in day cell (week number must be along the side)
6236 return '<td/>'; // will create an empty space above events :(
6237 }
6238
6239 classes = this.getDayClasses(date);
6240 classes.unshift('fc-day-top');
6241
6242 if (this.view.cellWeekNumbersVisible) {
6243 // To determine the day of week number change under ISO, we cannot
6244 // rely on moment.js methods such as firstDayOfWeek() or weekday(),
6245 // because they rely on the locale's dow (possibly overridden by
6246 // our firstDay option), which may not be Monday. We cannot change
6247 // dow, because that would affect the calendar start day as well.
6248 if (date._locale._fullCalendar_weekCalc === 'ISO') {
6249 weekCalcFirstDoW = 1; // Monday by ISO 8601 definition
6250 }
6251 else {
6252 weekCalcFirstDoW = date._locale.firstDayOfWeek();
6253 }
6254 }
6255
6256 html += '<td class="' + classes.join(' ') + '" data-date="' + date.format() + '">';
6257
6258 if (this.view.cellWeekNumbersVisible && (date.day() == weekCalcFirstDoW)) {
6259 html += this.view.buildGotoAnchorHtml(
6260 { date: date, type: 'week' },
6261 { 'class': 'fc-week-number' },
6262 date.format('w') // inner HTML
6263 );
6264 }
6265
6266 if (this.view.dayNumbersVisible) {
6267 html += this.view.buildGotoAnchorHtml(
6268 date,
6269 { 'class': 'fc-day-number' },
6270 date.date() // inner HTML
6271 );
6272 }
6273
6274 html += '</td>';
6275
6276 return html;
6277 },
6278
6279
6280 /* Options
6281 ------------------------------------------------------------------------------------------------------------------*/
6282
6283
6284 // Computes a default event time formatting string if `timeFormat` is not explicitly defined
6285 computeEventTimeFormat: function() {
6286 return this.view.opt('extraSmallTimeFormat'); // like "6p" or "6:30p"
6287 },
6288
6289
6290 // Computes a default `displayEventEnd` value if one is not expliclty defined
6291 computeDisplayEventEnd: function() {
6292 return this.colCnt == 1; // we'll likely have space if there's only one day
6293 },
6294
6295
6296 /* Dates
6297 ------------------------------------------------------------------------------------------------------------------*/
6298
6299
6300 rangeUpdated: function() {
6301 this.updateDayTable();
6302 },
6303
6304
6305 // Slices up the given span (unzoned start/end with other misc data) into an array of segments
6306 spanToSegs: function(span) {
6307 var segs = this.sliceRangeByRow(span);
6308 var i, seg;
6309
6310 for (i = 0; i < segs.length; i++) {
6311 seg = segs[i];
6312 if (this.isRTL) {
6313 seg.leftCol = this.daysPerRow - 1 - seg.lastRowDayIndex;
6314 seg.rightCol = this.daysPerRow - 1 - seg.firstRowDayIndex;
6315 }
6316 else {
6317 seg.leftCol = seg.firstRowDayIndex;
6318 seg.rightCol = seg.lastRowDayIndex;
6319 }
6320 }
6321
6322 return segs;
6323 },
6324
6325
6326 /* Hit System
6327 ------------------------------------------------------------------------------------------------------------------*/
6328
6329
6330 prepareHits: function() {
6331 this.colCoordCache.build();
6332 this.rowCoordCache.build();
6333 this.rowCoordCache.bottoms[this.rowCnt - 1] += this.bottomCoordPadding; // hack
6334 },
6335
6336
6337 releaseHits: function() {
6338 this.colCoordCache.clear();
6339 this.rowCoordCache.clear();
6340 },
6341
6342
6343 queryHit: function(leftOffset, topOffset) {
6344 if (this.colCoordCache.isLeftInBounds(leftOffset) && this.rowCoordCache.isTopInBounds(topOffset)) {
6345 var col = this.colCoordCache.getHorizontalIndex(leftOffset);
6346 var row = this.rowCoordCache.getVerticalIndex(topOffset);
6347
6348 if (row != null && col != null) {
6349 return this.getCellHit(row, col);
6350 }
6351 }
6352 },
6353
6354
6355 getHitSpan: function(hit) {
6356 return this.getCellRange(hit.row, hit.col);
6357 },
6358
6359
6360 getHitEl: function(hit) {
6361 return this.getCellEl(hit.row, hit.col);
6362 },
6363
6364
6365 /* Cell System
6366 ------------------------------------------------------------------------------------------------------------------*/
6367 // FYI: the first column is the leftmost column, regardless of date
6368
6369
6370 getCellHit: function(row, col) {
6371 return {
6372 row: row,
6373 col: col,
6374 component: this, // needed unfortunately :(
6375 left: this.colCoordCache.getLeftOffset(col),
6376 right: this.colCoordCache.getRightOffset(col),
6377 top: this.rowCoordCache.getTopOffset(row),
6378 bottom: this.rowCoordCache.getBottomOffset(row)
6379 };
6380 },
6381
6382
6383 getCellEl: function(row, col) {
6384 return this.cellEls.eq(row * this.colCnt + col);
6385 },
6386
6387
6388 /* Event Drag Visualization
6389 ------------------------------------------------------------------------------------------------------------------*/
6390 // TODO: move to DayGrid.event, similar to what we did with Grid's drag methods
6391
6392
6393 // Renders a visual indication of an event or external element being dragged.
6394 // `eventLocation` has zoned start and end (optional)
6395 renderDrag: function(eventLocation, seg) {
6396
6397 // always render a highlight underneath
6398 this.renderHighlight(this.eventToSpan(eventLocation));
6399
6400 // if a segment from the same calendar but another component is being dragged, render a helper event
6401 if (seg && seg.component !== this) {
6402 return this.renderEventLocationHelper(eventLocation, seg); // returns mock event elements
6403 }
6404 },
6405
6406
6407 // Unrenders any visual indication of a hovering event
6408 unrenderDrag: function() {
6409 this.unrenderHighlight();
6410 this.unrenderHelper();
6411 },
6412
6413
6414 /* Event Resize Visualization
6415 ------------------------------------------------------------------------------------------------------------------*/
6416
6417
6418 // Renders a visual indication of an event being resized
6419 renderEventResize: function(eventLocation, seg) {
6420 this.renderHighlight(this.eventToSpan(eventLocation));
6421 return this.renderEventLocationHelper(eventLocation, seg); // returns mock event elements
6422 },
6423
6424
6425 // Unrenders a visual indication of an event being resized
6426 unrenderEventResize: function() {
6427 this.unrenderHighlight();
6428 this.unrenderHelper();
6429 },
6430
6431
6432 /* Event Helper
6433 ------------------------------------------------------------------------------------------------------------------*/
6434
6435
6436 // Renders a mock "helper" event. `sourceSeg` is the associated internal segment object. It can be null.
6437 renderHelper: function(event, sourceSeg) {
6438 var helperNodes = [];
6439 var segs = this.eventToSegs(event);
6440 var rowStructs;
6441
6442 segs = this.renderFgSegEls(segs); // assigns each seg's el and returns a subset of segs that were rendered
6443 rowStructs = this.renderSegRows(segs);
6444
6445 // inject each new event skeleton into each associated row
6446 this.rowEls.each(function(row, rowNode) {
6447 var rowEl = $(rowNode); // the .fc-row
6448 var skeletonEl = $('<div class="fc-helper-skeleton"><table/></div>'); // will be absolutely positioned
6449 var skeletonTop;
6450
6451 // If there is an original segment, match the top position. Otherwise, put it at the row's top level
6452 if (sourceSeg && sourceSeg.row === row) {
6453 skeletonTop = sourceSeg.el.position().top;
6454 }
6455 else {
6456 skeletonTop = rowEl.find('.fc-content-skeleton tbody').position().top;
6457 }
6458
6459 skeletonEl.css('top', skeletonTop)
6460 .find('table')
6461 .append(rowStructs[row].tbodyEl);
6462
6463 rowEl.append(skeletonEl);
6464 helperNodes.push(skeletonEl[0]);
6465 });
6466
6467 return ( // must return the elements rendered
6468 this.helperEls = $(helperNodes) // array -> jQuery set
6469 );
6470 },
6471
6472
6473 // Unrenders any visual indication of a mock helper event
6474 unrenderHelper: function() {
6475 if (this.helperEls) {
6476 this.helperEls.remove();
6477 this.helperEls = null;
6478 }
6479 },
6480
6481
6482 /* Fill System (highlight, background events, business hours)
6483 ------------------------------------------------------------------------------------------------------------------*/
6484
6485
6486 fillSegTag: 'td', // override the default tag name
6487
6488
6489 // Renders a set of rectangles over the given segments of days.
6490 // Only returns segments that successfully rendered.
6491 renderFill: function(type, segs, className) {
6492 var nodes = [];
6493 var i, seg;
6494 var skeletonEl;
6495
6496 segs = this.renderFillSegEls(type, segs); // assignes `.el` to each seg. returns successfully rendered segs
6497
6498 for (i = 0; i < segs.length; i++) {
6499 seg = segs[i];
6500 skeletonEl = this.renderFillRow(type, seg, className);
6501 this.rowEls.eq(seg.row).append(skeletonEl);
6502 nodes.push(skeletonEl[0]);
6503 }
6504
6505 this.elsByFill[type] = $(nodes);
6506
6507 return segs;
6508 },
6509
6510
6511 // Generates the HTML needed for one row of a fill. Requires the seg's el to be rendered.
6512 renderFillRow: function(type, seg, className) {
6513 var colCnt = this.colCnt;
6514 var startCol = seg.leftCol;
6515 var endCol = seg.rightCol + 1;
6516 var skeletonEl;
6517 var trEl;
6518
6519 className = className || type.toLowerCase();
6520
6521 skeletonEl = $(
6522 '<div class="fc-' + className + '-skeleton">' +
6523 '<table><tr/></table>' +
6524 '</div>'
6525 );
6526 trEl = skeletonEl.find('tr');
6527
6528 if (startCol > 0) {
6529 trEl.append('<td colspan="' + startCol + '"/>');
6530 }
6531
6532 trEl.append(
6533 seg.el.attr('colspan', endCol - startCol)
6534 );
6535
6536 if (endCol < colCnt) {
6537 trEl.append('<td colspan="' + (colCnt - endCol) + '"/>');
6538 }
6539
6540 this.bookendCells(trEl);
6541
6542 return skeletonEl;
6543 }
6544
6545 });
6546
6547 ;;
6548
6549 /* Event-rendering methods for the DayGrid class
6550 ----------------------------------------------------------------------------------------------------------------------*/
6551
6552 DayGrid.mixin({
6553
6554 rowStructs: null, // an array of objects, each holding information about a row's foreground event-rendering
6555
6556
6557 // Unrenders all events currently rendered on the grid
6558 unrenderEvents: function() {
6559 this.removeSegPopover(); // removes the "more.." events popover
6560 Grid.prototype.unrenderEvents.apply(this, arguments); // calls the super-method
6561 },
6562
6563
6564 // Retrieves all rendered segment objects currently rendered on the grid
6565 getEventSegs: function() {
6566 return Grid.prototype.getEventSegs.call(this) // get the segments from the super-method
6567 .concat(this.popoverSegs || []); // append the segments from the "more..." popover
6568 },
6569
6570
6571 // Renders the given background event segments onto the grid
6572 renderBgSegs: function(segs) {
6573
6574 // don't render timed background events
6575 var allDaySegs = $.grep(segs, function(seg) {
6576 return seg.event.allDay;
6577 });
6578
6579 return Grid.prototype.renderBgSegs.call(this, allDaySegs); // call the super-method
6580 },
6581
6582
6583 // Renders the given foreground event segments onto the grid
6584 renderFgSegs: function(segs) {
6585 var rowStructs;
6586
6587 // render an `.el` on each seg
6588 // returns a subset of the segs. segs that were actually rendered
6589 segs = this.renderFgSegEls(segs);
6590
6591 rowStructs = this.rowStructs = this.renderSegRows(segs);
6592
6593 // append to each row's content skeleton
6594 this.rowEls.each(function(i, rowNode) {
6595 $(rowNode).find('.fc-content-skeleton > table').append(
6596 rowStructs[i].tbodyEl
6597 );
6598 });
6599
6600 return segs; // return only the segs that were actually rendered
6601 },
6602
6603
6604 // Unrenders all currently rendered foreground event segments
6605 unrenderFgSegs: function() {
6606 var rowStructs = this.rowStructs || [];
6607 var rowStruct;
6608
6609 while ((rowStruct = rowStructs.pop())) {
6610 rowStruct.tbodyEl.remove();
6611 }
6612
6613 this.rowStructs = null;
6614 },
6615
6616
6617 // Uses the given events array to generate <tbody> elements that should be appended to each row's content skeleton.
6618 // Returns an array of rowStruct objects (see the bottom of `renderSegRow`).
6619 // PRECONDITION: each segment shoud already have a rendered and assigned `.el`
6620 renderSegRows: function(segs) {
6621 var rowStructs = [];
6622 var segRows;
6623 var row;
6624
6625 segRows = this.groupSegRows(segs); // group into nested arrays
6626
6627 // iterate each row of segment groupings
6628 for (row = 0; row < segRows.length; row++) {
6629 rowStructs.push(
6630 this.renderSegRow(row, segRows[row])
6631 );
6632 }
6633
6634 return rowStructs;
6635 },
6636
6637
6638 // Builds the HTML to be used for the default element for an individual segment
6639 fgSegHtml: function(seg, disableResizing) {
6640 var view = this.view;
6641 var event = seg.event;
6642 var isDraggable = view.isEventDraggable(event);
6643 var isResizableFromStart = !disableResizing && event.allDay &&
6644 seg.isStart && view.isEventResizableFromStart(event);
6645 var isResizableFromEnd = !disableResizing && event.allDay &&
6646 seg.isEnd && view.isEventResizableFromEnd(event);
6647 var classes = this.getSegClasses(seg, isDraggable, isResizableFromStart || isResizableFromEnd);
6648 var skinCss = cssToStr(this.getSegSkinCss(seg));
6649 var timeHtml = '';
6650 var timeText;
6651 var titleHtml;
6652
6653 classes.unshift('fc-day-grid-event', 'fc-h-event');
6654
6655 // Only display a timed events time if it is the starting segment
6656 if (seg.isStart) {
6657 timeText = this.getEventTimeText(event);
6658 if (timeText) {
6659 timeHtml = '<span class="fc-time">' + htmlEscape(timeText) + '</span>';
6660 }
6661 }
6662
6663 titleHtml =
6664 '<span class="fc-title">' +
6665 (htmlEscape(event.title || '') || '&nbsp;') + // we always want one line of height
6666 '</span>';
6667
6668 return '<a class="' + classes.join(' ') + '"' +
6669 (event.url ?
6670 ' href="' + htmlEscape(event.url) + '"' :
6671 ''
6672 ) +
6673 (skinCss ?
6674 ' style="' + skinCss + '"' :
6675 ''
6676 ) +
6677 '>' +
6678 '<div class="fc-content">' +
6679 (this.isRTL ?
6680 titleHtml + ' ' + timeHtml : // put a natural space in between
6681 timeHtml + ' ' + titleHtml //
6682 ) +
6683 '</div>' +
6684 (isResizableFromStart ?
6685 '<div class="fc-resizer fc-start-resizer" />' :
6686 ''
6687 ) +
6688 (isResizableFromEnd ?
6689 '<div class="fc-resizer fc-end-resizer" />' :
6690 ''
6691 ) +
6692 '</a>';
6693 },
6694
6695
6696 // Given a row # and an array of segments all in the same row, render a <tbody> element, a skeleton that contains
6697 // the segments. Returns object with a bunch of internal data about how the render was calculated.
6698 // NOTE: modifies rowSegs
6699 renderSegRow: function(row, rowSegs) {
6700 var colCnt = this.colCnt;
6701 var segLevels = this.buildSegLevels(rowSegs); // group into sub-arrays of levels
6702 var levelCnt = Math.max(1, segLevels.length); // ensure at least one level
6703 var tbody = $('<tbody/>');
6704 var segMatrix = []; // lookup for which segments are rendered into which level+col cells
6705 var cellMatrix = []; // lookup for all <td> elements of the level+col matrix
6706 var loneCellMatrix = []; // lookup for <td> elements that only take up a single column
6707 var i, levelSegs;
6708 var col;
6709 var tr;
6710 var j, seg;
6711 var td;
6712
6713 // populates empty cells from the current column (`col`) to `endCol`
6714 function emptyCellsUntil(endCol) {
6715 while (col < endCol) {
6716 // try to grab a cell from the level above and extend its rowspan. otherwise, create a fresh cell
6717 td = (loneCellMatrix[i - 1] || [])[col];
6718 if (td) {
6719 td.attr(
6720 'rowspan',
6721 parseInt(td.attr('rowspan') || 1, 10) + 1
6722 );
6723 }
6724 else {
6725 td = $('<td/>');
6726 tr.append(td);
6727 }
6728 cellMatrix[i][col] = td;
6729 loneCellMatrix[i][col] = td;
6730 col++;
6731 }
6732 }
6733
6734 for (i = 0; i < levelCnt; i++) { // iterate through all levels
6735 levelSegs = segLevels[i];
6736 col = 0;
6737 tr = $('<tr/>');
6738
6739 segMatrix.push([]);
6740 cellMatrix.push([]);
6741 loneCellMatrix.push([]);
6742
6743 // levelCnt might be 1 even though there are no actual levels. protect against this.
6744 // this single empty row is useful for styling.
6745 if (levelSegs) {
6746 for (j = 0; j < levelSegs.length; j++) { // iterate through segments in level
6747 seg = levelSegs[j];
6748
6749 emptyCellsUntil(seg.leftCol);
6750
6751 // create a container that occupies or more columns. append the event element.
6752 td = $('<td class="fc-event-container"/>').append(seg.el);
6753 if (seg.leftCol != seg.rightCol) {
6754 td.attr('colspan', seg.rightCol - seg.leftCol + 1);
6755 }
6756 else { // a single-column segment
6757 loneCellMatrix[i][col] = td;
6758 }
6759
6760 while (col <= seg.rightCol) {
6761 cellMatrix[i][col] = td;
6762 segMatrix[i][col] = seg;
6763 col++;
6764 }
6765
6766 tr.append(td);
6767 }
6768 }
6769
6770 emptyCellsUntil(colCnt); // finish off the row
6771 this.bookendCells(tr);
6772 tbody.append(tr);
6773 }
6774
6775 return { // a "rowStruct"
6776 row: row, // the row number
6777 tbodyEl: tbody,
6778 cellMatrix: cellMatrix,
6779 segMatrix: segMatrix,
6780 segLevels: segLevels,
6781 segs: rowSegs
6782 };
6783 },
6784
6785
6786 // Stacks a flat array of segments, which are all assumed to be in the same row, into subarrays of vertical levels.
6787 // NOTE: modifies segs
6788 buildSegLevels: function(segs) {
6789 var levels = [];
6790 var i, seg;
6791 var j;
6792
6793 // Give preference to elements with certain criteria, so they have
6794 // a chance to be closer to the top.
6795 this.sortEventSegs(segs);
6796
6797 for (i = 0; i < segs.length; i++) {
6798 seg = segs[i];
6799
6800 // loop through levels, starting with the topmost, until the segment doesn't collide with other segments
6801 for (j = 0; j < levels.length; j++) {
6802 if (!isDaySegCollision(seg, levels[j])) {
6803 break;
6804 }
6805 }
6806 // `j` now holds the desired subrow index
6807 seg.level = j;
6808
6809 // create new level array if needed and append segment
6810 (levels[j] || (levels[j] = [])).push(seg);
6811 }
6812
6813 // order segments left-to-right. very important if calendar is RTL
6814 for (j = 0; j < levels.length; j++) {
6815 levels[j].sort(compareDaySegCols);
6816 }
6817
6818 return levels;
6819 },
6820
6821
6822 // Given a flat array of segments, return an array of sub-arrays, grouped by each segment's row
6823 groupSegRows: function(segs) {
6824 var segRows = [];
6825 var i;
6826
6827 for (i = 0; i < this.rowCnt; i++) {
6828 segRows.push([]);
6829 }
6830
6831 for (i = 0; i < segs.length; i++) {
6832 segRows[segs[i].row].push(segs[i]);
6833 }
6834
6835 return segRows;
6836 }
6837
6838 });
6839
6840
6841 // Computes whether two segments' columns collide. They are assumed to be in the same row.
6842 function isDaySegCollision(seg, otherSegs) {
6843 var i, otherSeg;
6844
6845 for (i = 0; i < otherSegs.length; i++) {
6846 otherSeg = otherSegs[i];
6847
6848 if (
6849 otherSeg.leftCol <= seg.rightCol &&
6850 otherSeg.rightCol >= seg.leftCol
6851 ) {
6852 return true;
6853 }
6854 }
6855
6856 return false;
6857 }
6858
6859
6860 // A cmp function for determining the leftmost event
6861 function compareDaySegCols(a, b) {
6862 return a.leftCol - b.leftCol;
6863 }
6864
6865 ;;
6866
6867 /* Methods relate to limiting the number events for a given day on a DayGrid
6868 ----------------------------------------------------------------------------------------------------------------------*/
6869 // NOTE: all the segs being passed around in here are foreground segs
6870
6871 DayGrid.mixin({
6872
6873 segPopover: null, // the Popover that holds events that can't fit in a cell. null when not visible
6874 popoverSegs: null, // an array of segment objects that the segPopover holds. null when not visible
6875
6876
6877 removeSegPopover: function() {
6878 if (this.segPopover) {
6879 this.segPopover.hide(); // in handler, will call segPopover's removeElement
6880 }
6881 },
6882
6883
6884 // Limits the number of "levels" (vertically stacking layers of events) for each row of the grid.
6885 // `levelLimit` can be false (don't limit), a number, or true (should be computed).
6886 limitRows: function(levelLimit) {
6887 var rowStructs = this.rowStructs || [];
6888 var row; // row #
6889 var rowLevelLimit;
6890
6891 for (row = 0; row < rowStructs.length; row++) {
6892 this.unlimitRow(row);
6893
6894 if (!levelLimit) {
6895 rowLevelLimit = false;
6896 }
6897 else if (typeof levelLimit === 'number') {
6898 rowLevelLimit = levelLimit;
6899 }
6900 else {
6901 rowLevelLimit = this.computeRowLevelLimit(row);
6902 }
6903
6904 if (rowLevelLimit !== false) {
6905 this.limitRow(row, rowLevelLimit);
6906 }
6907 }
6908 },
6909
6910
6911 // Computes the number of levels a row will accomodate without going outside its bounds.
6912 // Assumes the row is "rigid" (maintains a constant height regardless of what is inside).
6913 // `row` is the row number.
6914 computeRowLevelLimit: function(row) {
6915 var rowEl = this.rowEls.eq(row); // the containing "fake" row div
6916 var rowHeight = rowEl.height(); // TODO: cache somehow?
6917 var trEls = this.rowStructs[row].tbodyEl.children();
6918 var i, trEl;
6919 var trHeight;
6920
6921 function iterInnerHeights(i, childNode) {
6922 trHeight = Math.max(trHeight, $(childNode).outerHeight());
6923 }
6924
6925 // Reveal one level <tr> at a time and stop when we find one out of bounds
6926 for (i = 0; i < trEls.length; i++) {
6927 trEl = trEls.eq(i).removeClass('fc-limited'); // reset to original state (reveal)
6928
6929 // with rowspans>1 and IE8, trEl.outerHeight() would return the height of the largest cell,
6930 // so instead, find the tallest inner content element.
6931 trHeight = 0;
6932 trEl.find('> td > :first-child').each(iterInnerHeights);
6933
6934 if (trEl.position().top + trHeight > rowHeight) {
6935 return i;
6936 }
6937 }
6938
6939 return false; // should not limit at all
6940 },
6941
6942
6943 // Limits the given grid row to the maximum number of levels and injects "more" links if necessary.
6944 // `row` is the row number.
6945 // `levelLimit` is a number for the maximum (inclusive) number of levels allowed.
6946 limitRow: function(row, levelLimit) {
6947 var _this = this;
6948 var rowStruct = this.rowStructs[row];
6949 var moreNodes = []; // array of "more" <a> links and <td> DOM nodes
6950 var col = 0; // col #, left-to-right (not chronologically)
6951 var levelSegs; // array of segment objects in the last allowable level, ordered left-to-right
6952 var cellMatrix; // a matrix (by level, then column) of all <td> jQuery elements in the row
6953 var limitedNodes; // array of temporarily hidden level <tr> and segment <td> DOM nodes
6954 var i, seg;
6955 var segsBelow; // array of segment objects below `seg` in the current `col`
6956 var totalSegsBelow; // total number of segments below `seg` in any of the columns `seg` occupies
6957 var colSegsBelow; // array of segment arrays, below seg, one for each column (offset from segs's first column)
6958 var td, rowspan;
6959 var segMoreNodes; // array of "more" <td> cells that will stand-in for the current seg's cell
6960 var j;
6961 var moreTd, moreWrap, moreLink;
6962
6963 // Iterates through empty level cells and places "more" links inside if need be
6964 function emptyCellsUntil(endCol) { // goes from current `col` to `endCol`
6965 while (col < endCol) {
6966 segsBelow = _this.getCellSegs(row, col, levelLimit);
6967 if (segsBelow.length) {
6968 td = cellMatrix[levelLimit - 1][col];
6969 moreLink = _this.renderMoreLink(row, col, segsBelow);
6970 moreWrap = $('<div/>').append(moreLink);
6971 td.append(moreWrap);
6972 moreNodes.push(moreWrap[0]);
6973 }
6974 col++;
6975 }
6976 }
6977
6978 if (levelLimit && levelLimit < rowStruct.segLevels.length) { // is it actually over the limit?
6979 levelSegs = rowStruct.segLevels[levelLimit - 1];
6980 cellMatrix = rowStruct.cellMatrix;
6981
6982 limitedNodes = rowStruct.tbodyEl.children().slice(levelLimit) // get level <tr> elements past the limit
6983 .addClass('fc-limited').get(); // hide elements and get a simple DOM-nodes array
6984
6985 // iterate though segments in the last allowable level
6986 for (i = 0; i < levelSegs.length; i++) {
6987 seg = levelSegs[i];
6988 emptyCellsUntil(seg.leftCol); // process empty cells before the segment
6989
6990 // determine *all* segments below `seg` that occupy the same columns
6991 colSegsBelow = [];
6992 totalSegsBelow = 0;
6993 while (col <= seg.rightCol) {
6994 segsBelow = this.getCellSegs(row, col, levelLimit);
6995 colSegsBelow.push(segsBelow);
6996 totalSegsBelow += segsBelow.length;
6997 col++;
6998 }
6999
7000 if (totalSegsBelow) { // do we need to replace this segment with one or many "more" links?
7001 td = cellMatrix[levelLimit - 1][seg.leftCol]; // the segment's parent cell
7002 rowspan = td.attr('rowspan') || 1;
7003 segMoreNodes = [];
7004
7005 // make a replacement <td> for each column the segment occupies. will be one for each colspan
7006 for (j = 0; j < colSegsBelow.length; j++) {
7007 moreTd = $('<td class="fc-more-cell"/>').attr('rowspan', rowspan);
7008 segsBelow = colSegsBelow[j];
7009 moreLink = this.renderMoreLink(
7010 row,
7011 seg.leftCol + j,
7012 [ seg ].concat(segsBelow) // count seg as hidden too
7013 );
7014 moreWrap = $('<div/>').append(moreLink);
7015 moreTd.append(moreWrap);
7016 segMoreNodes.push(moreTd[0]);
7017 moreNodes.push(moreTd[0]);
7018 }
7019
7020 td.addClass('fc-limited').after($(segMoreNodes)); // hide original <td> and inject replacements
7021 limitedNodes.push(td[0]);
7022 }
7023 }
7024
7025 emptyCellsUntil(this.colCnt); // finish off the level
7026 rowStruct.moreEls = $(moreNodes); // for easy undoing later
7027 rowStruct.limitedEls = $(limitedNodes); // for easy undoing later
7028 }
7029 },
7030
7031
7032 // Reveals all levels and removes all "more"-related elements for a grid's row.
7033 // `row` is a row number.
7034 unlimitRow: function(row) {
7035 var rowStruct = this.rowStructs[row];
7036
7037 if (rowStruct.moreEls) {
7038 rowStruct.moreEls.remove();
7039 rowStruct.moreEls = null;
7040 }
7041
7042 if (rowStruct.limitedEls) {
7043 rowStruct.limitedEls.removeClass('fc-limited');
7044 rowStruct.limitedEls = null;
7045 }
7046 },
7047
7048
7049 // Renders an <a> element that represents hidden event element for a cell.
7050 // Responsible for attaching click handler as well.
7051 renderMoreLink: function(row, col, hiddenSegs) {
7052 var _this = this;
7053 var view = this.view;
7054
7055 return $('<a class="fc-more"/>')
7056 .text(
7057 this.getMoreLinkText(hiddenSegs.length)
7058 )
7059 .on('click', function(ev) {
7060 var clickOption = view.opt('eventLimitClick');
7061 var date = _this.getCellDate(row, col);
7062 var moreEl = $(this);
7063 var dayEl = _this.getCellEl(row, col);
7064 var allSegs = _this.getCellSegs(row, col);
7065
7066 // rescope the segments to be within the cell's date
7067 var reslicedAllSegs = _this.resliceDaySegs(allSegs, date);
7068 var reslicedHiddenSegs = _this.resliceDaySegs(hiddenSegs, date);
7069
7070 if (typeof clickOption === 'function') {
7071 // the returned value can be an atomic option
7072 clickOption = view.publiclyTrigger('eventLimitClick', null, {
7073 date: date,
7074 dayEl: dayEl,
7075 moreEl: moreEl,
7076 segs: reslicedAllSegs,
7077 hiddenSegs: reslicedHiddenSegs
7078 }, ev);
7079 }
7080
7081 if (clickOption === 'popover') {
7082 _this.showSegPopover(row, col, moreEl, reslicedAllSegs);
7083 }
7084 else if (typeof clickOption === 'string') { // a view name
7085 view.calendar.zoomTo(date, clickOption);
7086 }
7087 });
7088 },
7089
7090
7091 // Reveals the popover that displays all events within a cell
7092 showSegPopover: function(row, col, moreLink, segs) {
7093 var _this = this;
7094 var view = this.view;
7095 var moreWrap = moreLink.parent(); // the <div> wrapper around the <a>
7096 var topEl; // the element we want to match the top coordinate of
7097 var options;
7098
7099 if (this.rowCnt == 1) {
7100 topEl = view.el; // will cause the popover to cover any sort of header
7101 }
7102 else {
7103 topEl = this.rowEls.eq(row); // will align with top of row
7104 }
7105
7106 options = {
7107 className: 'fc-more-popover',
7108 content: this.renderSegPopoverContent(row, col, segs),
7109 parentEl: this.view.el, // attach to root of view. guarantees outside of scrollbars.
7110 top: topEl.offset().top,
7111 autoHide: true, // when the user clicks elsewhere, hide the popover
7112 viewportConstrain: view.opt('popoverViewportConstrain'),
7113 hide: function() {
7114 // kill everything when the popover is hidden
7115 // notify events to be removed
7116 if (_this.popoverSegs) {
7117 var seg;
7118 for (var i = 0; i < _this.popoverSegs.length; ++i) {
7119 seg = _this.popoverSegs[i];
7120 view.publiclyTrigger('eventDestroy', seg.event, seg.event, seg.el);
7121 }
7122 }
7123 _this.segPopover.removeElement();
7124 _this.segPopover = null;
7125 _this.popoverSegs = null;
7126 }
7127 };
7128
7129 // Determine horizontal coordinate.
7130 // We use the moreWrap instead of the <td> to avoid border confusion.
7131 if (this.isRTL) {
7132 options.right = moreWrap.offset().left + moreWrap.outerWidth() + 1; // +1 to be over cell border
7133 }
7134 else {
7135 options.left = moreWrap.offset().left - 1; // -1 to be over cell border
7136 }
7137
7138 this.segPopover = new Popover(options);
7139 this.segPopover.show();
7140
7141 // the popover doesn't live within the grid's container element, and thus won't get the event
7142 // delegated-handlers for free. attach event-related handlers to the popover.
7143 this.bindSegHandlersToEl(this.segPopover.el);
7144 },
7145
7146
7147 // Builds the inner DOM contents of the segment popover
7148 renderSegPopoverContent: function(row, col, segs) {
7149 var view = this.view;
7150 var isTheme = view.opt('theme');
7151 var title = this.getCellDate(row, col).format(view.opt('dayPopoverFormat'));
7152 var content = $(
7153 '<div class="fc-header ' + view.widgetHeaderClass + '">' +
7154 '<span class="fc-close ' +
7155 (isTheme ? 'ui-icon ui-icon-closethick' : 'fc-icon fc-icon-x') +
7156 '"></span>' +
7157 '<span class="fc-title">' +
7158 htmlEscape(title) +
7159 '</span>' +
7160 '<div class="fc-clear"/>' +
7161 '</div>' +
7162 '<div class="fc-body ' + view.widgetContentClass + '">' +
7163 '<div class="fc-event-container"></div>' +
7164 '</div>'
7165 );
7166 var segContainer = content.find('.fc-event-container');
7167 var i;
7168
7169 // render each seg's `el` and only return the visible segs
7170 segs = this.renderFgSegEls(segs, true); // disableResizing=true
7171 this.popoverSegs = segs;
7172
7173 for (i = 0; i < segs.length; i++) {
7174
7175 // because segments in the popover are not part of a grid coordinate system, provide a hint to any
7176 // grids that want to do drag-n-drop about which cell it came from
7177 this.hitsNeeded();
7178 segs[i].hit = this.getCellHit(row, col);
7179 this.hitsNotNeeded();
7180
7181 segContainer.append(segs[i].el);
7182 }
7183
7184 return content;
7185 },
7186
7187
7188 // Given the events within an array of segment objects, reslice them to be in a single day
7189 resliceDaySegs: function(segs, dayDate) {
7190
7191 // build an array of the original events
7192 var events = $.map(segs, function(seg) {
7193 return seg.event;
7194 });
7195
7196 var dayStart = dayDate.clone();
7197 var dayEnd = dayStart.clone().add(1, 'days');
7198 var dayRange = { start: dayStart, end: dayEnd };
7199
7200 // slice the events with a custom slicing function
7201 segs = this.eventsToSegs(
7202 events,
7203 function(range) {
7204 var seg = intersectRanges(range, dayRange); // undefind if no intersection
7205 return seg ? [ seg ] : []; // must return an array of segments
7206 }
7207 );
7208
7209 // force an order because eventsToSegs doesn't guarantee one
7210 this.sortEventSegs(segs);
7211
7212 return segs;
7213 },
7214
7215
7216 // Generates the text that should be inside a "more" link, given the number of events it represents
7217 getMoreLinkText: function(num) {
7218 var opt = this.view.opt('eventLimitText');
7219
7220 if (typeof opt === 'function') {
7221 return opt(num);
7222 }
7223 else {
7224 return '+' + num + ' ' + opt;
7225 }
7226 },
7227
7228
7229 // Returns segments within a given cell.
7230 // If `startLevel` is specified, returns only events including and below that level. Otherwise returns all segs.
7231 getCellSegs: function(row, col, startLevel) {
7232 var segMatrix = this.rowStructs[row].segMatrix;
7233 var level = startLevel || 0;
7234 var segs = [];
7235 var seg;
7236
7237 while (level < segMatrix.length) {
7238 seg = segMatrix[level][col];
7239 if (seg) {
7240 segs.push(seg);
7241 }
7242 level++;
7243 }
7244
7245 return segs;
7246 }
7247
7248 });
7249
7250 ;;
7251
7252 /* A component that renders one or more columns of vertical time slots
7253 ----------------------------------------------------------------------------------------------------------------------*/
7254 // We mixin DayTable, even though there is only a single row of days
7255
7256 var TimeGrid = FC.TimeGrid = Grid.extend(DayTableMixin, {
7257
7258 slotDuration: null, // duration of a "slot", a distinct time segment on given day, visualized by lines
7259 snapDuration: null, // granularity of time for dragging and selecting
7260 snapsPerSlot: null,
7261 minTime: null, // Duration object that denotes the first visible time of any given day
7262 maxTime: null, // Duration object that denotes the exclusive visible end time of any given day
7263 labelFormat: null, // formatting string for times running along vertical axis
7264 labelInterval: null, // duration of how often a label should be displayed for a slot
7265
7266 colEls: null, // cells elements in the day-row background
7267 slatContainerEl: null, // div that wraps all the slat rows
7268 slatEls: null, // elements running horizontally across all columns
7269 nowIndicatorEls: null,
7270
7271 colCoordCache: null,
7272 slatCoordCache: null,
7273
7274
7275 constructor: function() {
7276 Grid.apply(this, arguments); // call the super-constructor
7277
7278 this.processOptions();
7279 },
7280
7281
7282 // Renders the time grid into `this.el`, which should already be assigned.
7283 // Relies on the view's colCnt. In the future, this component should probably be self-sufficient.
7284 renderDates: function() {
7285 this.el.html(this.renderHtml());
7286 this.colEls = this.el.find('.fc-day');
7287 this.slatContainerEl = this.el.find('.fc-slats');
7288 this.slatEls = this.slatContainerEl.find('tr');
7289
7290 this.colCoordCache = new CoordCache({
7291 els: this.colEls,
7292 isHorizontal: true
7293 });
7294 this.slatCoordCache = new CoordCache({
7295 els: this.slatEls,
7296 isVertical: true
7297 });
7298
7299 this.renderContentSkeleton();
7300 },
7301
7302
7303 // Renders the basic HTML skeleton for the grid
7304 renderHtml: function() {
7305 return '' +
7306 '<div class="fc-bg">' +
7307 '<table>' +
7308 this.renderBgTrHtml(0) + // row=0
7309 '</table>' +
7310 '</div>' +
7311 '<div class="fc-slats">' +
7312 '<table>' +
7313 this.renderSlatRowHtml() +
7314 '</table>' +
7315 '</div>';
7316 },
7317
7318
7319 // Generates the HTML for the horizontal "slats" that run width-wise. Has a time axis on a side. Depends on RTL.
7320 renderSlatRowHtml: function() {
7321 var view = this.view;
7322 var isRTL = this.isRTL;
7323 var html = '';
7324 var slotTime = moment.duration(+this.minTime); // wish there was .clone() for durations
7325 var slotDate; // will be on the view's first day, but we only care about its time
7326 var isLabeled;
7327 var axisHtml;
7328
7329 // Calculate the time for each slot
7330 while (slotTime < this.maxTime) {
7331 slotDate = this.start.clone().time(slotTime);
7332 isLabeled = isInt(divideDurationByDuration(slotTime, this.labelInterval));
7333
7334 axisHtml =
7335 '<td class="fc-axis fc-time ' + view.widgetContentClass + '" ' + view.axisStyleAttr() + '>' +
7336 (isLabeled ?
7337 '<span>' + // for matchCellWidths
7338 htmlEscape(slotDate.format(this.labelFormat)) +
7339 '</span>' :
7340 ''
7341 ) +
7342 '</td>';
7343
7344 html +=
7345 '<tr data-time="' + slotDate.format('HH:mm:ss') + '"' +
7346 (isLabeled ? '' : ' class="fc-minor"') +
7347 '>' +
7348 (!isRTL ? axisHtml : '') +
7349 '<td class="' + view.widgetContentClass + '"/>' +
7350 (isRTL ? axisHtml : '') +
7351 "</tr>";
7352
7353 slotTime.add(this.slotDuration);
7354 }
7355
7356 return html;
7357 },
7358
7359
7360 /* Options
7361 ------------------------------------------------------------------------------------------------------------------*/
7362
7363
7364 // Parses various options into properties of this object
7365 processOptions: function() {
7366 var view = this.view;
7367 var slotDuration = view.opt('slotDuration');
7368 var snapDuration = view.opt('snapDuration');
7369 var input;
7370
7371 slotDuration = moment.duration(slotDuration);
7372 snapDuration = snapDuration ? moment.duration(snapDuration) : slotDuration;
7373
7374 this.slotDuration = slotDuration;
7375 this.snapDuration = snapDuration;
7376 this.snapsPerSlot = slotDuration / snapDuration; // TODO: ensure an integer multiple?
7377
7378 this.minResizeDuration = snapDuration; // hack
7379
7380 this.minTime = moment.duration(view.opt('minTime'));
7381 this.maxTime = moment.duration(view.opt('maxTime'));
7382
7383 // might be an array value (for TimelineView).
7384 // if so, getting the most granular entry (the last one probably).
7385 input = view.opt('slotLabelFormat');
7386 if ($.isArray(input)) {
7387 input = input[input.length - 1];
7388 }
7389
7390 this.labelFormat =
7391 input ||
7392 view.opt('smallTimeFormat'); // the computed default
7393
7394 input = view.opt('slotLabelInterval');
7395 this.labelInterval = input ?
7396 moment.duration(input) :
7397 this.computeLabelInterval(slotDuration);
7398 },
7399
7400
7401 // Computes an automatic value for slotLabelInterval
7402 computeLabelInterval: function(slotDuration) {
7403 var i;
7404 var labelInterval;
7405 var slotsPerLabel;
7406
7407 // find the smallest stock label interval that results in more than one slots-per-label
7408 for (i = AGENDA_STOCK_SUB_DURATIONS.length - 1; i >= 0; i--) {
7409 labelInterval = moment.duration(AGENDA_STOCK_SUB_DURATIONS[i]);
7410 slotsPerLabel = divideDurationByDuration(labelInterval, slotDuration);
7411 if (isInt(slotsPerLabel) && slotsPerLabel > 1) {
7412 return labelInterval;
7413 }
7414 }
7415
7416 return moment.duration(slotDuration); // fall back. clone
7417 },
7418
7419
7420 // Computes a default event time formatting string if `timeFormat` is not explicitly defined
7421 computeEventTimeFormat: function() {
7422 return this.view.opt('noMeridiemTimeFormat'); // like "6:30" (no AM/PM)
7423 },
7424
7425
7426 // Computes a default `displayEventEnd` value if one is not expliclty defined
7427 computeDisplayEventEnd: function() {
7428 return true;
7429 },
7430
7431
7432 /* Hit System
7433 ------------------------------------------------------------------------------------------------------------------*/
7434
7435
7436 prepareHits: function() {
7437 this.colCoordCache.build();
7438 this.slatCoordCache.build();
7439 },
7440
7441
7442 releaseHits: function() {
7443 this.colCoordCache.clear();
7444 // NOTE: don't clear slatCoordCache because we rely on it for computeTimeTop
7445 },
7446
7447
7448 queryHit: function(leftOffset, topOffset) {
7449 var snapsPerSlot = this.snapsPerSlot;
7450 var colCoordCache = this.colCoordCache;
7451 var slatCoordCache = this.slatCoordCache;
7452
7453 if (colCoordCache.isLeftInBounds(leftOffset) && slatCoordCache.isTopInBounds(topOffset)) {
7454 var colIndex = colCoordCache.getHorizontalIndex(leftOffset);
7455 var slatIndex = slatCoordCache.getVerticalIndex(topOffset);
7456
7457 if (colIndex != null && slatIndex != null) {
7458 var slatTop = slatCoordCache.getTopOffset(slatIndex);
7459 var slatHeight = slatCoordCache.getHeight(slatIndex);
7460 var partial = (topOffset - slatTop) / slatHeight; // floating point number between 0 and 1
7461 var localSnapIndex = Math.floor(partial * snapsPerSlot); // the snap # relative to start of slat
7462 var snapIndex = slatIndex * snapsPerSlot + localSnapIndex;
7463 var snapTop = slatTop + (localSnapIndex / snapsPerSlot) * slatHeight;
7464 var snapBottom = slatTop + ((localSnapIndex + 1) / snapsPerSlot) * slatHeight;
7465
7466 return {
7467 col: colIndex,
7468 snap: snapIndex,
7469 component: this, // needed unfortunately :(
7470 left: colCoordCache.getLeftOffset(colIndex),
7471 right: colCoordCache.getRightOffset(colIndex),
7472 top: snapTop,
7473 bottom: snapBottom
7474 };
7475 }
7476 }
7477 },
7478
7479
7480 getHitSpan: function(hit) {
7481 var start = this.getCellDate(0, hit.col); // row=0
7482 var time = this.computeSnapTime(hit.snap); // pass in the snap-index
7483 var end;
7484
7485 start.time(time);
7486 end = start.clone().add(this.snapDuration);
7487
7488 return { start: start, end: end };
7489 },
7490
7491
7492 getHitEl: function(hit) {
7493 return this.colEls.eq(hit.col);
7494 },
7495
7496
7497 /* Dates
7498 ------------------------------------------------------------------------------------------------------------------*/
7499
7500
7501 rangeUpdated: function() {
7502 this.updateDayTable();
7503 },
7504
7505
7506 // Given a row number of the grid, representing a "snap", returns a time (Duration) from its start-of-day
7507 computeSnapTime: function(snapIndex) {
7508 return moment.duration(this.minTime + this.snapDuration * snapIndex);
7509 },
7510
7511
7512 // Slices up the given span (unzoned start/end with other misc data) into an array of segments
7513 spanToSegs: function(span) {
7514 var segs = this.sliceRangeByTimes(span);
7515 var i;
7516
7517 for (i = 0; i < segs.length; i++) {
7518 if (this.isRTL) {
7519 segs[i].col = this.daysPerRow - 1 - segs[i].dayIndex;
7520 }
7521 else {
7522 segs[i].col = segs[i].dayIndex;
7523 }
7524 }
7525
7526 return segs;
7527 },
7528
7529
7530 sliceRangeByTimes: function(range) {
7531 var segs = [];
7532 var seg;
7533 var dayIndex;
7534 var dayDate;
7535 var dayRange;
7536
7537 for (dayIndex = 0; dayIndex < this.daysPerRow; dayIndex++) {
7538 dayDate = this.dayDates[dayIndex].clone(); // TODO: better API for this?
7539 dayRange = {
7540 start: dayDate.clone().time(this.minTime),
7541 end: dayDate.clone().time(this.maxTime)
7542 };
7543 seg = intersectRanges(range, dayRange); // both will be ambig timezone
7544 if (seg) {
7545 seg.dayIndex = dayIndex;
7546 segs.push(seg);
7547 }
7548 }
7549
7550 return segs;
7551 },
7552
7553
7554 /* Coordinates
7555 ------------------------------------------------------------------------------------------------------------------*/
7556
7557
7558 updateSize: function(isResize) { // NOT a standard Grid method
7559 this.slatCoordCache.build();
7560
7561 if (isResize) {
7562 this.updateSegVerticals(
7563 [].concat(this.fgSegs || [], this.bgSegs || [], this.businessSegs || [])
7564 );
7565 }
7566 },
7567
7568
7569 getTotalSlatHeight: function() {
7570 return this.slatContainerEl.outerHeight();
7571 },
7572
7573
7574 // Computes the top coordinate, relative to the bounds of the grid, of the given date.
7575 // A `startOfDayDate` must be given for avoiding ambiguity over how to treat midnight.
7576 computeDateTop: function(date, startOfDayDate) {
7577 return this.computeTimeTop(
7578 moment.duration(
7579 date - startOfDayDate.clone().stripTime()
7580 )
7581 );
7582 },
7583
7584
7585 // Computes the top coordinate, relative to the bounds of the grid, of the given time (a Duration).
7586 computeTimeTop: function(time) {
7587 var len = this.slatEls.length;
7588 var slatCoverage = (time - this.minTime) / this.slotDuration; // floating-point value of # of slots covered
7589 var slatIndex;
7590 var slatRemainder;
7591
7592 // compute a floating-point number for how many slats should be progressed through.
7593 // from 0 to number of slats (inclusive)
7594 // constrained because minTime/maxTime might be customized.
7595 slatCoverage = Math.max(0, slatCoverage);
7596 slatCoverage = Math.min(len, slatCoverage);
7597
7598 // an integer index of the furthest whole slat
7599 // from 0 to number slats (*exclusive*, so len-1)
7600 slatIndex = Math.floor(slatCoverage);
7601 slatIndex = Math.min(slatIndex, len - 1);
7602
7603 // how much further through the slatIndex slat (from 0.0-1.0) must be covered in addition.
7604 // could be 1.0 if slatCoverage is covering *all* the slots
7605 slatRemainder = slatCoverage - slatIndex;
7606
7607 return this.slatCoordCache.getTopPosition(slatIndex) +
7608 this.slatCoordCache.getHeight(slatIndex) * slatRemainder;
7609 },
7610
7611
7612
7613 /* Event Drag Visualization
7614 ------------------------------------------------------------------------------------------------------------------*/
7615
7616
7617 // Renders a visual indication of an event being dragged over the specified date(s).
7618 // A returned value of `true` signals that a mock "helper" event has been rendered.
7619 renderDrag: function(eventLocation, seg) {
7620
7621 if (seg) { // if there is event information for this drag, render a helper event
7622
7623 // returns mock event elements
7624 // signal that a helper has been rendered
7625 return this.renderEventLocationHelper(eventLocation, seg);
7626 }
7627 else {
7628 // otherwise, just render a highlight
7629 this.renderHighlight(this.eventToSpan(eventLocation));
7630 }
7631 },
7632
7633
7634 // Unrenders any visual indication of an event being dragged
7635 unrenderDrag: function() {
7636 this.unrenderHelper();
7637 this.unrenderHighlight();
7638 },
7639
7640
7641 /* Event Resize Visualization
7642 ------------------------------------------------------------------------------------------------------------------*/
7643
7644
7645 // Renders a visual indication of an event being resized
7646 renderEventResize: function(eventLocation, seg) {
7647 return this.renderEventLocationHelper(eventLocation, seg); // returns mock event elements
7648 },
7649
7650
7651 // Unrenders any visual indication of an event being resized
7652 unrenderEventResize: function() {
7653 this.unrenderHelper();
7654 },
7655
7656
7657 /* Event Helper
7658 ------------------------------------------------------------------------------------------------------------------*/
7659
7660
7661 // Renders a mock "helper" event. `sourceSeg` is the original segment object and might be null (an external drag)
7662 renderHelper: function(event, sourceSeg) {
7663 return this.renderHelperSegs(this.eventToSegs(event), sourceSeg); // returns mock event elements
7664 },
7665
7666
7667 // Unrenders any mock helper event
7668 unrenderHelper: function() {
7669 this.unrenderHelperSegs();
7670 },
7671
7672
7673 /* Business Hours
7674 ------------------------------------------------------------------------------------------------------------------*/
7675
7676
7677 renderBusinessHours: function() {
7678 this.renderBusinessSegs(
7679 this.buildBusinessHourSegs()
7680 );
7681 },
7682
7683
7684 unrenderBusinessHours: function() {
7685 this.unrenderBusinessSegs();
7686 },
7687
7688
7689 /* Now Indicator
7690 ------------------------------------------------------------------------------------------------------------------*/
7691
7692
7693 getNowIndicatorUnit: function() {
7694 return 'minute'; // will refresh on the minute
7695 },
7696
7697
7698 renderNowIndicator: function(date) {
7699 // seg system might be overkill, but it handles scenario where line needs to be rendered
7700 // more than once because of columns with the same date (resources columns for example)
7701 var segs = this.spanToSegs({ start: date, end: date });
7702 var top = this.computeDateTop(date, date);
7703 var nodes = [];
7704 var i;
7705
7706 // render lines within the columns
7707 for (i = 0; i < segs.length; i++) {
7708 nodes.push($('<div class="fc-now-indicator fc-now-indicator-line"></div>')
7709 .css('top', top)
7710 .appendTo(this.colContainerEls.eq(segs[i].col))[0]);
7711 }
7712
7713 // render an arrow over the axis
7714 if (segs.length > 0) { // is the current time in view?
7715 nodes.push($('<div class="fc-now-indicator fc-now-indicator-arrow"></div>')
7716 .css('top', top)
7717 .appendTo(this.el.find('.fc-content-skeleton'))[0]);
7718 }
7719
7720 this.nowIndicatorEls = $(nodes);
7721 },
7722
7723
7724 unrenderNowIndicator: function() {
7725 if (this.nowIndicatorEls) {
7726 this.nowIndicatorEls.remove();
7727 this.nowIndicatorEls = null;
7728 }
7729 },
7730
7731
7732 /* Selection
7733 ------------------------------------------------------------------------------------------------------------------*/
7734
7735
7736 // Renders a visual indication of a selection. Overrides the default, which was to simply render a highlight.
7737 renderSelection: function(span) {
7738 if (this.view.opt('selectHelper')) { // this setting signals that a mock helper event should be rendered
7739
7740 // normally acceps an eventLocation, span has a start/end, which is good enough
7741 this.renderEventLocationHelper(span);
7742 }
7743 else {
7744 this.renderHighlight(span);
7745 }
7746 },
7747
7748
7749 // Unrenders any visual indication of a selection
7750 unrenderSelection: function() {
7751 this.unrenderHelper();
7752 this.unrenderHighlight();
7753 },
7754
7755
7756 /* Highlight
7757 ------------------------------------------------------------------------------------------------------------------*/
7758
7759
7760 renderHighlight: function(span) {
7761 this.renderHighlightSegs(this.spanToSegs(span));
7762 },
7763
7764
7765 unrenderHighlight: function() {
7766 this.unrenderHighlightSegs();
7767 }
7768
7769 });
7770
7771 ;;
7772
7773 /* Methods for rendering SEGMENTS, pieces of content that live on the view
7774 ( this file is no longer just for events )
7775 ----------------------------------------------------------------------------------------------------------------------*/
7776
7777 TimeGrid.mixin({
7778
7779 colContainerEls: null, // containers for each column
7780
7781 // inner-containers for each column where different types of segs live
7782 fgContainerEls: null,
7783 bgContainerEls: null,
7784 helperContainerEls: null,
7785 highlightContainerEls: null,
7786 businessContainerEls: null,
7787
7788 // arrays of different types of displayed segments
7789 fgSegs: null,
7790 bgSegs: null,
7791 helperSegs: null,
7792 highlightSegs: null,
7793 businessSegs: null,
7794
7795
7796 // Renders the DOM that the view's content will live in
7797 renderContentSkeleton: function() {
7798 var cellHtml = '';
7799 var i;
7800 var skeletonEl;
7801
7802 for (i = 0; i < this.colCnt; i++) {
7803 cellHtml +=
7804 '<td>' +
7805 '<div class="fc-content-col">' +
7806 '<div class="fc-event-container fc-helper-container"></div>' +
7807 '<div class="fc-event-container"></div>' +
7808 '<div class="fc-highlight-container"></div>' +
7809 '<div class="fc-bgevent-container"></div>' +
7810 '<div class="fc-business-container"></div>' +
7811 '</div>' +
7812 '</td>';
7813 }
7814
7815 skeletonEl = $(
7816 '<div class="fc-content-skeleton">' +
7817 '<table>' +
7818 '<tr>' + cellHtml + '</tr>' +
7819 '</table>' +
7820 '</div>'
7821 );
7822
7823 this.colContainerEls = skeletonEl.find('.fc-content-col');
7824 this.helperContainerEls = skeletonEl.find('.fc-helper-container');
7825 this.fgContainerEls = skeletonEl.find('.fc-event-container:not(.fc-helper-container)');
7826 this.bgContainerEls = skeletonEl.find('.fc-bgevent-container');
7827 this.highlightContainerEls = skeletonEl.find('.fc-highlight-container');
7828 this.businessContainerEls = skeletonEl.find('.fc-business-container');
7829
7830 this.bookendCells(skeletonEl.find('tr')); // TODO: do this on string level
7831 this.el.append(skeletonEl);
7832 },
7833
7834
7835 /* Foreground Events
7836 ------------------------------------------------------------------------------------------------------------------*/
7837
7838
7839 renderFgSegs: function(segs) {
7840 segs = this.renderFgSegsIntoContainers(segs, this.fgContainerEls);
7841 this.fgSegs = segs;
7842 return segs; // needed for Grid::renderEvents
7843 },
7844
7845
7846 unrenderFgSegs: function() {
7847 this.unrenderNamedSegs('fgSegs');
7848 },
7849
7850
7851 /* Foreground Helper Events
7852 ------------------------------------------------------------------------------------------------------------------*/
7853
7854
7855 renderHelperSegs: function(segs, sourceSeg) {
7856 var helperEls = [];
7857 var i, seg;
7858 var sourceEl;
7859
7860 segs = this.renderFgSegsIntoContainers(segs, this.helperContainerEls);
7861
7862 // Try to make the segment that is in the same row as sourceSeg look the same
7863 for (i = 0; i < segs.length; i++) {
7864 seg = segs[i];
7865 if (sourceSeg && sourceSeg.col === seg.col) {
7866 sourceEl = sourceSeg.el;
7867 seg.el.css({
7868 left: sourceEl.css('left'),
7869 right: sourceEl.css('right'),
7870 'margin-left': sourceEl.css('margin-left'),
7871 'margin-right': sourceEl.css('margin-right')
7872 });
7873 }
7874 helperEls.push(seg.el[0]);
7875 }
7876
7877 this.helperSegs = segs;
7878
7879 return $(helperEls); // must return rendered helpers
7880 },
7881
7882
7883 unrenderHelperSegs: function() {
7884 this.unrenderNamedSegs('helperSegs');
7885 },
7886
7887
7888 /* Background Events
7889 ------------------------------------------------------------------------------------------------------------------*/
7890
7891
7892 renderBgSegs: function(segs) {
7893 segs = this.renderFillSegEls('bgEvent', segs); // TODO: old fill system
7894 this.updateSegVerticals(segs);
7895 this.attachSegsByCol(this.groupSegsByCol(segs), this.bgContainerEls);
7896 this.bgSegs = segs;
7897 return segs; // needed for Grid::renderEvents
7898 },
7899
7900
7901 unrenderBgSegs: function() {
7902 this.unrenderNamedSegs('bgSegs');
7903 },
7904
7905
7906 /* Highlight
7907 ------------------------------------------------------------------------------------------------------------------*/
7908
7909
7910 renderHighlightSegs: function(segs) {
7911 segs = this.renderFillSegEls('highlight', segs); // TODO: old fill system
7912 this.updateSegVerticals(segs);
7913 this.attachSegsByCol(this.groupSegsByCol(segs), this.highlightContainerEls);
7914 this.highlightSegs = segs;
7915 },
7916
7917
7918 unrenderHighlightSegs: function() {
7919 this.unrenderNamedSegs('highlightSegs');
7920 },
7921
7922
7923 /* Business Hours
7924 ------------------------------------------------------------------------------------------------------------------*/
7925
7926
7927 renderBusinessSegs: function(segs) {
7928 segs = this.renderFillSegEls('businessHours', segs); // TODO: old fill system
7929 this.updateSegVerticals(segs);
7930 this.attachSegsByCol(this.groupSegsByCol(segs), this.businessContainerEls);
7931 this.businessSegs = segs;
7932 },
7933
7934
7935 unrenderBusinessSegs: function() {
7936 this.unrenderNamedSegs('businessSegs');
7937 },
7938
7939
7940 /* Seg Rendering Utils
7941 ------------------------------------------------------------------------------------------------------------------*/
7942
7943
7944 // Given a flat array of segments, return an array of sub-arrays, grouped by each segment's col
7945 groupSegsByCol: function(segs) {
7946 var segsByCol = [];
7947 var i;
7948
7949 for (i = 0; i < this.colCnt; i++) {
7950 segsByCol.push([]);
7951 }
7952
7953 for (i = 0; i < segs.length; i++) {
7954 segsByCol[segs[i].col].push(segs[i]);
7955 }
7956
7957 return segsByCol;
7958 },
7959
7960
7961 // Given segments grouped by column, insert the segments' elements into a parallel array of container
7962 // elements, each living within a column.
7963 attachSegsByCol: function(segsByCol, containerEls) {
7964 var col;
7965 var segs;
7966 var i;
7967
7968 for (col = 0; col < this.colCnt; col++) { // iterate each column grouping
7969 segs = segsByCol[col];
7970
7971 for (i = 0; i < segs.length; i++) {
7972 containerEls.eq(col).append(segs[i].el);
7973 }
7974 }
7975 },
7976
7977
7978 // Given the name of a property of `this` object, assumed to be an array of segments,
7979 // loops through each segment and removes from DOM. Will null-out the property afterwards.
7980 unrenderNamedSegs: function(propName) {
7981 var segs = this[propName];
7982 var i;
7983
7984 if (segs) {
7985 for (i = 0; i < segs.length; i++) {
7986 segs[i].el.remove();
7987 }
7988 this[propName] = null;
7989 }
7990 },
7991
7992
7993
7994 /* Foreground Event Rendering Utils
7995 ------------------------------------------------------------------------------------------------------------------*/
7996
7997
7998 // Given an array of foreground segments, render a DOM element for each, computes position,
7999 // and attaches to the column inner-container elements.
8000 renderFgSegsIntoContainers: function(segs, containerEls) {
8001 var segsByCol;
8002 var col;
8003
8004 segs = this.renderFgSegEls(segs); // will call fgSegHtml
8005 segsByCol = this.groupSegsByCol(segs);
8006
8007 for (col = 0; col < this.colCnt; col++) {
8008 this.updateFgSegCoords(segsByCol[col]);
8009 }
8010
8011 this.attachSegsByCol(segsByCol, containerEls);
8012
8013 return segs;
8014 },
8015
8016
8017 // Renders the HTML for a single event segment's default rendering
8018 fgSegHtml: function(seg, disableResizing) {
8019 var view = this.view;
8020 var event = seg.event;
8021 var isDraggable = view.isEventDraggable(event);
8022 var isResizableFromStart = !disableResizing && seg.isStart && view.isEventResizableFromStart(event);
8023 var isResizableFromEnd = !disableResizing && seg.isEnd && view.isEventResizableFromEnd(event);
8024 var classes = this.getSegClasses(seg, isDraggable, isResizableFromStart || isResizableFromEnd);
8025 var skinCss = cssToStr(this.getSegSkinCss(seg));
8026 var timeText;
8027 var fullTimeText; // more verbose time text. for the print stylesheet
8028 var startTimeText; // just the start time text
8029
8030 classes.unshift('fc-time-grid-event', 'fc-v-event');
8031
8032 if (view.isMultiDayEvent(event)) { // if the event appears to span more than one day...
8033 // Don't display time text on segments that run entirely through a day.
8034 // That would appear as midnight-midnight and would look dumb.
8035 // Otherwise, display the time text for the *segment's* times (like 6pm-midnight or midnight-10am)
8036 if (seg.isStart || seg.isEnd) {
8037 timeText = this.getEventTimeText(seg);
8038 fullTimeText = this.getEventTimeText(seg, 'LT');
8039 startTimeText = this.getEventTimeText(seg, null, false); // displayEnd=false
8040 }
8041 } else {
8042 // Display the normal time text for the *event's* times
8043 timeText = this.getEventTimeText(event);
8044 fullTimeText = this.getEventTimeText(event, 'LT');
8045 startTimeText = this.getEventTimeText(event, null, false); // displayEnd=false
8046 }
8047
8048 return '<a class="' + classes.join(' ') + '"' +
8049 (event.url ?
8050 ' href="' + htmlEscape(event.url) + '"' :
8051 ''
8052 ) +
8053 (skinCss ?
8054 ' style="' + skinCss + '"' :
8055 ''
8056 ) +
8057 '>' +
8058 '<div class="fc-content">' +
8059 (timeText ?
8060 '<div class="fc-time"' +
8061 ' data-start="' + htmlEscape(startTimeText) + '"' +
8062 ' data-full="' + htmlEscape(fullTimeText) + '"' +
8063 '>' +
8064 '<span>' + htmlEscape(timeText) + '</span>' +
8065 '</div>' :
8066 ''
8067 ) +
8068 (event.title ?
8069 '<div class="fc-title">' +
8070 htmlEscape(event.title) +
8071 '</div>' :
8072 ''
8073 ) +
8074 '</div>' +
8075 '<div class="fc-bg"/>' +
8076 /* TODO: write CSS for this
8077 (isResizableFromStart ?
8078 '<div class="fc-resizer fc-start-resizer" />' :
8079 ''
8080 ) +
8081 */
8082 (isResizableFromEnd ?
8083 '<div class="fc-resizer fc-end-resizer" />' :
8084 ''
8085 ) +
8086 '</a>';
8087 },
8088
8089
8090 /* Seg Position Utils
8091 ------------------------------------------------------------------------------------------------------------------*/
8092
8093
8094 // Refreshes the CSS top/bottom coordinates for each segment element.
8095 // Works when called after initial render, after a window resize/zoom for example.
8096 updateSegVerticals: function(segs) {
8097 this.computeSegVerticals(segs);
8098 this.assignSegVerticals(segs);
8099 },
8100
8101
8102 // For each segment in an array, computes and assigns its top and bottom properties
8103 computeSegVerticals: function(segs) {
8104 var i, seg;
8105
8106 for (i = 0; i < segs.length; i++) {
8107 seg = segs[i];
8108 seg.top = this.computeDateTop(seg.start, seg.start);
8109 seg.bottom = this.computeDateTop(seg.end, seg.start);
8110 }
8111 },
8112
8113
8114 // Given segments that already have their top/bottom properties computed, applies those values to
8115 // the segments' elements.
8116 assignSegVerticals: function(segs) {
8117 var i, seg;
8118
8119 for (i = 0; i < segs.length; i++) {
8120 seg = segs[i];
8121 seg.el.css(this.generateSegVerticalCss(seg));
8122 }
8123 },
8124
8125
8126 // Generates an object with CSS properties for the top/bottom coordinates of a segment element
8127 generateSegVerticalCss: function(seg) {
8128 return {
8129 top: seg.top,
8130 bottom: -seg.bottom // flipped because needs to be space beyond bottom edge of event container
8131 };
8132 },
8133
8134
8135 /* Foreground Event Positioning Utils
8136 ------------------------------------------------------------------------------------------------------------------*/
8137
8138
8139 // Given segments that are assumed to all live in the *same column*,
8140 // compute their verical/horizontal coordinates and assign to their elements.
8141 updateFgSegCoords: function(segs) {
8142 this.computeSegVerticals(segs); // horizontals relies on this
8143 this.computeFgSegHorizontals(segs); // compute horizontal coordinates, z-index's, and reorder the array
8144 this.assignSegVerticals(segs);
8145 this.assignFgSegHorizontals(segs);
8146 },
8147
8148
8149 // Given an array of segments that are all in the same column, sets the backwardCoord and forwardCoord on each.
8150 // NOTE: Also reorders the given array by date!
8151 computeFgSegHorizontals: function(segs) {
8152 var levels;
8153 var level0;
8154 var i;
8155
8156 this.sortEventSegs(segs); // order by certain criteria
8157 levels = buildSlotSegLevels(segs);
8158 computeForwardSlotSegs(levels);
8159
8160 if ((level0 = levels[0])) {
8161
8162 for (i = 0; i < level0.length; i++) {
8163 computeSlotSegPressures(level0[i]);
8164 }
8165
8166 for (i = 0; i < level0.length; i++) {
8167 this.computeFgSegForwardBack(level0[i], 0, 0);
8168 }
8169 }
8170 },
8171
8172
8173 // Calculate seg.forwardCoord and seg.backwardCoord for the segment, where both values range
8174 // from 0 to 1. If the calendar is left-to-right, the seg.backwardCoord maps to "left" and
8175 // seg.forwardCoord maps to "right" (via percentage). Vice-versa if the calendar is right-to-left.
8176 //
8177 // The segment might be part of a "series", which means consecutive segments with the same pressure
8178 // who's width is unknown until an edge has been hit. `seriesBackwardPressure` is the number of
8179 // segments behind this one in the current series, and `seriesBackwardCoord` is the starting
8180 // coordinate of the first segment in the series.
8181 computeFgSegForwardBack: function(seg, seriesBackwardPressure, seriesBackwardCoord) {
8182 var forwardSegs = seg.forwardSegs;
8183 var i;
8184
8185 if (seg.forwardCoord === undefined) { // not already computed
8186
8187 if (!forwardSegs.length) {
8188
8189 // if there are no forward segments, this segment should butt up against the edge
8190 seg.forwardCoord = 1;
8191 }
8192 else {
8193
8194 // sort highest pressure first
8195 this.sortForwardSegs(forwardSegs);
8196
8197 // this segment's forwardCoord will be calculated from the backwardCoord of the
8198 // highest-pressure forward segment.
8199 this.computeFgSegForwardBack(forwardSegs[0], seriesBackwardPressure + 1, seriesBackwardCoord);
8200 seg.forwardCoord = forwardSegs[0].backwardCoord;
8201 }
8202
8203 // calculate the backwardCoord from the forwardCoord. consider the series
8204 seg.backwardCoord = seg.forwardCoord -
8205 (seg.forwardCoord - seriesBackwardCoord) / // available width for series
8206 (seriesBackwardPressure + 1); // # of segments in the series
8207
8208 // use this segment's coordinates to computed the coordinates of the less-pressurized
8209 // forward segments
8210 for (i=0; i<forwardSegs.length; i++) {
8211 this.computeFgSegForwardBack(forwardSegs[i], 0, seg.forwardCoord);
8212 }
8213 }
8214 },
8215
8216
8217 sortForwardSegs: function(forwardSegs) {
8218 forwardSegs.sort(proxy(this, 'compareForwardSegs'));
8219 },
8220
8221
8222 // A cmp function for determining which forward segment to rely on more when computing coordinates.
8223 compareForwardSegs: function(seg1, seg2) {
8224 // put higher-pressure first
8225 return seg2.forwardPressure - seg1.forwardPressure ||
8226 // put segments that are closer to initial edge first (and favor ones with no coords yet)
8227 (seg1.backwardCoord || 0) - (seg2.backwardCoord || 0) ||
8228 // do normal sorting...
8229 this.compareEventSegs(seg1, seg2);
8230 },
8231
8232
8233 // Given foreground event segments that have already had their position coordinates computed,
8234 // assigns position-related CSS values to their elements.
8235 assignFgSegHorizontals: function(segs) {
8236 var i, seg;
8237
8238 for (i = 0; i < segs.length; i++) {
8239 seg = segs[i];
8240 seg.el.css(this.generateFgSegHorizontalCss(seg));
8241
8242 // if the height is short, add a className for alternate styling
8243 if (seg.bottom - seg.top < 30) {
8244 seg.el.addClass('fc-short');
8245 }
8246 }
8247 },
8248
8249
8250 // Generates an object with CSS properties/values that should be applied to an event segment element.
8251 // Contains important positioning-related properties that should be applied to any event element, customized or not.
8252 generateFgSegHorizontalCss: function(seg) {
8253 var shouldOverlap = this.view.opt('slotEventOverlap');
8254 var backwardCoord = seg.backwardCoord; // the left side if LTR. the right side if RTL. floating-point
8255 var forwardCoord = seg.forwardCoord; // the right side if LTR. the left side if RTL. floating-point
8256 var props = this.generateSegVerticalCss(seg); // get top/bottom first
8257 var left; // amount of space from left edge, a fraction of the total width
8258 var right; // amount of space from right edge, a fraction of the total width
8259
8260 if (shouldOverlap) {
8261 // double the width, but don't go beyond the maximum forward coordinate (1.0)
8262 forwardCoord = Math.min(1, backwardCoord + (forwardCoord - backwardCoord) * 2);
8263 }
8264
8265 if (this.isRTL) {
8266 left = 1 - forwardCoord;
8267 right = backwardCoord;
8268 }
8269 else {
8270 left = backwardCoord;
8271 right = 1 - forwardCoord;
8272 }
8273
8274 props.zIndex = seg.level + 1; // convert from 0-base to 1-based
8275 props.left = left * 100 + '%';
8276 props.right = right * 100 + '%';
8277
8278 if (shouldOverlap && seg.forwardPressure) {
8279 // add padding to the edge so that forward stacked events don't cover the resizer's icon
8280 props[this.isRTL ? 'marginLeft' : 'marginRight'] = 10 * 2; // 10 is a guesstimate of the icon's width
8281 }
8282
8283 return props;
8284 }
8285
8286 });
8287
8288
8289 // Builds an array of segments "levels". The first level will be the leftmost tier of segments if the calendar is
8290 // left-to-right, or the rightmost if the calendar is right-to-left. Assumes the segments are already ordered by date.
8291 function buildSlotSegLevels(segs) {
8292 var levels = [];
8293 var i, seg;
8294 var j;
8295
8296 for (i=0; i<segs.length; i++) {
8297 seg = segs[i];
8298
8299 // go through all the levels and stop on the first level where there are no collisions
8300 for (j=0; j<levels.length; j++) {
8301 if (!computeSlotSegCollisions(seg, levels[j]).length) {
8302 break;
8303 }
8304 }
8305
8306 seg.level = j;
8307
8308 (levels[j] || (levels[j] = [])).push(seg);
8309 }
8310
8311 return levels;
8312 }
8313
8314
8315 // For every segment, figure out the other segments that are in subsequent
8316 // levels that also occupy the same vertical space. Accumulate in seg.forwardSegs
8317 function computeForwardSlotSegs(levels) {
8318 var i, level;
8319 var j, seg;
8320 var k;
8321
8322 for (i=0; i<levels.length; i++) {
8323 level = levels[i];
8324
8325 for (j=0; j<level.length; j++) {
8326 seg = level[j];
8327
8328 seg.forwardSegs = [];
8329 for (k=i+1; k<levels.length; k++) {
8330 computeSlotSegCollisions(seg, levels[k], seg.forwardSegs);
8331 }
8332 }
8333 }
8334 }
8335
8336
8337 // Figure out which path forward (via seg.forwardSegs) results in the longest path until
8338 // the furthest edge is reached. The number of segments in this path will be seg.forwardPressure
8339 function computeSlotSegPressures(seg) {
8340 var forwardSegs = seg.forwardSegs;
8341 var forwardPressure = 0;
8342 var i, forwardSeg;
8343
8344 if (seg.forwardPressure === undefined) { // not already computed
8345
8346 for (i=0; i<forwardSegs.length; i++) {
8347 forwardSeg = forwardSegs[i];
8348
8349 // figure out the child's maximum forward path
8350 computeSlotSegPressures(forwardSeg);
8351
8352 // either use the existing maximum, or use the child's forward pressure
8353 // plus one (for the forwardSeg itself)
8354 forwardPressure = Math.max(
8355 forwardPressure,
8356 1 + forwardSeg.forwardPressure
8357 );
8358 }
8359
8360 seg.forwardPressure = forwardPressure;
8361 }
8362 }
8363
8364
8365 // Find all the segments in `otherSegs` that vertically collide with `seg`.
8366 // Append into an optionally-supplied `results` array and return.
8367 function computeSlotSegCollisions(seg, otherSegs, results) {
8368 results = results || [];
8369
8370 for (var i=0; i<otherSegs.length; i++) {
8371 if (isSlotSegCollision(seg, otherSegs[i])) {
8372 results.push(otherSegs[i]);
8373 }
8374 }
8375
8376 return results;
8377 }
8378
8379
8380 // Do these segments occupy the same vertical space?
8381 function isSlotSegCollision(seg1, seg2) {
8382 return seg1.bottom > seg2.top && seg1.top < seg2.bottom;
8383 }
8384
8385 ;;
8386
8387 /* An abstract class from which other views inherit from
8388 ----------------------------------------------------------------------------------------------------------------------*/
8389
8390 var View = FC.View = Class.extend(EmitterMixin, ListenerMixin, {
8391
8392 type: null, // subclass' view name (string)
8393 name: null, // deprecated. use `type` instead
8394 title: null, // the text that will be displayed in the header's title
8395
8396 calendar: null, // owner Calendar object
8397 options: null, // hash containing all options. already merged with view-specific-options
8398 el: null, // the view's containing element. set by Calendar
8399
8400 isDateSet: false,
8401 isDateRendered: false,
8402 dateRenderQueue: null,
8403
8404 isEventsBound: false,
8405 isEventsSet: false,
8406 isEventsRendered: false,
8407 eventRenderQueue: null,
8408
8409 // range the view is actually displaying (moments)
8410 start: null,
8411 end: null, // exclusive
8412
8413 // range the view is formally responsible for (moments)
8414 // may be different from start/end. for example, a month view might have 1st-31st, excluding padded dates
8415 intervalStart: null,
8416 intervalEnd: null, // exclusive
8417 intervalDuration: null,
8418 intervalUnit: null, // name of largest unit being displayed, like "month" or "week"
8419
8420 isRTL: false,
8421 isSelected: false, // boolean whether a range of time is user-selected or not
8422 selectedEvent: null,
8423
8424 eventOrderSpecs: null, // criteria for ordering events when they have same date/time
8425
8426 // classNames styled by jqui themes
8427 widgetHeaderClass: null,
8428 widgetContentClass: null,
8429 highlightStateClass: null,
8430
8431 // for date utils, computed from options
8432 nextDayThreshold: null,
8433 isHiddenDayHash: null,
8434
8435 // now indicator
8436 isNowIndicatorRendered: null,
8437 initialNowDate: null, // result first getNow call
8438 initialNowQueriedMs: null, // ms time the getNow was called
8439 nowIndicatorTimeoutID: null, // for refresh timing of now indicator
8440 nowIndicatorIntervalID: null, // "
8441
8442
8443 constructor: function(calendar, type, options, intervalDuration) {
8444
8445 this.calendar = calendar;
8446 this.type = this.name = type; // .name is deprecated
8447 this.options = options;
8448 this.intervalDuration = intervalDuration || moment.duration(1, 'day');
8449
8450 this.nextDayThreshold = moment.duration(this.opt('nextDayThreshold'));
8451 this.initThemingProps();
8452 this.initHiddenDays();
8453 this.isRTL = this.opt('isRTL');
8454
8455 this.eventOrderSpecs = parseFieldSpecs(this.opt('eventOrder'));
8456
8457 this.dateRenderQueue = new TaskQueue();
8458 this.eventRenderQueue = new TaskQueue(this.opt('eventRenderWait'));
8459
8460 this.initialize();
8461 },
8462
8463
8464 // A good place for subclasses to initialize member variables
8465 initialize: function() {
8466 // subclasses can implement
8467 },
8468
8469
8470 // Retrieves an option with the given name
8471 opt: function(name) {
8472 return this.options[name];
8473 },
8474
8475
8476 // Triggers handlers that are view-related. Modifies args before passing to calendar.
8477 publiclyTrigger: function(name, thisObj) { // arguments beyond thisObj are passed along
8478 var calendar = this.calendar;
8479
8480 return calendar.publiclyTrigger.apply(
8481 calendar,
8482 [name, thisObj || this].concat(
8483 Array.prototype.slice.call(arguments, 2), // arguments beyond thisObj
8484 [ this ] // always make the last argument a reference to the view. TODO: deprecate
8485 )
8486 );
8487 },
8488
8489
8490 // Returns a proxy of the given promise that will be rejected if the given event fires
8491 // before the promise resolves.
8492 rejectOn: function(eventName, promise) {
8493 var _this = this;
8494
8495 return new Promise(function(resolve, reject) {
8496 _this.one(eventName, reject);
8497
8498 function cleanup() {
8499 _this.off(eventName, reject);
8500 }
8501
8502 promise.then(function(res) { // success
8503 cleanup();
8504 resolve(res);
8505 }, function() { // failure
8506 cleanup();
8507 reject();
8508 });
8509 });
8510 },
8511
8512
8513 /* Date Computation
8514 ------------------------------------------------------------------------------------------------------------------*/
8515
8516
8517 // Updates all internal dates for displaying the given unzoned range.
8518 setRange: function(range) {
8519 $.extend(this, range); // assigns every property to this object's member variables
8520 this.updateTitle();
8521 },
8522
8523
8524 // Given a single current unzoned date, produce information about what range to display.
8525 // Subclasses can override. Must return all properties.
8526 computeRange: function(date) {
8527 var intervalUnit = computeIntervalUnit(this.intervalDuration);
8528 var intervalStart = date.clone().startOf(intervalUnit);
8529 var intervalEnd = intervalStart.clone().add(this.intervalDuration);
8530 var start, end;
8531
8532 // normalize the range's time-ambiguity
8533 if (/year|month|week|day/.test(intervalUnit)) { // whole-days?
8534 intervalStart.stripTime();
8535 intervalEnd.stripTime();
8536 }
8537 else { // needs to have a time?
8538 if (!intervalStart.hasTime()) {
8539 intervalStart = this.calendar.time(0); // give 00:00 time
8540 }
8541 if (!intervalEnd.hasTime()) {
8542 intervalEnd = this.calendar.time(0); // give 00:00 time
8543 }
8544 }
8545
8546 start = intervalStart.clone();
8547 start = this.skipHiddenDays(start);
8548 end = intervalEnd.clone();
8549 end = this.skipHiddenDays(end, -1, true); // exclusively move backwards
8550
8551 return {
8552 intervalUnit: intervalUnit,
8553 intervalStart: intervalStart,
8554 intervalEnd: intervalEnd,
8555 start: start,
8556 end: end
8557 };
8558 },
8559
8560
8561 // Computes the new date when the user hits the prev button, given the current date
8562 computePrevDate: function(date) {
8563 return this.massageCurrentDate(
8564 date.clone().startOf(this.intervalUnit).subtract(this.intervalDuration), -1
8565 );
8566 },
8567
8568
8569 // Computes the new date when the user hits the next button, given the current date
8570 computeNextDate: function(date) {
8571 return this.massageCurrentDate(
8572 date.clone().startOf(this.intervalUnit).add(this.intervalDuration)
8573 );
8574 },
8575
8576
8577 // Given an arbitrarily calculated current date of the calendar, returns a date that is ensured to be completely
8578 // visible. `direction` is optional and indicates which direction the current date was being
8579 // incremented or decremented (1 or -1).
8580 massageCurrentDate: function(date, direction) {
8581 if (this.intervalDuration.as('days') <= 1) { // if the view displays a single day or smaller
8582 if (this.isHiddenDay(date)) {
8583 date = this.skipHiddenDays(date, direction);
8584 date.startOf('day');
8585 }
8586 }
8587
8588 return date;
8589 },
8590
8591
8592 /* Title and Date Formatting
8593 ------------------------------------------------------------------------------------------------------------------*/
8594
8595
8596 // Sets the view's title property to the most updated computed value
8597 updateTitle: function() {
8598 this.title = this.computeTitle();
8599 this.calendar.setToolbarsTitle(this.title);
8600 },
8601
8602
8603 // Computes what the title at the top of the calendar should be for this view
8604 computeTitle: function() {
8605 var start, end;
8606
8607 // for views that span a large unit of time, show the proper interval, ignoring stray days before and after
8608 if (this.intervalUnit === 'year' || this.intervalUnit === 'month') {
8609 start = this.intervalStart;
8610 end = this.intervalEnd;
8611 }
8612 else { // for day units or smaller, use the actual day range
8613 start = this.start;
8614 end = this.end;
8615 }
8616
8617 return this.formatRange(
8618 {
8619 // in case intervalStart/End has a time, make sure timezone is correct
8620 start: this.calendar.applyTimezone(start),
8621 end: this.calendar.applyTimezone(end)
8622 },
8623 this.opt('titleFormat') || this.computeTitleFormat(),
8624 this.opt('titleRangeSeparator')
8625 );
8626 },
8627
8628
8629 // Generates the format string that should be used to generate the title for the current date range.
8630 // Attempts to compute the most appropriate format if not explicitly specified with `titleFormat`.
8631 computeTitleFormat: function() {
8632 if (this.intervalUnit == 'year') {
8633 return 'YYYY';
8634 }
8635 else if (this.intervalUnit == 'month') {
8636 return this.opt('monthYearFormat'); // like "September 2014"
8637 }
8638 else if (this.intervalDuration.as('days') > 1) {
8639 return 'll'; // multi-day range. shorter, like "Sep 9 - 10 2014"
8640 }
8641 else {
8642 return 'LL'; // one day. longer, like "September 9 2014"
8643 }
8644 },
8645
8646
8647 // Utility for formatting a range. Accepts a range object, formatting string, and optional separator.
8648 // Displays all-day ranges naturally, with an inclusive end. Takes the current isRTL into account.
8649 // The timezones of the dates within `range` will be respected.
8650 formatRange: function(range, formatStr, separator) {
8651 var end = range.end;
8652
8653 if (!end.hasTime()) { // all-day?
8654 end = end.clone().subtract(1); // convert to inclusive. last ms of previous day
8655 }
8656
8657 return formatRange(range.start, end, formatStr, separator, this.opt('isRTL'));
8658 },
8659
8660
8661 getAllDayHtml: function() {
8662 return this.opt('allDayHtml') || htmlEscape(this.opt('allDayText'));
8663 },
8664
8665
8666 /* Navigation
8667 ------------------------------------------------------------------------------------------------------------------*/
8668
8669
8670 // Generates HTML for an anchor to another view into the calendar.
8671 // Will either generate an <a> tag or a non-clickable <span> tag, depending on enabled settings.
8672 // `gotoOptions` can either be a moment input, or an object with the form:
8673 // { date, type, forceOff }
8674 // `type` is a view-type like "day" or "week". default value is "day".
8675 // `attrs` and `innerHtml` are use to generate the rest of the HTML tag.
8676 buildGotoAnchorHtml: function(gotoOptions, attrs, innerHtml) {
8677 var date, type, forceOff;
8678 var finalOptions;
8679
8680 if ($.isPlainObject(gotoOptions)) {
8681 date = gotoOptions.date;
8682 type = gotoOptions.type;
8683 forceOff = gotoOptions.forceOff;
8684 }
8685 else {
8686 date = gotoOptions; // a single moment input
8687 }
8688 date = FC.moment(date); // if a string, parse it
8689
8690 finalOptions = { // for serialization into the link
8691 date: date.format('YYYY-MM-DD'),
8692 type: type || 'day'
8693 };
8694
8695 if (typeof attrs === 'string') {
8696 innerHtml = attrs;
8697 attrs = null;
8698 }
8699
8700 attrs = attrs ? ' ' + attrsToStr(attrs) : ''; // will have a leading space
8701 innerHtml = innerHtml || '';
8702
8703 if (!forceOff && this.opt('navLinks')) {
8704 return '<a' + attrs +
8705 ' data-goto="' + htmlEscape(JSON.stringify(finalOptions)) + '">' +
8706 innerHtml +
8707 '</a>';
8708 }
8709 else {
8710 return '<span' + attrs + '>' +
8711 innerHtml +
8712 '</span>';
8713 }
8714 },
8715
8716
8717 // Rendering Non-date-related Content
8718 // -----------------------------------------------------------------------------------------------------------------
8719
8720
8721 // Sets the container element that the view should render inside of, does global DOM-related initializations,
8722 // and renders all the non-date-related content inside.
8723 setElement: function(el) {
8724 this.el = el;
8725 this.bindGlobalHandlers();
8726 this.renderSkeleton();
8727 },
8728
8729
8730 // Removes the view's container element from the DOM, clearing any content beforehand.
8731 // Undoes any other DOM-related attachments.
8732 removeElement: function() {
8733 this.unsetDate();
8734 this.unrenderSkeleton();
8735
8736 this.unbindGlobalHandlers();
8737
8738 this.el.remove();
8739 // NOTE: don't null-out this.el in case the View was destroyed within an API callback.
8740 // We don't null-out the View's other jQuery element references upon destroy,
8741 // so we shouldn't kill this.el either.
8742 },
8743
8744
8745 // Renders the basic structure of the view before any content is rendered
8746 renderSkeleton: function() {
8747 // subclasses should implement
8748 },
8749
8750
8751 // Unrenders the basic structure of the view
8752 unrenderSkeleton: function() {
8753 // subclasses should implement
8754 },
8755
8756
8757 // Date Setting/Unsetting
8758 // -----------------------------------------------------------------------------------------------------------------
8759
8760
8761 setDate: function(date) {
8762 var isReset = this.isDateSet;
8763
8764 this.isDateSet = true;
8765 this.handleDate(date, isReset);
8766 this.trigger(isReset ? 'dateReset' : 'dateSet', date);
8767 },
8768
8769
8770 unsetDate: function() {
8771 if (this.isDateSet) {
8772 this.isDateSet = false;
8773 this.handleDateUnset();
8774 this.trigger('dateUnset');
8775 }
8776 },
8777
8778
8779 // Date Handling
8780 // -----------------------------------------------------------------------------------------------------------------
8781
8782
8783 handleDate: function(date, isReset) {
8784 var _this = this;
8785
8786 this.unbindEvents(); // will do nothing if not already bound
8787 this.requestDateRender(date).then(function() {
8788 // wish we could start earlier, but setRange/computeRange needs to execute first
8789 _this.bindEvents(); // will request events
8790 });
8791 },
8792
8793
8794 handleDateUnset: function() {
8795 this.unbindEvents();
8796 this.requestDateUnrender();
8797 },
8798
8799
8800 // Date Render Queuing
8801 // -----------------------------------------------------------------------------------------------------------------
8802
8803
8804 // if date not specified, uses current
8805 requestDateRender: function(date) {
8806 var _this = this;
8807
8808 return this.dateRenderQueue.add(function() {
8809 return _this.executeDateRender(date);
8810 });
8811 },
8812
8813
8814 requestDateUnrender: function() {
8815 var _this = this;
8816
8817 return this.dateRenderQueue.add(function() {
8818 return _this.executeDateUnrender();
8819 });
8820 },
8821
8822
8823 // Date High-level Rendering
8824 // -----------------------------------------------------------------------------------------------------------------
8825
8826
8827 // if date not specified, uses current
8828 executeDateRender: function(date) {
8829 var _this = this;
8830
8831 // if rendering a new date, reset scroll to initial state (scrollTime)
8832 if (date) {
8833 this.captureInitialScroll();
8834 }
8835 else {
8836 this.captureScroll(); // a rerender of the current date
8837 }
8838
8839 this.freezeHeight();
8840
8841 return this.executeDateUnrender().then(function() {
8842
8843 if (date) {
8844 _this.setRange(_this.computeRange(date));
8845 }
8846
8847 if (_this.render) {
8848 _this.render(); // TODO: deprecate
8849 }
8850
8851 _this.renderDates();
8852 _this.updateSize();
8853 _this.renderBusinessHours(); // might need coordinates, so should go after updateSize()
8854 _this.startNowIndicator();
8855
8856 _this.thawHeight();
8857 _this.releaseScroll();
8858
8859 _this.isDateRendered = true;
8860 _this.onDateRender();
8861 _this.trigger('dateRender');
8862 });
8863 },
8864
8865
8866 executeDateUnrender: function() {
8867 var _this = this;
8868
8869 if (_this.isDateRendered) {
8870 return this.requestEventsUnrender().then(function() {
8871
8872 _this.unselect();
8873 _this.stopNowIndicator();
8874 _this.triggerUnrender();
8875 _this.unrenderBusinessHours();
8876 _this.unrenderDates();
8877
8878 if (_this.destroy) {
8879 _this.destroy(); // TODO: deprecate
8880 }
8881
8882 _this.isDateRendered = false;
8883 _this.trigger('dateUnrender');
8884 });
8885 }
8886 else {
8887 return Promise.resolve();
8888 }
8889 },
8890
8891
8892 // Date Rendering Triggers
8893 // -----------------------------------------------------------------------------------------------------------------
8894
8895
8896 onDateRender: function() {
8897 this.triggerRender();
8898 },
8899
8900
8901 // Date Low-level Rendering
8902 // -----------------------------------------------------------------------------------------------------------------
8903
8904
8905 // date-cell content only
8906 renderDates: function() {
8907 // subclasses should implement
8908 },
8909
8910
8911 // date-cell content only
8912 unrenderDates: function() {
8913 // subclasses should override
8914 },
8915
8916
8917 // Misc view rendering utils
8918 // -------------------------
8919
8920
8921 // Signals that the view's content has been rendered
8922 triggerRender: function() {
8923 this.publiclyTrigger('viewRender', this, this, this.el);
8924 },
8925
8926
8927 // Signals that the view's content is about to be unrendered
8928 triggerUnrender: function() {
8929 this.publiclyTrigger('viewDestroy', this, this, this.el);
8930 },
8931
8932
8933 // Binds DOM handlers to elements that reside outside the view container, such as the document
8934 bindGlobalHandlers: function() {
8935 this.listenTo(GlobalEmitter.get(), {
8936 touchstart: this.processUnselect,
8937 mousedown: this.handleDocumentMousedown
8938 });
8939 },
8940
8941
8942 // Unbinds DOM handlers from elements that reside outside the view container
8943 unbindGlobalHandlers: function() {
8944 this.stopListeningTo(GlobalEmitter.get());
8945 },
8946
8947
8948 // Initializes internal variables related to theming
8949 initThemingProps: function() {
8950 var tm = this.opt('theme') ? 'ui' : 'fc';
8951
8952 this.widgetHeaderClass = tm + '-widget-header';
8953 this.widgetContentClass = tm + '-widget-content';
8954 this.highlightStateClass = tm + '-state-highlight';
8955 },
8956
8957
8958 /* Business Hours
8959 ------------------------------------------------------------------------------------------------------------------*/
8960
8961
8962 // Renders business-hours onto the view. Assumes updateSize has already been called.
8963 renderBusinessHours: function() {
8964 // subclasses should implement
8965 },
8966
8967
8968 // Unrenders previously-rendered business-hours
8969 unrenderBusinessHours: function() {
8970 // subclasses should implement
8971 },
8972
8973
8974 /* Now Indicator
8975 ------------------------------------------------------------------------------------------------------------------*/
8976
8977
8978 // Immediately render the current time indicator and begins re-rendering it at an interval,
8979 // which is defined by this.getNowIndicatorUnit().
8980 // TODO: somehow do this for the current whole day's background too
8981 startNowIndicator: function() {
8982 var _this = this;
8983 var unit;
8984 var update;
8985 var delay; // ms wait value
8986
8987 if (this.opt('nowIndicator')) {
8988 unit = this.getNowIndicatorUnit();
8989 if (unit) {
8990 update = proxy(this, 'updateNowIndicator'); // bind to `this`
8991
8992 this.initialNowDate = this.calendar.getNow();
8993 this.initialNowQueriedMs = +new Date();
8994 this.renderNowIndicator(this.initialNowDate);
8995 this.isNowIndicatorRendered = true;
8996
8997 // wait until the beginning of the next interval
8998 delay = this.initialNowDate.clone().startOf(unit).add(1, unit) - this.initialNowDate;
8999 this.nowIndicatorTimeoutID = setTimeout(function() {
9000 _this.nowIndicatorTimeoutID = null;
9001 update();
9002 delay = +moment.duration(1, unit);
9003 delay = Math.max(100, delay); // prevent too frequent
9004 _this.nowIndicatorIntervalID = setInterval(update, delay); // update every interval
9005 }, delay);
9006 }
9007 }
9008 },
9009
9010
9011 // rerenders the now indicator, computing the new current time from the amount of time that has passed
9012 // since the initial getNow call.
9013 updateNowIndicator: function() {
9014 if (this.isNowIndicatorRendered) {
9015 this.unrenderNowIndicator();
9016 this.renderNowIndicator(
9017 this.initialNowDate.clone().add(new Date() - this.initialNowQueriedMs) // add ms
9018 );
9019 }
9020 },
9021
9022
9023 // Immediately unrenders the view's current time indicator and stops any re-rendering timers.
9024 // Won't cause side effects if indicator isn't rendered.
9025 stopNowIndicator: function() {
9026 if (this.isNowIndicatorRendered) {
9027
9028 if (this.nowIndicatorTimeoutID) {
9029 clearTimeout(this.nowIndicatorTimeoutID);
9030 this.nowIndicatorTimeoutID = null;
9031 }
9032 if (this.nowIndicatorIntervalID) {
9033 clearTimeout(this.nowIndicatorIntervalID);
9034 this.nowIndicatorIntervalID = null;
9035 }
9036
9037 this.unrenderNowIndicator();
9038 this.isNowIndicatorRendered = false;
9039 }
9040 },
9041
9042
9043 // Returns a string unit, like 'second' or 'minute' that defined how often the current time indicator
9044 // should be refreshed. If something falsy is returned, no time indicator is rendered at all.
9045 getNowIndicatorUnit: function() {
9046 // subclasses should implement
9047 },
9048
9049
9050 // Renders a current time indicator at the given datetime
9051 renderNowIndicator: function(date) {
9052 // subclasses should implement
9053 },
9054
9055
9056 // Undoes the rendering actions from renderNowIndicator
9057 unrenderNowIndicator: function() {
9058 // subclasses should implement
9059 },
9060
9061
9062 /* Dimensions
9063 ------------------------------------------------------------------------------------------------------------------*/
9064
9065
9066 // Refreshes anything dependant upon sizing of the container element of the grid
9067 updateSize: function(isResize) {
9068
9069 if (isResize) {
9070 this.captureScroll();
9071 }
9072
9073 this.updateHeight(isResize);
9074 this.updateWidth(isResize);
9075 this.updateNowIndicator();
9076
9077 if (isResize) {
9078 this.releaseScroll();
9079 }
9080 },
9081
9082
9083 // Refreshes the horizontal dimensions of the calendar
9084 updateWidth: function(isResize) {
9085 // subclasses should implement
9086 },
9087
9088
9089 // Refreshes the vertical dimensions of the calendar
9090 updateHeight: function(isResize) {
9091 var calendar = this.calendar; // we poll the calendar for height information
9092
9093 this.setHeight(
9094 calendar.getSuggestedViewHeight(),
9095 calendar.isHeightAuto()
9096 );
9097 },
9098
9099
9100 // Updates the vertical dimensions of the calendar to the specified height.
9101 // if `isAuto` is set to true, height becomes merely a suggestion and the view should use its "natural" height.
9102 setHeight: function(height, isAuto) {
9103 // subclasses should implement
9104 },
9105
9106
9107 /* Scroller
9108 ------------------------------------------------------------------------------------------------------------------*/
9109
9110
9111 capturedScroll: null,
9112 capturedScrollDepth: 0,
9113
9114
9115 captureScroll: function() {
9116 if (!(this.capturedScrollDepth++)) {
9117 this.capturedScroll = this.isDateRendered ? this.queryScroll() : {}; // require a render first
9118 return true; // root?
9119 }
9120 return false;
9121 },
9122
9123
9124 captureInitialScroll: function(forcedScroll) {
9125 if (this.captureScroll()) { // root?
9126 this.capturedScroll.isInitial = true;
9127
9128 if (forcedScroll) {
9129 $.extend(this.capturedScroll, forcedScroll);
9130 }
9131 else {
9132 this.capturedScroll.isComputed = true;
9133 }
9134 }
9135 },
9136
9137
9138 releaseScroll: function() {
9139 var scroll = this.capturedScroll;
9140 var isRoot = this.discardScroll();
9141
9142 if (scroll.isComputed) {
9143 if (isRoot) {
9144 // only compute initial scroll if it will actually be used (is the root capture)
9145 $.extend(scroll, this.computeInitialScroll());
9146 }
9147 else {
9148 scroll = null; // scroll couldn't be computed. don't apply it to the DOM
9149 }
9150 }
9151
9152 if (scroll) {
9153 // we act immediately on a releaseScroll operation, as opposed to captureScroll.
9154 // if capture/release wraps a render operation that screws up the scroll,
9155 // we still want to restore it a good state after, regardless of depth.
9156
9157 if (scroll.isInitial) {
9158 this.hardSetScroll(scroll); // outsmart how browsers set scroll on initial DOM
9159 }
9160 else {
9161 this.setScroll(scroll);
9162 }
9163 }
9164 },
9165
9166
9167 discardScroll: function() {
9168 if (!(--this.capturedScrollDepth)) {
9169 this.capturedScroll = null;
9170 return true; // root?
9171 }
9172 return false;
9173 },
9174
9175
9176 computeInitialScroll: function() {
9177 return {};
9178 },
9179
9180
9181 queryScroll: function() {
9182 return {};
9183 },
9184
9185
9186 hardSetScroll: function(scroll) {
9187 var _this = this;
9188 var exec = function() { _this.setScroll(scroll); };
9189 exec();
9190 setTimeout(exec, 0); // to surely clear the browser's initial scroll for the DOM
9191 },
9192
9193
9194 setScroll: function(scroll) {
9195 },
9196
9197
9198 /* Height Freezing
9199 ------------------------------------------------------------------------------------------------------------------*/
9200
9201
9202 freezeHeight: function() {
9203 this.calendar.freezeContentHeight();
9204 },
9205
9206
9207 thawHeight: function() {
9208 this.calendar.thawContentHeight();
9209 },
9210
9211
9212 // Event Binding/Unbinding
9213 // -----------------------------------------------------------------------------------------------------------------
9214
9215
9216 bindEvents: function() {
9217 var _this = this;
9218
9219 if (!this.isEventsBound) {
9220 this.isEventsBound = true;
9221 this.rejectOn('eventsUnbind', this.requestEvents()).then(function(events) { // TODO: test rejection
9222 _this.listenTo(_this.calendar, 'eventsReset', _this.setEvents);
9223 _this.setEvents(events);
9224 });
9225 }
9226 },
9227
9228
9229 unbindEvents: function() {
9230 if (this.isEventsBound) {
9231 this.isEventsBound = false;
9232 this.stopListeningTo(this.calendar, 'eventsReset');
9233 this.unsetEvents();
9234 this.trigger('eventsUnbind');
9235 }
9236 },
9237
9238
9239 // Event Setting/Unsetting
9240 // -----------------------------------------------------------------------------------------------------------------
9241
9242
9243 setEvents: function(events) {
9244 var isReset = this.isEventSet;
9245
9246 this.isEventsSet = true;
9247 this.handleEvents(events, isReset);
9248 this.trigger(isReset ? 'eventsReset' : 'eventsSet', events);
9249 },
9250
9251
9252 unsetEvents: function() {
9253 if (this.isEventsSet) {
9254 this.isEventsSet = false;
9255 this.handleEventsUnset();
9256 this.trigger('eventsUnset');
9257 }
9258 },
9259
9260
9261 whenEventsSet: function() {
9262 var _this = this;
9263
9264 if (this.isEventsSet) {
9265 return Promise.resolve(this.getCurrentEvents());
9266 }
9267 else {
9268 return new Promise(function(resolve) {
9269 _this.one('eventsSet', resolve);
9270 });
9271 }
9272 },
9273
9274
9275 // Event Handling
9276 // -----------------------------------------------------------------------------------------------------------------
9277
9278
9279 handleEvents: function(events, isReset) {
9280 this.requestEventsRender(events);
9281 },
9282
9283
9284 handleEventsUnset: function() {
9285 this.requestEventsUnrender();
9286 },
9287
9288
9289 // Event Render Queuing
9290 // -----------------------------------------------------------------------------------------------------------------
9291
9292
9293 // assumes any previous event renders have been cleared already
9294 requestEventsRender: function(events) {
9295 var _this = this;
9296
9297 return this.eventRenderQueue.add(function() { // might not return a promise if debounced!? bad
9298 return _this.executeEventsRender(events);
9299 });
9300 },
9301
9302
9303 requestEventsUnrender: function() {
9304 var _this = this;
9305
9306 if (this.isEventsRendered) {
9307 return this.eventRenderQueue.addQuickly(function() {
9308 return _this.executeEventsUnrender();
9309 });
9310 }
9311 else {
9312 return Promise.resolve();
9313 }
9314 },
9315
9316
9317 requestCurrentEventsRender: function() {
9318 if (this.isEventsSet) {
9319 this.requestEventsRender(this.getCurrentEvents());
9320 }
9321 else {
9322 return Promise.reject();
9323 }
9324 },
9325
9326
9327 // Event High-level Rendering
9328 // -----------------------------------------------------------------------------------------------------------------
9329
9330
9331 executeEventsRender: function(events) {
9332 var _this = this;
9333
9334 this.captureScroll();
9335 this.freezeHeight();
9336
9337 return this.executeEventsUnrender().then(function() {
9338 _this.renderEvents(events);
9339
9340 _this.thawHeight();
9341 _this.releaseScroll();
9342
9343 _this.isEventsRendered = true;
9344 _this.onEventsRender();
9345 _this.trigger('eventsRender');
9346 });
9347 },
9348
9349
9350 executeEventsUnrender: function() {
9351 if (this.isEventsRendered) {
9352 this.onBeforeEventsUnrender();
9353
9354 this.captureScroll();
9355 this.freezeHeight();
9356
9357 if (this.destroyEvents) {
9358 this.destroyEvents(); // TODO: deprecate
9359 }
9360
9361 this.unrenderEvents();
9362
9363 this.thawHeight();
9364 this.releaseScroll();
9365
9366 this.isEventsRendered = false;
9367 this.trigger('eventsUnrender');
9368 }
9369
9370 return Promise.resolve(); // always synchronous
9371 },
9372
9373
9374 // Event Rendering Triggers
9375 // -----------------------------------------------------------------------------------------------------------------
9376
9377
9378 // Signals that all events have been rendered
9379 onEventsRender: function() {
9380 this.renderedEventSegEach(function(seg) {
9381 this.publiclyTrigger('eventAfterRender', seg.event, seg.event, seg.el);
9382 });
9383 this.publiclyTrigger('eventAfterAllRender');
9384 },
9385
9386
9387 // Signals that all event elements are about to be removed
9388 onBeforeEventsUnrender: function() {
9389 this.renderedEventSegEach(function(seg) {
9390 this.publiclyTrigger('eventDestroy', seg.event, seg.event, seg.el);
9391 });
9392 },
9393
9394
9395 // Event Low-level Rendering
9396 // -----------------------------------------------------------------------------------------------------------------
9397
9398
9399 // Renders the events onto the view.
9400 renderEvents: function(events) {
9401 // subclasses should implement
9402 },
9403
9404
9405 // Removes event elements from the view.
9406 unrenderEvents: function() {
9407 // subclasses should implement
9408 },
9409
9410
9411 // Event Data Access
9412 // -----------------------------------------------------------------------------------------------------------------
9413
9414
9415 requestEvents: function() {
9416 return this.calendar.requestEvents(this.start, this.end);
9417 },
9418
9419
9420 getCurrentEvents: function() {
9421 return this.calendar.getPrunedEventCache();
9422 },
9423
9424
9425 // Event Rendering Utils
9426 // -----------------------------------------------------------------------------------------------------------------
9427
9428
9429 // Given an event and the default element used for rendering, returns the element that should actually be used.
9430 // Basically runs events and elements through the eventRender hook.
9431 resolveEventEl: function(event, el) {
9432 var custom = this.publiclyTrigger('eventRender', event, event, el);
9433
9434 if (custom === false) { // means don't render at all
9435 el = null;
9436 }
9437 else if (custom && custom !== true) {
9438 el = $(custom);
9439 }
9440
9441 return el;
9442 },
9443
9444
9445 // Hides all rendered event segments linked to the given event
9446 showEvent: function(event) {
9447 this.renderedEventSegEach(function(seg) {
9448 seg.el.css('visibility', '');
9449 }, event);
9450 },
9451
9452
9453 // Shows all rendered event segments linked to the given event
9454 hideEvent: function(event) {
9455 this.renderedEventSegEach(function(seg) {
9456 seg.el.css('visibility', 'hidden');
9457 }, event);
9458 },
9459
9460
9461 // Iterates through event segments that have been rendered (have an el). Goes through all by default.
9462 // If the optional `event` argument is specified, only iterates through segments linked to that event.
9463 // The `this` value of the callback function will be the view.
9464 renderedEventSegEach: function(func, event) {
9465 var segs = this.getEventSegs();
9466 var i;
9467
9468 for (i = 0; i < segs.length; i++) {
9469 if (!event || segs[i].event._id === event._id) {
9470 if (segs[i].el) {
9471 func.call(this, segs[i]);
9472 }
9473 }
9474 }
9475 },
9476
9477
9478 // Retrieves all the rendered segment objects for the view
9479 getEventSegs: function() {
9480 // subclasses must implement
9481 return [];
9482 },
9483
9484
9485 /* Event Drag-n-Drop
9486 ------------------------------------------------------------------------------------------------------------------*/
9487
9488
9489 // Computes if the given event is allowed to be dragged by the user
9490 isEventDraggable: function(event) {
9491 return this.isEventStartEditable(event);
9492 },
9493
9494
9495 isEventStartEditable: function(event) {
9496 return firstDefined(
9497 event.startEditable,
9498 (event.source || {}).startEditable,
9499 this.opt('eventStartEditable'),
9500 this.isEventGenerallyEditable(event)
9501 );
9502 },
9503
9504
9505 isEventGenerallyEditable: function(event) {
9506 return firstDefined(
9507 event.editable,
9508 (event.source || {}).editable,
9509 this.opt('editable')
9510 );
9511 },
9512
9513
9514 // Must be called when an event in the view is dropped onto new location.
9515 // `dropLocation` is an object that contains the new zoned start/end/allDay values for the event.
9516 reportSegDrop: function(seg, dropLocation, largeUnit, el, ev) {
9517 var calendar = this.calendar;
9518 var mutateResult = calendar.mutateSeg(seg, dropLocation, largeUnit);
9519 var undoFunc = function() {
9520 mutateResult.undo();
9521 calendar.reportEventChange();
9522 };
9523
9524 this.triggerEventDrop(seg.event, mutateResult.dateDelta, undoFunc, el, ev);
9525 calendar.reportEventChange(); // will rerender events
9526 },
9527
9528
9529 // Triggers event-drop handlers that have subscribed via the API
9530 triggerEventDrop: function(event, dateDelta, undoFunc, el, ev) {
9531 this.publiclyTrigger('eventDrop', el[0], event, dateDelta, undoFunc, ev, {}); // {} = jqui dummy
9532 },
9533
9534
9535 /* External Element Drag-n-Drop
9536 ------------------------------------------------------------------------------------------------------------------*/
9537
9538
9539 // Must be called when an external element, via jQuery UI, has been dropped onto the calendar.
9540 // `meta` is the parsed data that has been embedded into the dragging event.
9541 // `dropLocation` is an object that contains the new zoned start/end/allDay values for the event.
9542 reportExternalDrop: function(meta, dropLocation, el, ev, ui) {
9543 var eventProps = meta.eventProps;
9544 var eventInput;
9545 var event;
9546
9547 // Try to build an event object and render it. TODO: decouple the two
9548 if (eventProps) {
9549 eventInput = $.extend({}, eventProps, dropLocation);
9550 event = this.calendar.renderEvent(eventInput, meta.stick)[0]; // renderEvent returns an array
9551 }
9552
9553 this.triggerExternalDrop(event, dropLocation, el, ev, ui);
9554 },
9555
9556
9557 // Triggers external-drop handlers that have subscribed via the API
9558 triggerExternalDrop: function(event, dropLocation, el, ev, ui) {
9559
9560 // trigger 'drop' regardless of whether element represents an event
9561 this.publiclyTrigger('drop', el[0], dropLocation.start, ev, ui);
9562
9563 if (event) {
9564 this.publiclyTrigger('eventReceive', null, event); // signal an external event landed
9565 }
9566 },
9567
9568
9569 /* Drag-n-Drop Rendering (for both events and external elements)
9570 ------------------------------------------------------------------------------------------------------------------*/
9571
9572
9573 // Renders a visual indication of a event or external-element drag over the given drop zone.
9574 // If an external-element, seg will be `null`.
9575 // Must return elements used for any mock events.
9576 renderDrag: function(dropLocation, seg) {
9577 // subclasses must implement
9578 },
9579
9580
9581 // Unrenders a visual indication of an event or external-element being dragged.
9582 unrenderDrag: function() {
9583 // subclasses must implement
9584 },
9585
9586
9587 /* Event Resizing
9588 ------------------------------------------------------------------------------------------------------------------*/
9589
9590
9591 // Computes if the given event is allowed to be resized from its starting edge
9592 isEventResizableFromStart: function(event) {
9593 return this.opt('eventResizableFromStart') && this.isEventResizable(event);
9594 },
9595
9596
9597 // Computes if the given event is allowed to be resized from its ending edge
9598 isEventResizableFromEnd: function(event) {
9599 return this.isEventResizable(event);
9600 },
9601
9602
9603 // Computes if the given event is allowed to be resized by the user at all
9604 isEventResizable: function(event) {
9605 var source = event.source || {};
9606
9607 return firstDefined(
9608 event.durationEditable,
9609 source.durationEditable,
9610 this.opt('eventDurationEditable'),
9611 event.editable,
9612 source.editable,
9613 this.opt('editable')
9614 );
9615 },
9616
9617
9618 // Must be called when an event in the view has been resized to a new length
9619 reportSegResize: function(seg, resizeLocation, largeUnit, el, ev) {
9620 var calendar = this.calendar;
9621 var mutateResult = calendar.mutateSeg(seg, resizeLocation, largeUnit);
9622 var undoFunc = function() {
9623 mutateResult.undo();
9624 calendar.reportEventChange();
9625 };
9626
9627 this.triggerEventResize(seg.event, mutateResult.durationDelta, undoFunc, el, ev);
9628 calendar.reportEventChange(); // will rerender events
9629 },
9630
9631
9632 // Triggers event-resize handlers that have subscribed via the API
9633 triggerEventResize: function(event, durationDelta, undoFunc, el, ev) {
9634 this.publiclyTrigger('eventResize', el[0], event, durationDelta, undoFunc, ev, {}); // {} = jqui dummy
9635 },
9636
9637
9638 /* Selection (time range)
9639 ------------------------------------------------------------------------------------------------------------------*/
9640
9641
9642 // Selects a date span on the view. `start` and `end` are both Moments.
9643 // `ev` is the native mouse event that begin the interaction.
9644 select: function(span, ev) {
9645 this.unselect(ev);
9646 this.renderSelection(span);
9647 this.reportSelection(span, ev);
9648 },
9649
9650
9651 // Renders a visual indication of the selection
9652 renderSelection: function(span) {
9653 // subclasses should implement
9654 },
9655
9656
9657 // Called when a new selection is made. Updates internal state and triggers handlers.
9658 reportSelection: function(span, ev) {
9659 this.isSelected = true;
9660 this.triggerSelect(span, ev);
9661 },
9662
9663
9664 // Triggers handlers to 'select'
9665 triggerSelect: function(span, ev) {
9666 this.publiclyTrigger(
9667 'select',
9668 null,
9669 this.calendar.applyTimezone(span.start), // convert to calendar's tz for external API
9670 this.calendar.applyTimezone(span.end), // "
9671 ev
9672 );
9673 },
9674
9675
9676 // Undoes a selection. updates in the internal state and triggers handlers.
9677 // `ev` is the native mouse event that began the interaction.
9678 unselect: function(ev) {
9679 if (this.isSelected) {
9680 this.isSelected = false;
9681 if (this.destroySelection) {
9682 this.destroySelection(); // TODO: deprecate
9683 }
9684 this.unrenderSelection();
9685 this.publiclyTrigger('unselect', null, ev);
9686 }
9687 },
9688
9689
9690 // Unrenders a visual indication of selection
9691 unrenderSelection: function() {
9692 // subclasses should implement
9693 },
9694
9695
9696 /* Event Selection
9697 ------------------------------------------------------------------------------------------------------------------*/
9698
9699
9700 selectEvent: function(event) {
9701 if (!this.selectedEvent || this.selectedEvent !== event) {
9702 this.unselectEvent();
9703 this.renderedEventSegEach(function(seg) {
9704 seg.el.addClass('fc-selected');
9705 }, event);
9706 this.selectedEvent = event;
9707 }
9708 },
9709
9710
9711 unselectEvent: function() {
9712 if (this.selectedEvent) {
9713 this.renderedEventSegEach(function(seg) {
9714 seg.el.removeClass('fc-selected');
9715 }, this.selectedEvent);
9716 this.selectedEvent = null;
9717 }
9718 },
9719
9720
9721 isEventSelected: function(event) {
9722 // event references might change on refetchEvents(), while selectedEvent doesn't,
9723 // so compare IDs
9724 return this.selectedEvent && this.selectedEvent._id === event._id;
9725 },
9726
9727
9728 /* Mouse / Touch Unselecting (time range & event unselection)
9729 ------------------------------------------------------------------------------------------------------------------*/
9730 // TODO: move consistently to down/start or up/end?
9731 // TODO: don't kill previous selection if touch scrolling
9732
9733
9734 handleDocumentMousedown: function(ev) {
9735 if (isPrimaryMouseButton(ev)) {
9736 this.processUnselect(ev);
9737 }
9738 },
9739
9740
9741 processUnselect: function(ev) {
9742 this.processRangeUnselect(ev);
9743 this.processEventUnselect(ev);
9744 },
9745
9746
9747 processRangeUnselect: function(ev) {
9748 var ignore;
9749
9750 // is there a time-range selection?
9751 if (this.isSelected && this.opt('unselectAuto')) {
9752 // only unselect if the clicked element is not identical to or inside of an 'unselectCancel' element
9753 ignore = this.opt('unselectCancel');
9754 if (!ignore || !$(ev.target).closest(ignore).length) {
9755 this.unselect(ev);
9756 }
9757 }
9758 },
9759
9760
9761 processEventUnselect: function(ev) {
9762 if (this.selectedEvent) {
9763 if (!$(ev.target).closest('.fc-selected').length) {
9764 this.unselectEvent();
9765 }
9766 }
9767 },
9768
9769
9770 /* Day Click
9771 ------------------------------------------------------------------------------------------------------------------*/
9772
9773
9774 // Triggers handlers to 'dayClick'
9775 // Span has start/end of the clicked area. Only the start is useful.
9776 triggerDayClick: function(span, dayEl, ev) {
9777 this.publiclyTrigger(
9778 'dayClick',
9779 dayEl,
9780 this.calendar.applyTimezone(span.start), // convert to calendar's timezone for external API
9781 ev
9782 );
9783 },
9784
9785
9786 /* Date Utils
9787 ------------------------------------------------------------------------------------------------------------------*/
9788
9789
9790 // Initializes internal variables related to calculating hidden days-of-week
9791 initHiddenDays: function() {
9792 var hiddenDays = this.opt('hiddenDays') || []; // array of day-of-week indices that are hidden
9793 var isHiddenDayHash = []; // is the day-of-week hidden? (hash with day-of-week-index -> bool)
9794 var dayCnt = 0;
9795 var i;
9796
9797 if (this.opt('weekends') === false) {
9798 hiddenDays.push(0, 6); // 0=sunday, 6=saturday
9799 }
9800
9801 for (i = 0; i < 7; i++) {
9802 if (
9803 !(isHiddenDayHash[i] = $.inArray(i, hiddenDays) !== -1)
9804 ) {
9805 dayCnt++;
9806 }
9807 }
9808
9809 if (!dayCnt) {
9810 throw 'invalid hiddenDays'; // all days were hidden? bad.
9811 }
9812
9813 this.isHiddenDayHash = isHiddenDayHash;
9814 },
9815
9816
9817 // Is the current day hidden?
9818 // `day` is a day-of-week index (0-6), or a Moment
9819 isHiddenDay: function(day) {
9820 if (moment.isMoment(day)) {
9821 day = day.day();
9822 }
9823 return this.isHiddenDayHash[day];
9824 },
9825
9826
9827 // Incrementing the current day until it is no longer a hidden day, returning a copy.
9828 // If the initial value of `date` is not a hidden day, don't do anything.
9829 // Pass `isExclusive` as `true` if you are dealing with an end date.
9830 // `inc` defaults to `1` (increment one day forward each time)
9831 skipHiddenDays: function(date, inc, isExclusive) {
9832 var out = date.clone();
9833 inc = inc || 1;
9834 while (
9835 this.isHiddenDayHash[(out.day() + (isExclusive ? inc : 0) + 7) % 7]
9836 ) {
9837 out.add(inc, 'days');
9838 }
9839 return out;
9840 },
9841
9842
9843 // Returns the date range of the full days the given range visually appears to occupy.
9844 // Returns a new range object.
9845 computeDayRange: function(range) {
9846 var startDay = range.start.clone().stripTime(); // the beginning of the day the range starts
9847 var end = range.end;
9848 var endDay = null;
9849 var endTimeMS;
9850
9851 if (end) {
9852 endDay = end.clone().stripTime(); // the beginning of the day the range exclusively ends
9853 endTimeMS = +end.time(); // # of milliseconds into `endDay`
9854
9855 // If the end time is actually inclusively part of the next day and is equal to or
9856 // beyond the next day threshold, adjust the end to be the exclusive end of `endDay`.
9857 // Otherwise, leaving it as inclusive will cause it to exclude `endDay`.
9858 if (endTimeMS && endTimeMS >= this.nextDayThreshold) {
9859 endDay.add(1, 'days');
9860 }
9861 }
9862
9863 // If no end was specified, or if it is within `startDay` but not past nextDayThreshold,
9864 // assign the default duration of one day.
9865 if (!end || endDay <= startDay) {
9866 endDay = startDay.clone().add(1, 'days');
9867 }
9868
9869 return { start: startDay, end: endDay };
9870 },
9871
9872
9873 // Does the given event visually appear to occupy more than one day?
9874 isMultiDayEvent: function(event) {
9875 var range = this.computeDayRange(event); // event is range-ish
9876
9877 return range.end.diff(range.start, 'days') > 1;
9878 }
9879
9880 });
9881
9882 ;;
9883
9884 /*
9885 Embodies a div that has potential scrollbars
9886 */
9887 var Scroller = FC.Scroller = Class.extend({
9888
9889 el: null, // the guaranteed outer element
9890 scrollEl: null, // the element with the scrollbars
9891 overflowX: null,
9892 overflowY: null,
9893
9894
9895 constructor: function(options) {
9896 options = options || {};
9897 this.overflowX = options.overflowX || options.overflow || 'auto';
9898 this.overflowY = options.overflowY || options.overflow || 'auto';
9899 },
9900
9901
9902 render: function() {
9903 this.el = this.renderEl();
9904 this.applyOverflow();
9905 },
9906
9907
9908 renderEl: function() {
9909 return (this.scrollEl = $('<div class="fc-scroller"></div>'));
9910 },
9911
9912
9913 // sets to natural height, unlocks overflow
9914 clear: function() {
9915 this.setHeight('auto');
9916 this.applyOverflow();
9917 },
9918
9919
9920 destroy: function() {
9921 this.el.remove();
9922 },
9923
9924
9925 // Overflow
9926 // -----------------------------------------------------------------------------------------------------------------
9927
9928
9929 applyOverflow: function() {
9930 this.scrollEl.css({
9931 'overflow-x': this.overflowX,
9932 'overflow-y': this.overflowY
9933 });
9934 },
9935
9936
9937 // Causes any 'auto' overflow values to resolves to 'scroll' or 'hidden'.
9938 // Useful for preserving scrollbar widths regardless of future resizes.
9939 // Can pass in scrollbarWidths for optimization.
9940 lockOverflow: function(scrollbarWidths) {
9941 var overflowX = this.overflowX;
9942 var overflowY = this.overflowY;
9943
9944 scrollbarWidths = scrollbarWidths || this.getScrollbarWidths();
9945
9946 if (overflowX === 'auto') {
9947 overflowX = (
9948 scrollbarWidths.top || scrollbarWidths.bottom || // horizontal scrollbars?
9949 // OR scrolling pane with massless scrollbars?
9950 this.scrollEl[0].scrollWidth - 1 > this.scrollEl[0].clientWidth
9951 // subtract 1 because of IE off-by-one issue
9952 ) ? 'scroll' : 'hidden';
9953 }
9954
9955 if (overflowY === 'auto') {
9956 overflowY = (
9957 scrollbarWidths.left || scrollbarWidths.right || // vertical scrollbars?
9958 // OR scrolling pane with massless scrollbars?
9959 this.scrollEl[0].scrollHeight - 1 > this.scrollEl[0].clientHeight
9960 // subtract 1 because of IE off-by-one issue
9961 ) ? 'scroll' : 'hidden';
9962 }
9963
9964 this.scrollEl.css({ 'overflow-x': overflowX, 'overflow-y': overflowY });
9965 },
9966
9967
9968 // Getters / Setters
9969 // -----------------------------------------------------------------------------------------------------------------
9970
9971
9972 setHeight: function(height) {
9973 this.scrollEl.height(height);
9974 },
9975
9976
9977 getScrollTop: function() {
9978 return this.scrollEl.scrollTop();
9979 },
9980
9981
9982 setScrollTop: function(top) {
9983 this.scrollEl.scrollTop(top);
9984 },
9985
9986
9987 getClientWidth: function() {
9988 return this.scrollEl[0].clientWidth;
9989 },
9990
9991
9992 getClientHeight: function() {
9993 return this.scrollEl[0].clientHeight;
9994 },
9995
9996
9997 getScrollbarWidths: function() {
9998 return getScrollbarWidths(this.scrollEl);
9999 }
10000
10001 });
10002
10003 ;;
10004 function Iterator(items) {
10005 this.items = items || [];
10006 }
10007
10008
10009 /* Calls a method on every item passing the arguments through */
10010 Iterator.prototype.proxyCall = function(methodName) {
10011 var args = Array.prototype.slice.call(arguments, 1);
10012 var results = [];
10013
10014 this.items.forEach(function(item) {
10015 results.push(item[methodName].apply(item, args));
10016 });
10017
10018 return results;
10019 };
10020
10021 ;;
10022
10023 /* Toolbar with buttons and title
10024 ----------------------------------------------------------------------------------------------------------------------*/
10025
10026 function Toolbar(calendar, toolbarOptions) {
10027 var t = this;
10028
10029 // exports
10030 t.setToolbarOptions = setToolbarOptions;
10031 t.render = render;
10032 t.removeElement = removeElement;
10033 t.updateTitle = updateTitle;
10034 t.activateButton = activateButton;
10035 t.deactivateButton = deactivateButton;
10036 t.disableButton = disableButton;
10037 t.enableButton = enableButton;
10038 t.getViewsWithButtons = getViewsWithButtons;
10039 t.el = null; // mirrors local `el`
10040
10041 // locals
10042 var el;
10043 var viewsWithButtons = [];
10044 var tm;
10045
10046 // method to update toolbar-specific options, not calendar-wide options
10047 function setToolbarOptions(newToolbarOptions) {
10048 toolbarOptions = newToolbarOptions;
10049 }
10050
10051 // can be called repeatedly and will rerender
10052 function render() {
10053 var sections = toolbarOptions.layout;
10054
10055 tm = calendar.options.theme ? 'ui' : 'fc';
10056
10057 if (sections) {
10058 if (!el) {
10059 el = this.el = $("<div class='fc-toolbar "+ toolbarOptions.extraClasses + "'/>");
10060 }
10061 else {
10062 el.empty();
10063 }
10064 el.append(renderSection('left'))
10065 .append(renderSection('right'))
10066 .append(renderSection('center'))
10067 .append('<div class="fc-clear"/>');
10068 }
10069 else {
10070 removeElement();
10071 }
10072 }
10073
10074
10075 function removeElement() {
10076 if (el) {
10077 el.remove();
10078 el = t.el = null;
10079 }
10080 }
10081
10082
10083 function renderSection(position) {
10084 var sectionEl = $('<div class="fc-' + position + '"/>');
10085 var buttonStr = toolbarOptions.layout[position];
10086
10087 if (buttonStr) {
10088 $.each(buttonStr.split(' '), function(i) {
10089 var groupChildren = $();
10090 var isOnlyButtons = true;
10091 var groupEl;
10092
10093 $.each(this.split(','), function(j, buttonName) {
10094 var customButtonProps;
10095 var viewSpec;
10096 var buttonClick;
10097 var overrideText; // text explicitly set by calendar's constructor options. overcomes icons
10098 var defaultText;
10099 var themeIcon;
10100 var normalIcon;
10101 var innerHtml;
10102 var classes;
10103 var button; // the element
10104
10105 if (buttonName == 'title') {
10106 groupChildren = groupChildren.add($('<h2>&nbsp;</h2>')); // we always want it to take up height
10107 isOnlyButtons = false;
10108 }
10109 else {
10110 if ((customButtonProps = (calendar.options.customButtons || {})[buttonName])) {
10111 buttonClick = function(ev) {
10112 if (customButtonProps.click) {
10113 customButtonProps.click.call(button[0], ev);
10114 }
10115 };
10116 overrideText = ''; // icons will override text
10117 defaultText = customButtonProps.text;
10118 }
10119 else if ((viewSpec = calendar.getViewSpec(buttonName))) {
10120 buttonClick = function() {
10121 calendar.changeView(buttonName);
10122 };
10123 viewsWithButtons.push(buttonName);
10124 overrideText = viewSpec.buttonTextOverride;
10125 defaultText = viewSpec.buttonTextDefault;
10126 }
10127 else if (calendar[buttonName]) { // a calendar method
10128 buttonClick = function() {
10129 calendar[buttonName]();
10130 };
10131 overrideText = (calendar.overrides.buttonText || {})[buttonName];
10132 defaultText = calendar.options.buttonText[buttonName]; // everything else is considered default
10133 }
10134
10135 if (buttonClick) {
10136
10137 themeIcon =
10138 customButtonProps ?
10139 customButtonProps.themeIcon :
10140 calendar.options.themeButtonIcons[buttonName];
10141
10142 normalIcon =
10143 customButtonProps ?
10144 customButtonProps.icon :
10145 calendar.options.buttonIcons[buttonName];
10146
10147 if (overrideText) {
10148 innerHtml = htmlEscape(overrideText);
10149 }
10150 else if (themeIcon && calendar.options.theme) {
10151 innerHtml = "<span class='ui-icon ui-icon-" + themeIcon + "'></span>";
10152 }
10153 else if (normalIcon && !calendar.options.theme) {
10154 innerHtml = "<span class='fc-icon fc-icon-" + normalIcon + "'></span>";
10155 }
10156 else {
10157 innerHtml = htmlEscape(defaultText);
10158 }
10159
10160 classes = [
10161 'fc-' + buttonName + '-button',
10162 tm + '-button',
10163 tm + '-state-default'
10164 ];
10165
10166 button = $( // type="button" so that it doesn't submit a form
10167 '<button type="button" class="' + classes.join(' ') + '">' +
10168 innerHtml +
10169 '</button>'
10170 )
10171 .click(function(ev) {
10172 // don't process clicks for disabled buttons
10173 if (!button.hasClass(tm + '-state-disabled')) {
10174
10175 buttonClick(ev);
10176
10177 // after the click action, if the button becomes the "active" tab, or disabled,
10178 // it should never have a hover class, so remove it now.
10179 if (
10180 button.hasClass(tm + '-state-active') ||
10181 button.hasClass(tm + '-state-disabled')
10182 ) {
10183 button.removeClass(tm + '-state-hover');
10184 }
10185 }
10186 })
10187 .mousedown(function() {
10188 // the *down* effect (mouse pressed in).
10189 // only on buttons that are not the "active" tab, or disabled
10190 button
10191 .not('.' + tm + '-state-active')
10192 .not('.' + tm + '-state-disabled')
10193 .addClass(tm + '-state-down');
10194 })
10195 .mouseup(function() {
10196 // undo the *down* effect
10197 button.removeClass(tm + '-state-down');
10198 })
10199 .hover(
10200 function() {
10201 // the *hover* effect.
10202 // only on buttons that are not the "active" tab, or disabled
10203 button
10204 .not('.' + tm + '-state-active')
10205 .not('.' + tm + '-state-disabled')
10206 .addClass(tm + '-state-hover');
10207 },
10208 function() {
10209 // undo the *hover* effect
10210 button
10211 .removeClass(tm + '-state-hover')
10212 .removeClass(tm + '-state-down'); // if mouseleave happens before mouseup
10213 }
10214 );
10215
10216 groupChildren = groupChildren.add(button);
10217 }
10218 }
10219 });
10220
10221 if (isOnlyButtons) {
10222 groupChildren
10223 .first().addClass(tm + '-corner-left').end()
10224 .last().addClass(tm + '-corner-right').end();
10225 }
10226
10227 if (groupChildren.length > 1) {
10228 groupEl = $('<div/>');
10229 if (isOnlyButtons) {
10230 groupEl.addClass('fc-button-group');
10231 }
10232 groupEl.append(groupChildren);
10233 sectionEl.append(groupEl);
10234 }
10235 else {
10236 sectionEl.append(groupChildren); // 1 or 0 children
10237 }
10238 });
10239 }
10240
10241 return sectionEl;
10242 }
10243
10244
10245 function updateTitle(text) {
10246 if (el) {
10247 el.find('h2').text(text);
10248 }
10249 }
10250
10251
10252 function activateButton(buttonName) {
10253 if (el) {
10254 el.find('.fc-' + buttonName + '-button')
10255 .addClass(tm + '-state-active');
10256 }
10257 }
10258
10259
10260 function deactivateButton(buttonName) {
10261 if (el) {
10262 el.find('.fc-' + buttonName + '-button')
10263 .removeClass(tm + '-state-active');
10264 }
10265 }
10266
10267
10268 function disableButton(buttonName) {
10269 if (el) {
10270 el.find('.fc-' + buttonName + '-button')
10271 .prop('disabled', true)
10272 .addClass(tm + '-state-disabled');
10273 }
10274 }
10275
10276
10277 function enableButton(buttonName) {
10278 if (el) {
10279 el.find('.fc-' + buttonName + '-button')
10280 .prop('disabled', false)
10281 .removeClass(tm + '-state-disabled');
10282 }
10283 }
10284
10285
10286 function getViewsWithButtons() {
10287 return viewsWithButtons;
10288 }
10289
10290 }
10291
10292 ;;
10293
10294 var Calendar = FC.Calendar = Class.extend({
10295
10296 dirDefaults: null, // option defaults related to LTR or RTL
10297 localeDefaults: null, // option defaults related to current locale
10298 overrides: null, // option overrides given to the fullCalendar constructor
10299 dynamicOverrides: null, // options set with dynamic setter method. higher precedence than view overrides.
10300 options: null, // all defaults combined with overrides
10301 viewSpecCache: null, // cache of view definitions
10302 view: null, // current View object
10303 header: null,
10304 footer: null,
10305 loadingLevel: 0, // number of simultaneous loading tasks
10306
10307
10308 // a lot of this class' OOP logic is scoped within this constructor function,
10309 // but in the future, write individual methods on the prototype.
10310 constructor: Calendar_constructor,
10311
10312
10313 // Subclasses can override this for initialization logic after the constructor has been called
10314 initialize: function() {
10315 },
10316
10317
10318 // Computes the flattened options hash for the calendar and assigns to `this.options`.
10319 // Assumes this.overrides and this.dynamicOverrides have already been initialized.
10320 populateOptionsHash: function() {
10321 var locale, localeDefaults;
10322 var isRTL, dirDefaults;
10323
10324 locale = firstDefined( // explicit locale option given?
10325 this.dynamicOverrides.locale,
10326 this.overrides.locale
10327 );
10328 localeDefaults = localeOptionHash[locale];
10329 if (!localeDefaults) { // explicit locale option not given or invalid?
10330 locale = Calendar.defaults.locale;
10331 localeDefaults = localeOptionHash[locale] || {};
10332 }
10333
10334 isRTL = firstDefined( // based on options computed so far, is direction RTL?
10335 this.dynamicOverrides.isRTL,
10336 this.overrides.isRTL,
10337 localeDefaults.isRTL,
10338 Calendar.defaults.isRTL
10339 );
10340 dirDefaults = isRTL ? Calendar.rtlDefaults : {};
10341
10342 this.dirDefaults = dirDefaults;
10343 this.localeDefaults = localeDefaults;
10344 this.options = mergeOptions([ // merge defaults and overrides. lowest to highest precedence
10345 Calendar.defaults, // global defaults
10346 dirDefaults,
10347 localeDefaults,
10348 this.overrides,
10349 this.dynamicOverrides
10350 ]);
10351 populateInstanceComputableOptions(this.options); // fill in gaps with computed options
10352 },
10353
10354
10355 // Gets information about how to create a view. Will use a cache.
10356 getViewSpec: function(viewType) {
10357 var cache = this.viewSpecCache;
10358
10359 return cache[viewType] || (cache[viewType] = this.buildViewSpec(viewType));
10360 },
10361
10362
10363 // Given a duration singular unit, like "week" or "day", finds a matching view spec.
10364 // Preference is given to views that have corresponding buttons.
10365 getUnitViewSpec: function(unit) {
10366 var viewTypes;
10367 var i;
10368 var spec;
10369
10370 if ($.inArray(unit, intervalUnits) != -1) {
10371
10372 // put views that have buttons first. there will be duplicates, but oh well
10373 viewTypes = this.header.getViewsWithButtons(); // TODO: include footer as well?
10374 $.each(FC.views, function(viewType) { // all views
10375 viewTypes.push(viewType);
10376 });
10377
10378 for (i = 0; i < viewTypes.length; i++) {
10379 spec = this.getViewSpec(viewTypes[i]);
10380 if (spec) {
10381 if (spec.singleUnit == unit) {
10382 return spec;
10383 }
10384 }
10385 }
10386 }
10387 },
10388
10389
10390 // Builds an object with information on how to create a given view
10391 buildViewSpec: function(requestedViewType) {
10392 var viewOverrides = this.overrides.views || {};
10393 var specChain = []; // for the view. lowest to highest priority
10394 var defaultsChain = []; // for the view. lowest to highest priority
10395 var overridesChain = []; // for the view. lowest to highest priority
10396 var viewType = requestedViewType;
10397 var spec; // for the view
10398 var overrides; // for the view
10399 var duration;
10400 var unit;
10401
10402 // iterate from the specific view definition to a more general one until we hit an actual View class
10403 while (viewType) {
10404 spec = fcViews[viewType];
10405 overrides = viewOverrides[viewType];
10406 viewType = null; // clear. might repopulate for another iteration
10407
10408 if (typeof spec === 'function') { // TODO: deprecate
10409 spec = { 'class': spec };
10410 }
10411
10412 if (spec) {
10413 specChain.unshift(spec);
10414 defaultsChain.unshift(spec.defaults || {});
10415 duration = duration || spec.duration;
10416 viewType = viewType || spec.type;
10417 }
10418
10419 if (overrides) {
10420 overridesChain.unshift(overrides); // view-specific option hashes have options at zero-level
10421 duration = duration || overrides.duration;
10422 viewType = viewType || overrides.type;
10423 }
10424 }
10425
10426 spec = mergeProps(specChain);
10427 spec.type = requestedViewType;
10428 if (!spec['class']) {
10429 return false;
10430 }
10431
10432 if (duration) {
10433 duration = moment.duration(duration);
10434 if (duration.valueOf()) { // valid?
10435 spec.duration = duration;
10436 unit = computeIntervalUnit(duration);
10437
10438 // view is a single-unit duration, like "week" or "day"
10439 // incorporate options for this. lowest priority
10440 if (duration.as(unit) === 1) {
10441 spec.singleUnit = unit;
10442 overridesChain.unshift(viewOverrides[unit] || {});
10443 }
10444 }
10445 }
10446
10447 spec.defaults = mergeOptions(defaultsChain);
10448 spec.overrides = mergeOptions(overridesChain);
10449
10450 this.buildViewSpecOptions(spec);
10451 this.buildViewSpecButtonText(spec, requestedViewType);
10452
10453 return spec;
10454 },
10455
10456
10457 // Builds and assigns a view spec's options object from its already-assigned defaults and overrides
10458 buildViewSpecOptions: function(spec) {
10459 spec.options = mergeOptions([ // lowest to highest priority
10460 Calendar.defaults, // global defaults
10461 spec.defaults, // view's defaults (from ViewSubclass.defaults)
10462 this.dirDefaults,
10463 this.localeDefaults, // locale and dir take precedence over view's defaults!
10464 this.overrides, // calendar's overrides (options given to constructor)
10465 spec.overrides, // view's overrides (view-specific options)
10466 this.dynamicOverrides // dynamically set via setter. highest precedence
10467 ]);
10468 populateInstanceComputableOptions(spec.options);
10469 },
10470
10471
10472 // Computes and assigns a view spec's buttonText-related options
10473 buildViewSpecButtonText: function(spec, requestedViewType) {
10474
10475 // given an options object with a possible `buttonText` hash, lookup the buttonText for the
10476 // requested view, falling back to a generic unit entry like "week" or "day"
10477 function queryButtonText(options) {
10478 var buttonText = options.buttonText || {};
10479 return buttonText[requestedViewType] ||
10480 // view can decide to look up a certain key
10481 (spec.buttonTextKey ? buttonText[spec.buttonTextKey] : null) ||
10482 // a key like "month"
10483 (spec.singleUnit ? buttonText[spec.singleUnit] : null);
10484 }
10485
10486 // highest to lowest priority
10487 spec.buttonTextOverride =
10488 queryButtonText(this.dynamicOverrides) ||
10489 queryButtonText(this.overrides) || // constructor-specified buttonText lookup hash takes precedence
10490 spec.overrides.buttonText; // `buttonText` for view-specific options is a string
10491
10492 // highest to lowest priority. mirrors buildViewSpecOptions
10493 spec.buttonTextDefault =
10494 queryButtonText(this.localeDefaults) ||
10495 queryButtonText(this.dirDefaults) ||
10496 spec.defaults.buttonText || // a single string. from ViewSubclass.defaults
10497 queryButtonText(Calendar.defaults) ||
10498 (spec.duration ? this.humanizeDuration(spec.duration) : null) || // like "3 days"
10499 requestedViewType; // fall back to given view name
10500 },
10501
10502
10503 // Given a view name for a custom view or a standard view, creates a ready-to-go View object
10504 instantiateView: function(viewType) {
10505 var spec = this.getViewSpec(viewType);
10506
10507 return new spec['class'](this, viewType, spec.options, spec.duration);
10508 },
10509
10510
10511 // Returns a boolean about whether the view is okay to instantiate at some point
10512 isValidViewType: function(viewType) {
10513 return Boolean(this.getViewSpec(viewType));
10514 },
10515
10516
10517 // Should be called when any type of async data fetching begins
10518 pushLoading: function() {
10519 if (!(this.loadingLevel++)) {
10520 this.publiclyTrigger('loading', null, true, this.view);
10521 }
10522 },
10523
10524
10525 // Should be called when any type of async data fetching completes
10526 popLoading: function() {
10527 if (!(--this.loadingLevel)) {
10528 this.publiclyTrigger('loading', null, false, this.view);
10529 }
10530 },
10531
10532
10533 // Given arguments to the select method in the API, returns a span (unzoned start/end and other info)
10534 buildSelectSpan: function(zonedStartInput, zonedEndInput) {
10535 var start = this.moment(zonedStartInput).stripZone();
10536 var end;
10537
10538 if (zonedEndInput) {
10539 end = this.moment(zonedEndInput).stripZone();
10540 }
10541 else if (start.hasTime()) {
10542 end = start.clone().add(this.defaultTimedEventDuration);
10543 }
10544 else {
10545 end = start.clone().add(this.defaultAllDayEventDuration);
10546 }
10547
10548 return { start: start, end: end };
10549 }
10550
10551 });
10552
10553
10554 Calendar.mixin(EmitterMixin);
10555
10556
10557 function Calendar_constructor(element, overrides) {
10558 var t = this;
10559
10560 // declare the current calendar instance relies on GlobalEmitter. needed for garbage collection.
10561 GlobalEmitter.needed();
10562
10563
10564 // Exports
10565 // -----------------------------------------------------------------------------------
10566
10567 t.render = render;
10568 t.destroy = destroy;
10569 t.rerenderEvents = rerenderEvents;
10570 t.changeView = renderView; // `renderView` will switch to another view
10571 t.select = select;
10572 t.unselect = unselect;
10573 t.prev = prev;
10574 t.next = next;
10575 t.prevYear = prevYear;
10576 t.nextYear = nextYear;
10577 t.today = today;
10578 t.gotoDate = gotoDate;
10579 t.incrementDate = incrementDate;
10580 t.zoomTo = zoomTo;
10581 t.getDate = getDate;
10582 t.getCalendar = getCalendar;
10583 t.getView = getView;
10584 t.option = option; // getter/setter method
10585 t.publiclyTrigger = publiclyTrigger;
10586
10587
10588 // Options
10589 // -----------------------------------------------------------------------------------
10590
10591 t.dynamicOverrides = {};
10592 t.viewSpecCache = {};
10593 t.optionHandlers = {}; // for Calendar.options.js
10594 t.overrides = $.extend({}, overrides); // make a copy
10595
10596 t.populateOptionsHash(); // sets this.options
10597
10598
10599
10600 // Locale-data Internals
10601 // -----------------------------------------------------------------------------------
10602 // Apply overrides to the current locale's data
10603
10604 var localeData;
10605
10606 // Called immediately, and when any of the options change.
10607 // Happens before any internal objects rebuild or rerender, because this is very core.
10608 t.bindOptions([
10609 'locale', 'monthNames', 'monthNamesShort', 'dayNames', 'dayNamesShort', 'firstDay', 'weekNumberCalculation'
10610 ], function(locale, monthNames, monthNamesShort, dayNames, dayNamesShort, firstDay, weekNumberCalculation) {
10611
10612 // normalize
10613 if (weekNumberCalculation === 'iso') {
10614 weekNumberCalculation = 'ISO'; // normalize
10615 }
10616
10617 localeData = createObject( // make a cheap copy
10618 getMomentLocaleData(locale) // will fall back to en
10619 );
10620
10621 if (monthNames) {
10622 localeData._months = monthNames;
10623 }
10624 if (monthNamesShort) {
10625 localeData._monthsShort = monthNamesShort;
10626 }
10627 if (dayNames) {
10628 localeData._weekdays = dayNames;
10629 }
10630 if (dayNamesShort) {
10631 localeData._weekdaysShort = dayNamesShort;
10632 }
10633
10634 if (firstDay == null && weekNumberCalculation === 'ISO') {
10635 firstDay = 1;
10636 }
10637 if (firstDay != null) {
10638 var _week = createObject(localeData._week); // _week: { dow: # }
10639 _week.dow = firstDay;
10640 localeData._week = _week;
10641 }
10642
10643 if ( // whitelist certain kinds of input
10644 weekNumberCalculation === 'ISO' ||
10645 weekNumberCalculation === 'local' ||
10646 typeof weekNumberCalculation === 'function'
10647 ) {
10648 localeData._fullCalendar_weekCalc = weekNumberCalculation; // moment-ext will know what to do with it
10649 }
10650
10651 // If the internal current date object already exists, move to new locale.
10652 // We do NOT need to do this technique for event dates, because this happens when converting to "segments".
10653 if (date) {
10654 localizeMoment(date); // sets to localeData
10655 }
10656 });
10657
10658
10659 // Calendar-specific Date Utilities
10660 // -----------------------------------------------------------------------------------
10661
10662
10663 t.defaultAllDayEventDuration = moment.duration(t.options.defaultAllDayEventDuration);
10664 t.defaultTimedEventDuration = moment.duration(t.options.defaultTimedEventDuration);
10665
10666
10667 // Builds a moment using the settings of the current calendar: timezone and locale.
10668 // Accepts anything the vanilla moment() constructor accepts.
10669 t.moment = function() {
10670 var mom;
10671
10672 if (t.options.timezone === 'local') {
10673 mom = FC.moment.apply(null, arguments);
10674
10675 // Force the moment to be local, because FC.moment doesn't guarantee it.
10676 if (mom.hasTime()) { // don't give ambiguously-timed moments a local zone
10677 mom.local();
10678 }
10679 }
10680 else if (t.options.timezone === 'UTC') {
10681 mom = FC.moment.utc.apply(null, arguments); // process as UTC
10682 }
10683 else {
10684 mom = FC.moment.parseZone.apply(null, arguments); // let the input decide the zone
10685 }
10686
10687 localizeMoment(mom);
10688
10689 return mom;
10690 };
10691
10692
10693 // Updates the given moment's locale settings to the current calendar locale settings.
10694 function localizeMoment(mom) {
10695 mom._locale = localeData;
10696 }
10697 t.localizeMoment = localizeMoment;
10698
10699
10700 // Returns a boolean about whether or not the calendar knows how to calculate
10701 // the timezone offset of arbitrary dates in the current timezone.
10702 t.getIsAmbigTimezone = function() {
10703 return t.options.timezone !== 'local' && t.options.timezone !== 'UTC';
10704 };
10705
10706
10707 // Returns a copy of the given date in the current timezone. Has no effect on dates without times.
10708 t.applyTimezone = function(date) {
10709 if (!date.hasTime()) {
10710 return date.clone();
10711 }
10712
10713 var zonedDate = t.moment(date.toArray());
10714 var timeAdjust = date.time() - zonedDate.time();
10715 var adjustedZonedDate;
10716
10717 // Safari sometimes has problems with this coersion when near DST. Adjust if necessary. (bug #2396)
10718 if (timeAdjust) { // is the time result different than expected?
10719 adjustedZonedDate = zonedDate.clone().add(timeAdjust); // add milliseconds
10720 if (date.time() - adjustedZonedDate.time() === 0) { // does it match perfectly now?
10721 zonedDate = adjustedZonedDate;
10722 }
10723 }
10724
10725 return zonedDate;
10726 };
10727
10728
10729 // Returns a moment for the current date, as defined by the client's computer or from the `now` option.
10730 // Will return an moment with an ambiguous timezone.
10731 t.getNow = function() {
10732 var now = t.options.now;
10733 if (typeof now === 'function') {
10734 now = now();
10735 }
10736 return t.moment(now).stripZone();
10737 };
10738
10739
10740 // Get an event's normalized end date. If not present, calculate it from the defaults.
10741 t.getEventEnd = function(event) {
10742 if (event.end) {
10743 return event.end.clone();
10744 }
10745 else {
10746 return t.getDefaultEventEnd(event.allDay, event.start);
10747 }
10748 };
10749
10750
10751 // Given an event's allDay status and start date, return what its fallback end date should be.
10752 // TODO: rename to computeDefaultEventEnd
10753 t.getDefaultEventEnd = function(allDay, zonedStart) {
10754 var end = zonedStart.clone();
10755
10756 if (allDay) {
10757 end.stripTime().add(t.defaultAllDayEventDuration);
10758 }
10759 else {
10760 end.add(t.defaultTimedEventDuration);
10761 }
10762
10763 if (t.getIsAmbigTimezone()) {
10764 end.stripZone(); // we don't know what the tzo should be
10765 }
10766
10767 return end;
10768 };
10769
10770
10771 // Produces a human-readable string for the given duration.
10772 // Side-effect: changes the locale of the given duration.
10773 t.humanizeDuration = function(duration) {
10774 return duration.locale(t.options.locale).humanize();
10775 };
10776
10777
10778
10779 // Imports
10780 // -----------------------------------------------------------------------------------
10781
10782
10783 EventManager.call(t);
10784
10785
10786
10787 // Locals
10788 // -----------------------------------------------------------------------------------
10789
10790
10791 var _element = element[0];
10792 var toolbarsManager;
10793 var header;
10794 var footer;
10795 var content;
10796 var tm; // for making theme classes
10797 var currentView; // NOTE: keep this in sync with this.view
10798 var viewsByType = {}; // holds all instantiated view instances, current or not
10799 var suggestedViewHeight;
10800 var windowResizeProxy; // wraps the windowResize function
10801 var ignoreWindowResize = 0;
10802 var date; // unzoned
10803
10804
10805
10806 // Main Rendering
10807 // -----------------------------------------------------------------------------------
10808
10809
10810 // compute the initial ambig-timezone date
10811 if (t.options.defaultDate != null) {
10812 date = t.moment(t.options.defaultDate).stripZone();
10813 }
10814 else {
10815 date = t.getNow(); // getNow already returns unzoned
10816 }
10817
10818
10819 function render() {
10820 if (!content) {
10821 initialRender();
10822 }
10823 else if (elementVisible()) {
10824 // mainly for the public API
10825 calcSize();
10826 renderView();
10827 }
10828 }
10829
10830
10831 function initialRender() {
10832 element.addClass('fc');
10833
10834 // event delegation for nav links
10835 element.on('click.fc', 'a[data-goto]', function(ev) {
10836 var anchorEl = $(this);
10837 var gotoOptions = anchorEl.data('goto'); // will automatically parse JSON
10838 var date = t.moment(gotoOptions.date);
10839 var viewType = gotoOptions.type;
10840
10841 // property like "navLinkDayClick". might be a string or a function
10842 var customAction = currentView.opt('navLink' + capitaliseFirstLetter(viewType) + 'Click');
10843
10844 if (typeof customAction === 'function') {
10845 customAction(date, ev);
10846 }
10847 else {
10848 if (typeof customAction === 'string') {
10849 viewType = customAction;
10850 }
10851 zoomTo(date, viewType);
10852 }
10853 });
10854
10855 // called immediately, and upon option change
10856 t.bindOption('theme', function(theme) {
10857 tm = theme ? 'ui' : 'fc'; // affects a larger scope
10858 element.toggleClass('ui-widget', theme);
10859 element.toggleClass('fc-unthemed', !theme);
10860 });
10861
10862 // called immediately, and upon option change.
10863 // HACK: locale often affects isRTL, so we explicitly listen to that too.
10864 t.bindOptions([ 'isRTL', 'locale' ], function(isRTL) {
10865 element.toggleClass('fc-ltr', !isRTL);
10866 element.toggleClass('fc-rtl', isRTL);
10867 });
10868
10869 content = $("<div class='fc-view-container'/>").prependTo(element);
10870
10871 var toolbars = buildToolbars();
10872 toolbarsManager = new Iterator(toolbars);
10873
10874 header = t.header = toolbars[0];
10875 footer = t.footer = toolbars[1];
10876
10877 renderHeader();
10878 renderFooter();
10879 renderView(t.options.defaultView);
10880
10881 if (t.options.handleWindowResize) {
10882 windowResizeProxy = debounce(windowResize, t.options.windowResizeDelay); // prevents rapid calls
10883 $(window).resize(windowResizeProxy);
10884 }
10885 }
10886
10887
10888 function destroy() {
10889
10890 if (currentView) {
10891 currentView.removeElement();
10892
10893 // NOTE: don't null-out currentView/t.view in case API methods are called after destroy.
10894 // It is still the "current" view, just not rendered.
10895 }
10896
10897 toolbarsManager.proxyCall('removeElement');
10898 content.remove();
10899 element.removeClass('fc fc-ltr fc-rtl fc-unthemed ui-widget');
10900
10901 element.off('.fc'); // unbind nav link handlers
10902
10903 if (windowResizeProxy) {
10904 $(window).unbind('resize', windowResizeProxy);
10905 }
10906
10907 GlobalEmitter.unneeded();
10908 }
10909
10910
10911 function elementVisible() {
10912 return element.is(':visible');
10913 }
10914
10915
10916
10917 // View Rendering
10918 // -----------------------------------------------------------------------------------
10919
10920
10921 // Renders a view because of a date change, view-type change, or for the first time.
10922 // If not given a viewType, keep the current view but render different dates.
10923 // Accepts an optional scroll state to restore to.
10924 function renderView(viewType, forcedScroll) {
10925 ignoreWindowResize++;
10926
10927 var needsClearView = currentView && viewType && currentView.type !== viewType;
10928
10929 // if viewType is changing, remove the old view's rendering
10930 if (needsClearView) {
10931 freezeContentHeight(); // prevent a scroll jump when view element is removed
10932 clearView();
10933 }
10934
10935 // if viewType changed, or the view was never created, create a fresh view
10936 if (!currentView && viewType) {
10937 currentView = t.view =
10938 viewsByType[viewType] ||
10939 (viewsByType[viewType] = t.instantiateView(viewType));
10940
10941 currentView.setElement(
10942 $("<div class='fc-view fc-" + viewType + "-view' />").appendTo(content)
10943 );
10944 toolbarsManager.proxyCall('activateButton', viewType);
10945 }
10946
10947 if (currentView) {
10948
10949 // in case the view should render a period of time that is completely hidden
10950 date = currentView.massageCurrentDate(date);
10951
10952 // render or rerender the view
10953 if (
10954 !currentView.isDateSet ||
10955 !( // NOT within interval range signals an implicit date window change
10956 date >= currentView.intervalStart &&
10957 date < currentView.intervalEnd
10958 )
10959 ) {
10960 if (elementVisible()) {
10961
10962 if (forcedScroll) {
10963 currentView.captureInitialScroll(forcedScroll);
10964 }
10965
10966 currentView.setDate(date, forcedScroll);
10967
10968 if (forcedScroll) {
10969 currentView.releaseScroll();
10970 }
10971
10972 // need to do this after View::render, so dates are calculated
10973 // NOTE: view updates title text proactively
10974 updateToolbarsTodayButton();
10975 }
10976 }
10977 }
10978
10979 if (needsClearView) {
10980 thawContentHeight();
10981 }
10982
10983 ignoreWindowResize--;
10984 }
10985
10986
10987 // Unrenders the current view and reflects this change in the Header.
10988 // Unregsiters the `currentView`, but does not remove from viewByType hash.
10989 function clearView() {
10990 toolbarsManager.proxyCall('deactivateButton', currentView.type);
10991 currentView.removeElement();
10992 currentView = t.view = null;
10993 }
10994
10995
10996 // Destroys the view, including the view object. Then, re-instantiates it and renders it.
10997 // Maintains the same scroll state.
10998 // TODO: maintain any other user-manipulated state.
10999 function reinitView() {
11000 ignoreWindowResize++;
11001 freezeContentHeight();
11002
11003 var viewType = currentView.type;
11004 var scrollState = currentView.queryScroll();
11005 clearView();
11006 calcSize();
11007 renderView(viewType, scrollState);
11008
11009 thawContentHeight();
11010 ignoreWindowResize--;
11011 }
11012
11013
11014
11015 // Resizing
11016 // -----------------------------------------------------------------------------------
11017
11018
11019 t.getSuggestedViewHeight = function() {
11020 if (suggestedViewHeight === undefined) {
11021 calcSize();
11022 }
11023 return suggestedViewHeight;
11024 };
11025
11026
11027 t.isHeightAuto = function() {
11028 return t.options.contentHeight === 'auto' || t.options.height === 'auto';
11029 };
11030
11031
11032 function updateSize(shouldRecalc) {
11033 if (elementVisible()) {
11034
11035 if (shouldRecalc) {
11036 _calcSize();
11037 }
11038
11039 ignoreWindowResize++;
11040 currentView.updateSize(true); // isResize=true. will poll getSuggestedViewHeight() and isHeightAuto()
11041 ignoreWindowResize--;
11042
11043 return true; // signal success
11044 }
11045 }
11046
11047
11048 function calcSize() {
11049 if (elementVisible()) {
11050 _calcSize();
11051 }
11052 }
11053
11054
11055 function _calcSize() { // assumes elementVisible
11056 var contentHeightInput = t.options.contentHeight;
11057 var heightInput = t.options.height;
11058
11059 if (typeof contentHeightInput === 'number') { // exists and not 'auto'
11060 suggestedViewHeight = contentHeightInput;
11061 }
11062 else if (typeof contentHeightInput === 'function') { // exists and is a function
11063 suggestedViewHeight = contentHeightInput();
11064 }
11065 else if (typeof heightInput === 'number') { // exists and not 'auto'
11066 suggestedViewHeight = heightInput - queryToolbarsHeight();
11067 }
11068 else if (typeof heightInput === 'function') { // exists and is a function
11069 suggestedViewHeight = heightInput() - queryToolbarsHeight();
11070 }
11071 else if (heightInput === 'parent') { // set to height of parent element
11072 suggestedViewHeight = element.parent().height() - queryToolbarsHeight();
11073 }
11074 else {
11075 suggestedViewHeight = Math.round(content.width() / Math.max(t.options.aspectRatio, .5));
11076 }
11077 }
11078
11079
11080 function queryToolbarsHeight() {
11081 return toolbarsManager.items.reduce(function(accumulator, toolbar) {
11082 var toolbarHeight = toolbar.el ? toolbar.el.outerHeight(true) : 0; // includes margin
11083 return accumulator + toolbarHeight;
11084 }, 0);
11085 }
11086
11087
11088 function windowResize(ev) {
11089 if (
11090 !ignoreWindowResize &&
11091 ev.target === window && // so we don't process jqui "resize" events that have bubbled up
11092 currentView.start // view has already been rendered
11093 ) {
11094 if (updateSize(true)) {
11095 currentView.publiclyTrigger('windowResize', _element);
11096 }
11097 }
11098 }
11099
11100
11101
11102 /* Event Rendering
11103 -----------------------------------------------------------------------------*/
11104
11105
11106 function rerenderEvents() { // API method. destroys old events if previously rendered.
11107 if (elementVisible()) {
11108 t.reportEventChange(); // will re-trasmit events to the view, causing a rerender
11109 }
11110 }
11111
11112
11113
11114 /* Toolbars
11115 -----------------------------------------------------------------------------*/
11116
11117
11118 function buildToolbars() {
11119 return [
11120 new Toolbar(t, computeHeaderOptions()),
11121 new Toolbar(t, computeFooterOptions())
11122 ];
11123 }
11124
11125
11126 function computeHeaderOptions() {
11127 return {
11128 extraClasses: 'fc-header-toolbar',
11129 layout: t.options.header
11130 };
11131 }
11132
11133
11134 function computeFooterOptions() {
11135 return {
11136 extraClasses: 'fc-footer-toolbar',
11137 layout: t.options.footer
11138 };
11139 }
11140
11141
11142 // can be called repeatedly and Header will rerender
11143 function renderHeader() {
11144 header.setToolbarOptions(computeHeaderOptions());
11145 header.render();
11146 if (header.el) {
11147 element.prepend(header.el);
11148 }
11149 }
11150
11151
11152 // can be called repeatedly and Footer will rerender
11153 function renderFooter() {
11154 footer.setToolbarOptions(computeFooterOptions());
11155 footer.render();
11156 if (footer.el) {
11157 element.append(footer.el);
11158 }
11159 }
11160
11161
11162 t.setToolbarsTitle = function(title) {
11163 toolbarsManager.proxyCall('updateTitle', title);
11164 };
11165
11166
11167 function updateToolbarsTodayButton() {
11168 var now = t.getNow();
11169 if (now >= currentView.intervalStart && now < currentView.intervalEnd) {
11170 toolbarsManager.proxyCall('disableButton', 'today');
11171 }
11172 else {
11173 toolbarsManager.proxyCall('enableButton', 'today');
11174 }
11175 }
11176
11177
11178
11179 /* Selection
11180 -----------------------------------------------------------------------------*/
11181
11182
11183 // this public method receives start/end dates in any format, with any timezone
11184 function select(zonedStartInput, zonedEndInput) {
11185 currentView.select(
11186 t.buildSelectSpan.apply(t, arguments)
11187 );
11188 }
11189
11190
11191 function unselect() { // safe to be called before renderView
11192 if (currentView) {
11193 currentView.unselect();
11194 }
11195 }
11196
11197
11198
11199 /* Date
11200 -----------------------------------------------------------------------------*/
11201
11202
11203 function prev() {
11204 date = currentView.computePrevDate(date);
11205 renderView();
11206 }
11207
11208
11209 function next() {
11210 date = currentView.computeNextDate(date);
11211 renderView();
11212 }
11213
11214
11215 function prevYear() {
11216 date.add(-1, 'years');
11217 renderView();
11218 }
11219
11220
11221 function nextYear() {
11222 date.add(1, 'years');
11223 renderView();
11224 }
11225
11226
11227 function today() {
11228 date = t.getNow();
11229 renderView();
11230 }
11231
11232
11233 function gotoDate(zonedDateInput) {
11234 date = t.moment(zonedDateInput).stripZone();
11235 renderView();
11236 }
11237
11238
11239 function incrementDate(delta) {
11240 date.add(moment.duration(delta));
11241 renderView();
11242 }
11243
11244
11245 // Forces navigation to a view for the given date.
11246 // `viewType` can be a specific view name or a generic one like "week" or "day".
11247 function zoomTo(newDate, viewType) {
11248 var spec;
11249
11250 viewType = viewType || 'day'; // day is default zoom
11251 spec = t.getViewSpec(viewType) || t.getUnitViewSpec(viewType);
11252
11253 date = newDate.clone();
11254 renderView(spec ? spec.type : null);
11255 }
11256
11257
11258 // for external API
11259 function getDate() {
11260 return t.applyTimezone(date); // infuse the calendar's timezone
11261 }
11262
11263
11264
11265 /* Height "Freezing"
11266 -----------------------------------------------------------------------------*/
11267
11268
11269 t.freezeContentHeight = freezeContentHeight;
11270 t.thawContentHeight = thawContentHeight;
11271
11272 var freezeContentHeightDepth = 0;
11273
11274
11275 function freezeContentHeight() {
11276 if (!(freezeContentHeightDepth++)) {
11277 content.css({
11278 width: '100%',
11279 height: content.height(),
11280 overflow: 'hidden'
11281 });
11282 }
11283 }
11284
11285
11286 function thawContentHeight() {
11287 if (!(--freezeContentHeightDepth)) {
11288 content.css({
11289 width: '',
11290 height: '',
11291 overflow: ''
11292 });
11293 }
11294 }
11295
11296
11297
11298 /* Misc
11299 -----------------------------------------------------------------------------*/
11300
11301
11302 function getCalendar() {
11303 return t;
11304 }
11305
11306
11307 function getView() {
11308 return currentView;
11309 }
11310
11311
11312 function option(name, value) {
11313 var newOptionHash;
11314
11315 if (typeof name === 'string') {
11316 if (value === undefined) { // getter
11317 return t.options[name];
11318 }
11319 else { // setter for individual option
11320 newOptionHash = {};
11321 newOptionHash[name] = value;
11322 setOptions(newOptionHash);
11323 }
11324 }
11325 else if (typeof name === 'object') { // compound setter with object input
11326 setOptions(name);
11327 }
11328 }
11329
11330
11331 function setOptions(newOptionHash) {
11332 var optionCnt = 0;
11333 var optionName;
11334
11335 for (optionName in newOptionHash) {
11336 t.dynamicOverrides[optionName] = newOptionHash[optionName];
11337 }
11338
11339 t.viewSpecCache = {}; // the dynamic override invalidates the options in this cache, so just clear it
11340 t.populateOptionsHash(); // this.options needs to be recomputed after the dynamic override
11341
11342 // trigger handlers after this.options has been updated
11343 for (optionName in newOptionHash) {
11344 t.triggerOptionHandlers(optionName); // recall bindOption/bindOptions
11345 optionCnt++;
11346 }
11347
11348 // special-case handling of single option change.
11349 // if only one option change, `optionName` will be its name.
11350 if (optionCnt === 1) {
11351 if (optionName === 'height' || optionName === 'contentHeight' || optionName === 'aspectRatio') {
11352 updateSize(true); // true = allow recalculation of height
11353 return;
11354 }
11355 else if (optionName === 'defaultDate') {
11356 return; // can't change date this way. use gotoDate instead
11357 }
11358 else if (optionName === 'businessHours') {
11359 if (currentView) {
11360 currentView.unrenderBusinessHours();
11361 currentView.renderBusinessHours();
11362 }
11363 return;
11364 }
11365 else if (optionName === 'timezone') {
11366 t.rezoneArrayEventSources();
11367 t.refetchEvents();
11368 return;
11369 }
11370 }
11371
11372 // catch-all. rerender the header and footer and rebuild/rerender the current view
11373 renderHeader();
11374 renderFooter();
11375 viewsByType = {}; // even non-current views will be affected by this option change. do before rerender
11376 reinitView();
11377 }
11378
11379
11380 function publiclyTrigger(name, thisObj) {
11381 var args = Array.prototype.slice.call(arguments, 2);
11382
11383 thisObj = thisObj || _element;
11384 this.triggerWith(name, thisObj, args); // Emitter's method
11385
11386 if (t.options[name]) {
11387 return t.options[name].apply(thisObj, args);
11388 }
11389 }
11390
11391 t.initialize();
11392 }
11393
11394 ;;
11395 /*
11396 Options binding/triggering system.
11397 */
11398 Calendar.mixin({
11399
11400 // A map of option names to arrays of handler objects. Initialized to {} in Calendar.
11401 // Format for a handler object:
11402 // {
11403 // func // callback function to be called upon change
11404 // names // option names whose values should be given to func
11405 // }
11406 optionHandlers: null,
11407
11408 // Calls handlerFunc immediately, and when the given option has changed.
11409 // handlerFunc will be given the option value.
11410 bindOption: function(optionName, handlerFunc) {
11411 this.bindOptions([ optionName ], handlerFunc);
11412 },
11413
11414 // Calls handlerFunc immediately, and when any of the given options change.
11415 // handlerFunc will be given each option value as ordered function arguments.
11416 bindOptions: function(optionNames, handlerFunc) {
11417 var handlerObj = { func: handlerFunc, names: optionNames };
11418 var i;
11419
11420 for (i = 0; i < optionNames.length; i++) {
11421 this.registerOptionHandlerObj(optionNames[i], handlerObj);
11422 }
11423
11424 this.triggerOptionHandlerObj(handlerObj);
11425 },
11426
11427 // Puts the given handler object into the internal hash
11428 registerOptionHandlerObj: function(optionName, handlerObj) {
11429 (this.optionHandlers[optionName] || (this.optionHandlers[optionName] = []))
11430 .push(handlerObj);
11431 },
11432
11433 // Reports that the given option has changed, and calls all appropriate handlers.
11434 triggerOptionHandlers: function(optionName) {
11435 var handlerObjs = this.optionHandlers[optionName] || [];
11436 var i;
11437
11438 for (i = 0; i < handlerObjs.length; i++) {
11439 this.triggerOptionHandlerObj(handlerObjs[i]);
11440 }
11441 },
11442
11443 // Calls the callback for a specific handler object, passing in the appropriate arguments.
11444 triggerOptionHandlerObj: function(handlerObj) {
11445 var optionNames = handlerObj.names;
11446 var optionValues = [];
11447 var i;
11448
11449 for (i = 0; i < optionNames.length; i++) {
11450 optionValues.push(this.options[optionNames[i]]);
11451 }
11452
11453 handlerObj.func.apply(this, optionValues); // maintain the Calendar's `this` context
11454 }
11455
11456 });
11457
11458 ;;
11459
11460 Calendar.defaults = {
11461
11462 titleRangeSeparator: ' \u2013 ', // en dash
11463 monthYearFormat: 'MMMM YYYY', // required for en. other locales rely on datepicker computable option
11464
11465 defaultTimedEventDuration: '02:00:00',
11466 defaultAllDayEventDuration: { days: 1 },
11467 forceEventDuration: false,
11468 nextDayThreshold: '09:00:00', // 9am
11469
11470 // display
11471 defaultView: 'month',
11472 aspectRatio: 1.35,
11473 header: {
11474 left: 'title',
11475 center: '',
11476 right: 'today prev,next'
11477 },
11478 weekends: true,
11479 weekNumbers: false,
11480
11481 weekNumberTitle: 'W',
11482 weekNumberCalculation: 'local',
11483
11484 //editable: false,
11485
11486 //nowIndicator: false,
11487
11488 scrollTime: '06:00:00',
11489
11490 // event ajax
11491 lazyFetching: true,
11492 startParam: 'start',
11493 endParam: 'end',
11494 timezoneParam: 'timezone',
11495
11496 timezone: false,
11497
11498 //allDayDefault: undefined,
11499
11500 // locale
11501 isRTL: false,
11502 buttonText: {
11503 prev: "prev",
11504 next: "next",
11505 prevYear: "prev year",
11506 nextYear: "next year",
11507 year: 'year', // TODO: locale files need to specify this
11508 today: 'today',
11509 month: 'month',
11510 week: 'week',
11511 day: 'day'
11512 },
11513
11514 buttonIcons: {
11515 prev: 'left-single-arrow',
11516 next: 'right-single-arrow',
11517 prevYear: 'left-double-arrow',
11518 nextYear: 'right-double-arrow'
11519 },
11520
11521 allDayText: 'all-day',
11522
11523 // jquery-ui theming
11524 theme: false,
11525 themeButtonIcons: {
11526 prev: 'circle-triangle-w',
11527 next: 'circle-triangle-e',
11528 prevYear: 'seek-prev',
11529 nextYear: 'seek-next'
11530 },
11531
11532 //eventResizableFromStart: false,
11533 dragOpacity: .75,
11534 dragRevertDuration: 500,
11535 dragScroll: true,
11536
11537 //selectable: false,
11538 unselectAuto: true,
11539 //selectMinDistance: 0,
11540
11541 dropAccept: '*',
11542
11543 eventOrder: 'title',
11544 //eventRenderWait: null,
11545
11546 eventLimit: false,
11547 eventLimitText: 'more',
11548 eventLimitClick: 'popover',
11549 dayPopoverFormat: 'LL',
11550
11551 handleWindowResize: true,
11552 windowResizeDelay: 100, // milliseconds before an updateSize happens
11553
11554 longPressDelay: 1000
11555
11556 };
11557
11558
11559 Calendar.englishDefaults = { // used by locale.js
11560 dayPopoverFormat: 'dddd, MMMM D'
11561 };
11562
11563
11564 Calendar.rtlDefaults = { // right-to-left defaults
11565 header: { // TODO: smarter solution (first/center/last ?)
11566 left: 'next,prev today',
11567 center: '',
11568 right: 'title'
11569 },
11570 buttonIcons: {
11571 prev: 'right-single-arrow',
11572 next: 'left-single-arrow',
11573 prevYear: 'right-double-arrow',
11574 nextYear: 'left-double-arrow'
11575 },
11576 themeButtonIcons: {
11577 prev: 'circle-triangle-e',
11578 next: 'circle-triangle-w',
11579 nextYear: 'seek-prev',
11580 prevYear: 'seek-next'
11581 }
11582 };
11583
11584 ;;
11585
11586 var localeOptionHash = FC.locales = {}; // initialize and expose
11587
11588
11589 // TODO: document the structure and ordering of a FullCalendar locale file
11590
11591
11592 // Initialize jQuery UI datepicker translations while using some of the translations
11593 // Will set this as the default locales for datepicker.
11594 FC.datepickerLocale = function(localeCode, dpLocaleCode, dpOptions) {
11595
11596 // get the FullCalendar internal option hash for this locale. create if necessary
11597 var fcOptions = localeOptionHash[localeCode] || (localeOptionHash[localeCode] = {});
11598
11599 // transfer some simple options from datepicker to fc
11600 fcOptions.isRTL = dpOptions.isRTL;
11601 fcOptions.weekNumberTitle = dpOptions.weekHeader;
11602
11603 // compute some more complex options from datepicker
11604 $.each(dpComputableOptions, function(name, func) {
11605 fcOptions[name] = func(dpOptions);
11606 });
11607
11608 // is jQuery UI Datepicker is on the page?
11609 if ($.datepicker) {
11610
11611 // Register the locale data.
11612 // FullCalendar and MomentJS use locale codes like "pt-br" but Datepicker
11613 // does it like "pt-BR" or if it doesn't have the locale, maybe just "pt".
11614 // Make an alias so the locale can be referenced either way.
11615 $.datepicker.regional[dpLocaleCode] =
11616 $.datepicker.regional[localeCode] = // alias
11617 dpOptions;
11618
11619 // Alias 'en' to the default locale data. Do this every time.
11620 $.datepicker.regional.en = $.datepicker.regional[''];
11621
11622 // Set as Datepicker's global defaults.
11623 $.datepicker.setDefaults(dpOptions);
11624 }
11625 };
11626
11627
11628 // Sets FullCalendar-specific translations. Will set the locales as the global default.
11629 FC.locale = function(localeCode, newFcOptions) {
11630 var fcOptions;
11631 var momOptions;
11632
11633 // get the FullCalendar internal option hash for this locale. create if necessary
11634 fcOptions = localeOptionHash[localeCode] || (localeOptionHash[localeCode] = {});
11635
11636 // provided new options for this locales? merge them in
11637 if (newFcOptions) {
11638 fcOptions = localeOptionHash[localeCode] = mergeOptions([ fcOptions, newFcOptions ]);
11639 }
11640
11641 // compute locale options that weren't defined.
11642 // always do this. newFcOptions can be undefined when initializing from i18n file,
11643 // so no way to tell if this is an initialization or a default-setting.
11644 momOptions = getMomentLocaleData(localeCode); // will fall back to en
11645 $.each(momComputableOptions, function(name, func) {
11646 if (fcOptions[name] == null) {
11647 fcOptions[name] = func(momOptions, fcOptions);
11648 }
11649 });
11650
11651 // set it as the default locale for FullCalendar
11652 Calendar.defaults.locale = localeCode;
11653 };
11654
11655
11656 // NOTE: can't guarantee any of these computations will run because not every locale has datepicker
11657 // configs, so make sure there are English fallbacks for these in the defaults file.
11658 var dpComputableOptions = {
11659
11660 buttonText: function(dpOptions) {
11661 return {
11662 // the translations sometimes wrongly contain HTML entities
11663 prev: stripHtmlEntities(dpOptions.prevText),
11664 next: stripHtmlEntities(dpOptions.nextText),
11665 today: stripHtmlEntities(dpOptions.currentText)
11666 };
11667 },
11668
11669 // Produces format strings like "MMMM YYYY" -> "September 2014"
11670 monthYearFormat: function(dpOptions) {
11671 return dpOptions.showMonthAfterYear ?
11672 'YYYY[' + dpOptions.yearSuffix + '] MMMM' :
11673 'MMMM YYYY[' + dpOptions.yearSuffix + ']';
11674 }
11675
11676 };
11677
11678 var momComputableOptions = {
11679
11680 // Produces format strings like "ddd M/D" -> "Fri 9/15"
11681 dayOfMonthFormat: function(momOptions, fcOptions) {
11682 var format = momOptions.longDateFormat('l'); // for the format like "M/D/YYYY"
11683
11684 // strip the year off the edge, as well as other misc non-whitespace chars
11685 format = format.replace(/^Y+[^\w\s]*|[^\w\s]*Y+$/g, '');
11686
11687 if (fcOptions.isRTL) {
11688 format += ' ddd'; // for RTL, add day-of-week to end
11689 }
11690 else {
11691 format = 'ddd ' + format; // for LTR, add day-of-week to beginning
11692 }
11693 return format;
11694 },
11695
11696 // Produces format strings like "h:mma" -> "6:00pm"
11697 mediumTimeFormat: function(momOptions) { // can't be called `timeFormat` because collides with option
11698 return momOptions.longDateFormat('LT')
11699 .replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand
11700 },
11701
11702 // Produces format strings like "h(:mm)a" -> "6pm" / "6:30pm"
11703 smallTimeFormat: function(momOptions) {
11704 return momOptions.longDateFormat('LT')
11705 .replace(':mm', '(:mm)')
11706 .replace(/(\Wmm)$/, '($1)') // like above, but for foreign locales
11707 .replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand
11708 },
11709
11710 // Produces format strings like "h(:mm)t" -> "6p" / "6:30p"
11711 extraSmallTimeFormat: function(momOptions) {
11712 return momOptions.longDateFormat('LT')
11713 .replace(':mm', '(:mm)')
11714 .replace(/(\Wmm)$/, '($1)') // like above, but for foreign locales
11715 .replace(/\s*a$/i, 't'); // convert to AM/PM/am/pm to lowercase one-letter. remove any spaces beforehand
11716 },
11717
11718 // Produces format strings like "ha" / "H" -> "6pm" / "18"
11719 hourFormat: function(momOptions) {
11720 return momOptions.longDateFormat('LT')
11721 .replace(':mm', '')
11722 .replace(/(\Wmm)$/, '') // like above, but for foreign locales
11723 .replace(/\s*a$/i, 'a'); // convert AM/PM/am/pm to lowercase. remove any spaces beforehand
11724 },
11725
11726 // Produces format strings like "h:mm" -> "6:30" (with no AM/PM)
11727 noMeridiemTimeFormat: function(momOptions) {
11728 return momOptions.longDateFormat('LT')
11729 .replace(/\s*a$/i, ''); // remove trailing AM/PM
11730 }
11731
11732 };
11733
11734
11735 // options that should be computed off live calendar options (considers override options)
11736 // TODO: best place for this? related to locale?
11737 // TODO: flipping text based on isRTL is a bad idea because the CSS `direction` might want to handle it
11738 var instanceComputableOptions = {
11739
11740 // Produces format strings for results like "Mo 16"
11741 smallDayDateFormat: function(options) {
11742 return options.isRTL ?
11743 'D dd' :
11744 'dd D';
11745 },
11746
11747 // Produces format strings for results like "Wk 5"
11748 weekFormat: function(options) {
11749 return options.isRTL ?
11750 'w[ ' + options.weekNumberTitle + ']' :
11751 '[' + options.weekNumberTitle + ' ]w';
11752 },
11753
11754 // Produces format strings for results like "Wk5"
11755 smallWeekFormat: function(options) {
11756 return options.isRTL ?
11757 'w[' + options.weekNumberTitle + ']' :
11758 '[' + options.weekNumberTitle + ']w';
11759 }
11760
11761 };
11762
11763 function populateInstanceComputableOptions(options) {
11764 $.each(instanceComputableOptions, function(name, func) {
11765 if (options[name] == null) {
11766 options[name] = func(options);
11767 }
11768 });
11769 }
11770
11771
11772 // Returns moment's internal locale data. If doesn't exist, returns English.
11773 function getMomentLocaleData(localeCode) {
11774 return moment.localeData(localeCode) || moment.localeData('en');
11775 }
11776
11777
11778 // Initialize English by forcing computation of moment-derived options.
11779 // Also, sets it as the default.
11780 FC.locale('en', Calendar.englishDefaults);
11781
11782 ;;
11783
11784 FC.sourceNormalizers = [];
11785 FC.sourceFetchers = [];
11786
11787 var ajaxDefaults = {
11788 dataType: 'json',
11789 cache: false
11790 };
11791
11792 var eventGUID = 1;
11793
11794
11795 function EventManager() { // assumed to be a calendar
11796 var t = this;
11797
11798
11799 // exports
11800 t.requestEvents = requestEvents;
11801 t.reportEventChange = reportEventChange;
11802 t.isFetchNeeded = isFetchNeeded;
11803 t.fetchEvents = fetchEvents;
11804 t.fetchEventSources = fetchEventSources;
11805 t.refetchEvents = refetchEvents;
11806 t.refetchEventSources = refetchEventSources;
11807 t.getEventSources = getEventSources;
11808 t.getEventSourceById = getEventSourceById;
11809 t.addEventSource = addEventSource;
11810 t.removeEventSource = removeEventSource;
11811 t.removeEventSources = removeEventSources;
11812 t.updateEvent = updateEvent;
11813 t.updateEvents = updateEvents;
11814 t.renderEvent = renderEvent;
11815 t.renderEvents = renderEvents;
11816 t.removeEvents = removeEvents;
11817 t.clientEvents = clientEvents;
11818 t.mutateEvent = mutateEvent;
11819 t.normalizeEventDates = normalizeEventDates;
11820 t.normalizeEventTimes = normalizeEventTimes;
11821
11822
11823 // locals
11824 var stickySource = { events: [] };
11825 var sources = [ stickySource ];
11826 var rangeStart, rangeEnd;
11827 var pendingSourceCnt = 0; // outstanding fetch requests, max one per source
11828 var cache = []; // holds events that have already been expanded
11829 var prunedCache; // like cache, but only events that intersect with rangeStart/rangeEnd
11830
11831
11832 $.each(
11833 (t.options.events ? [ t.options.events ] : []).concat(t.options.eventSources || []),
11834 function(i, sourceInput) {
11835 var source = buildEventSource(sourceInput);
11836 if (source) {
11837 sources.push(source);
11838 }
11839 }
11840 );
11841
11842
11843
11844 function requestEvents(start, end) {
11845 if (!t.options.lazyFetching || isFetchNeeded(start, end)) {
11846 return fetchEvents(start, end);
11847 }
11848 else {
11849 return Promise.resolve(prunedCache);
11850 }
11851 }
11852
11853
11854 function reportEventChange() {
11855 prunedCache = filterEventsWithinRange(cache);
11856 t.trigger('eventsReset', prunedCache);
11857 }
11858
11859
11860 function filterEventsWithinRange(events) {
11861 var filteredEvents = [];
11862 var i, event;
11863
11864 for (i = 0; i < events.length; i++) {
11865 event = events[i];
11866
11867 if (
11868 event.start.clone().stripZone() < rangeEnd &&
11869 t.getEventEnd(event).stripZone() > rangeStart
11870 ) {
11871 filteredEvents.push(event);
11872 }
11873 }
11874
11875 return filteredEvents;
11876 }
11877
11878
11879 t.getEventCache = function() {
11880 return cache;
11881 };
11882
11883
11884 t.getPrunedEventCache = function() {
11885 return prunedCache;
11886 };
11887
11888
11889
11890 /* Fetching
11891 -----------------------------------------------------------------------------*/
11892
11893
11894 // start and end are assumed to be unzoned
11895 function isFetchNeeded(start, end) {
11896 return !rangeStart || // nothing has been fetched yet?
11897 start < rangeStart || end > rangeEnd; // is part of the new range outside of the old range?
11898 }
11899
11900
11901 function fetchEvents(start, end) {
11902 rangeStart = start;
11903 rangeEnd = end;
11904 return refetchEvents();
11905 }
11906
11907
11908 // poorly named. fetches all sources with current `rangeStart` and `rangeEnd`.
11909 function refetchEvents() {
11910 return fetchEventSources(sources, 'reset');
11911 }
11912
11913
11914 // poorly named. fetches a subset of event sources.
11915 function refetchEventSources(matchInputs) {
11916 return fetchEventSources(getEventSourcesByMatchArray(matchInputs));
11917 }
11918
11919
11920 // expects an array of event source objects (the originals, not copies)
11921 // `specialFetchType` is an optimization parameter that affects purging of the event cache.
11922 function fetchEventSources(specificSources, specialFetchType) {
11923 var i, source;
11924
11925 if (specialFetchType === 'reset') {
11926 cache = [];
11927 }
11928 else if (specialFetchType !== 'add') {
11929 cache = excludeEventsBySources(cache, specificSources);
11930 }
11931
11932 for (i = 0; i < specificSources.length; i++) {
11933 source = specificSources[i];
11934
11935 // already-pending sources have already been accounted for in pendingSourceCnt
11936 if (source._status !== 'pending') {
11937 pendingSourceCnt++;
11938 }
11939
11940 source._fetchId = (source._fetchId || 0) + 1;
11941 source._status = 'pending';
11942 }
11943
11944 for (i = 0; i < specificSources.length; i++) {
11945 source = specificSources[i];
11946 tryFetchEventSource(source, source._fetchId);
11947 }
11948
11949 if (pendingSourceCnt) {
11950 return new Promise(function(resolve) {
11951 t.one('eventsReceived', resolve); // will send prunedCache
11952 });
11953 }
11954 else { // executed all synchronously, or no sources at all
11955 return Promise.resolve(prunedCache);
11956 }
11957 }
11958
11959
11960 // fetches an event source and processes its result ONLY if it is still the current fetch.
11961 // caller is responsible for incrementing pendingSourceCnt first.
11962 function tryFetchEventSource(source, fetchId) {
11963 _fetchEventSource(source, function(eventInputs) {
11964 var isArraySource = $.isArray(source.events);
11965 var i, eventInput;
11966 var abstractEvent;
11967
11968 if (
11969 // is this the source's most recent fetch?
11970 // if not, rely on an upcoming fetch of this source to decrement pendingSourceCnt
11971 fetchId === source._fetchId &&
11972 // event source no longer valid?
11973 source._status !== 'rejected'
11974 ) {
11975 source._status = 'resolved';
11976
11977 if (eventInputs) {
11978 for (i = 0; i < eventInputs.length; i++) {
11979 eventInput = eventInputs[i];
11980
11981 if (isArraySource) { // array sources have already been convert to Event Objects
11982 abstractEvent = eventInput;
11983 }
11984 else {
11985 abstractEvent = buildEventFromInput(eventInput, source);
11986 }
11987
11988 if (abstractEvent) { // not false (an invalid event)
11989 cache.push.apply( // append
11990 cache,
11991 expandEvent(abstractEvent) // add individual expanded events to the cache
11992 );
11993 }
11994 }
11995 }
11996
11997 decrementPendingSourceCnt();
11998 }
11999 });
12000 }
12001
12002
12003 function rejectEventSource(source) {
12004 var wasPending = source._status === 'pending';
12005
12006 source._status = 'rejected';
12007
12008 if (wasPending) {
12009 decrementPendingSourceCnt();
12010 }
12011 }
12012
12013
12014 function decrementPendingSourceCnt() {
12015 pendingSourceCnt--;
12016 if (!pendingSourceCnt) {
12017 reportEventChange(cache); // updates prunedCache
12018 t.trigger('eventsReceived', prunedCache);
12019 }
12020 }
12021
12022
12023 function _fetchEventSource(source, callback) {
12024 var i;
12025 var fetchers = FC.sourceFetchers;
12026 var res;
12027
12028 for (i=0; i<fetchers.length; i++) {
12029 res = fetchers[i].call(
12030 t, // this, the Calendar object
12031 source,
12032 rangeStart.clone(),
12033 rangeEnd.clone(),
12034 t.options.timezone,
12035 callback
12036 );
12037
12038 if (res === true) {
12039 // the fetcher is in charge. made its own async request
12040 return;
12041 }
12042 else if (typeof res == 'object') {
12043 // the fetcher returned a new source. process it
12044 _fetchEventSource(res, callback);
12045 return;
12046 }
12047 }
12048
12049 var events = source.events;
12050 if (events) {
12051 if ($.isFunction(events)) {
12052 t.pushLoading();
12053 events.call(
12054 t, // this, the Calendar object
12055 rangeStart.clone(),
12056 rangeEnd.clone(),
12057 t.options.timezone,
12058 function(events) {
12059 callback(events);
12060 t.popLoading();
12061 }
12062 );
12063 }
12064 else if ($.isArray(events)) {
12065 callback(events);
12066 }
12067 else {
12068 callback();
12069 }
12070 }else{
12071 var url = source.url;
12072 if (url) {
12073 var success = source.success;
12074 var error = source.error;
12075 var complete = source.complete;
12076
12077 // retrieve any outbound GET/POST $.ajax data from the options
12078 var customData;
12079 if ($.isFunction(source.data)) {
12080 // supplied as a function that returns a key/value object
12081 customData = source.data();
12082 }
12083 else {
12084 // supplied as a straight key/value object
12085 customData = source.data;
12086 }
12087
12088 // use a copy of the custom data so we can modify the parameters
12089 // and not affect the passed-in object.
12090 var data = $.extend({}, customData || {});
12091
12092 var startParam = firstDefined(source.startParam, t.options.startParam);
12093 var endParam = firstDefined(source.endParam, t.options.endParam);
12094 var timezoneParam = firstDefined(source.timezoneParam, t.options.timezoneParam);
12095
12096 if (startParam) {
12097 data[startParam] = rangeStart.format();
12098 }
12099 if (endParam) {
12100 data[endParam] = rangeEnd.format();
12101 }
12102 if (t.options.timezone && t.options.timezone != 'local') {
12103 data[timezoneParam] = t.options.timezone;
12104 }
12105
12106 t.pushLoading();
12107 $.ajax($.extend({}, ajaxDefaults, source, {
12108 data: data,
12109 success: function(events) {
12110 events = events || [];
12111 var res = applyAll(success, this, arguments);
12112 if ($.isArray(res)) {
12113 events = res;
12114 }
12115 callback(events);
12116 },
12117 error: function() {
12118 applyAll(error, this, arguments);
12119 callback();
12120 },
12121 complete: function() {
12122 applyAll(complete, this, arguments);
12123 t.popLoading();
12124 }
12125 }));
12126 }else{
12127 callback();
12128 }
12129 }
12130 }
12131
12132
12133
12134 /* Sources
12135 -----------------------------------------------------------------------------*/
12136
12137
12138 function addEventSource(sourceInput) {
12139 var source = buildEventSource(sourceInput);
12140 if (source) {
12141 sources.push(source);
12142 fetchEventSources([ source ], 'add'); // will eventually call reportEventChange
12143 }
12144 }
12145
12146
12147 function buildEventSource(sourceInput) { // will return undefined if invalid source
12148 var normalizers = FC.sourceNormalizers;
12149 var source;
12150 var i;
12151
12152 if ($.isFunction(sourceInput) || $.isArray(sourceInput)) {
12153 source = { events: sourceInput };
12154 }
12155 else if (typeof sourceInput === 'string') {
12156 source = { url: sourceInput };
12157 }
12158 else if (typeof sourceInput === 'object') {
12159 source = $.extend({}, sourceInput); // shallow copy
12160 }
12161
12162 if (source) {
12163
12164 // TODO: repeat code, same code for event classNames
12165 if (source.className) {
12166 if (typeof source.className === 'string') {
12167 source.className = source.className.split(/\s+/);
12168 }
12169 // otherwise, assumed to be an array
12170 }
12171 else {
12172 source.className = [];
12173 }
12174
12175 // for array sources, we convert to standard Event Objects up front
12176 if ($.isArray(source.events)) {
12177 source.origArray = source.events; // for removeEventSource
12178 source.events = $.map(source.events, function(eventInput) {
12179 return buildEventFromInput(eventInput, source);
12180 });
12181 }
12182
12183 for (i=0; i<normalizers.length; i++) {
12184 normalizers[i].call(t, source);
12185 }
12186
12187 return source;
12188 }
12189 }
12190
12191
12192 function removeEventSource(matchInput) {
12193 removeSpecificEventSources(
12194 getEventSourcesByMatch(matchInput)
12195 );
12196 }
12197
12198
12199 // if called with no arguments, removes all.
12200 function removeEventSources(matchInputs) {
12201 if (matchInputs == null) {
12202 removeSpecificEventSources(sources, true); // isAll=true
12203 }
12204 else {
12205 removeSpecificEventSources(
12206 getEventSourcesByMatchArray(matchInputs)
12207 );
12208 }
12209 }
12210
12211
12212 function removeSpecificEventSources(targetSources, isAll) {
12213 var i;
12214
12215 // cancel pending requests
12216 for (i = 0; i < targetSources.length; i++) {
12217 rejectEventSource(targetSources[i]);
12218 }
12219
12220 if (isAll) { // an optimization
12221 sources = [];
12222 cache = [];
12223 }
12224 else {
12225 // remove from persisted source list
12226 sources = $.grep(sources, function(source) {
12227 for (i = 0; i < targetSources.length; i++) {
12228 if (source === targetSources[i]) {
12229 return false; // exclude
12230 }
12231 }
12232 return true; // include
12233 });
12234
12235 cache = excludeEventsBySources(cache, targetSources);
12236 }
12237
12238 reportEventChange();
12239 }
12240
12241
12242 function getEventSources() {
12243 return sources.slice(1); // returns a shallow copy of sources with stickySource removed
12244 }
12245
12246
12247 function getEventSourceById(id) {
12248 return $.grep(sources, function(source) {
12249 return source.id && source.id === id;
12250 })[0];
12251 }
12252
12253
12254 // like getEventSourcesByMatch, but accepts multple match criteria (like multiple IDs)
12255 function getEventSourcesByMatchArray(matchInputs) {
12256
12257 // coerce into an array
12258 if (!matchInputs) {
12259 matchInputs = [];
12260 }
12261 else if (!$.isArray(matchInputs)) {
12262 matchInputs = [ matchInputs ];
12263 }
12264
12265 var matchingSources = [];
12266 var i;
12267
12268 // resolve raw inputs to real event source objects
12269 for (i = 0; i < matchInputs.length; i++) {
12270 matchingSources.push.apply( // append
12271 matchingSources,
12272 getEventSourcesByMatch(matchInputs[i])
12273 );
12274 }
12275
12276 return matchingSources;
12277 }
12278
12279
12280 // matchInput can either by a real event source object, an ID, or the function/URL for the source.
12281 // returns an array of matching source objects.
12282 function getEventSourcesByMatch(matchInput) {
12283 var i, source;
12284
12285 // given an proper event source object
12286 for (i = 0; i < sources.length; i++) {
12287 source = sources[i];
12288 if (source === matchInput) {
12289 return [ source ];
12290 }
12291 }
12292
12293 // an ID match
12294 source = getEventSourceById(matchInput);
12295 if (source) {
12296 return [ source ];
12297 }
12298
12299 return $.grep(sources, function(source) {
12300 return isSourcesEquivalent(matchInput, source);
12301 });
12302 }
12303
12304
12305 function isSourcesEquivalent(source1, source2) {
12306 return source1 && source2 && getSourcePrimitive(source1) == getSourcePrimitive(source2);
12307 }
12308
12309
12310 function getSourcePrimitive(source) {
12311 return (
12312 (typeof source === 'object') ? // a normalized event source?
12313 (source.origArray || source.googleCalendarId || source.url || source.events) : // get the primitive
12314 null
12315 ) ||
12316 source; // the given argument *is* the primitive
12317 }
12318
12319
12320 // util
12321 // returns a filtered array without events that are part of any of the given sources
12322 function excludeEventsBySources(specificEvents, specificSources) {
12323 return $.grep(specificEvents, function(event) {
12324 for (var i = 0; i < specificSources.length; i++) {
12325 if (event.source === specificSources[i]) {
12326 return false; // exclude
12327 }
12328 }
12329 return true; // keep
12330 });
12331 }
12332
12333
12334
12335 /* Manipulation
12336 -----------------------------------------------------------------------------*/
12337
12338
12339 // Only ever called from the externally-facing API
12340 function updateEvent(event) {
12341 updateEvents([ event ]);
12342 }
12343
12344
12345 // Only ever called from the externally-facing API
12346 function updateEvents(events) {
12347 var i, event;
12348
12349 for (i = 0; i < events.length; i++) {
12350 event = events[i];
12351
12352 // massage start/end values, even if date string values
12353 event.start = t.moment(event.start);
12354 if (event.end) {
12355 event.end = t.moment(event.end);
12356 }
12357 else {
12358 event.end = null;
12359 }
12360
12361 mutateEvent(event, getMiscEventProps(event)); // will handle start/end/allDay normalization
12362 }
12363
12364 reportEventChange(); // reports event modifications (so we can redraw)
12365 }
12366
12367
12368 // Returns a hash of misc event properties that should be copied over to related events.
12369 function getMiscEventProps(event) {
12370 var props = {};
12371
12372 $.each(event, function(name, val) {
12373 if (isMiscEventPropName(name)) {
12374 if (val !== undefined && isAtomic(val)) { // a defined non-object
12375 props[name] = val;
12376 }
12377 }
12378 });
12379
12380 return props;
12381 }
12382
12383 // non-date-related, non-id-related, non-secret
12384 function isMiscEventPropName(name) {
12385 return !/^_|^(id|allDay|start|end)$/.test(name);
12386 }
12387
12388
12389 // returns the expanded events that were created
12390 function renderEvent(eventInput, stick) {
12391 return renderEvents([ eventInput ], stick);
12392 }
12393
12394
12395 // returns the expanded events that were created
12396 function renderEvents(eventInputs, stick) {
12397 var renderedEvents = [];
12398 var renderableEvents;
12399 var abstractEvent;
12400 var i, j, event;
12401
12402 for (i = 0; i < eventInputs.length; i++) {
12403 abstractEvent = buildEventFromInput(eventInputs[i]);
12404
12405 if (abstractEvent) { // not false (a valid input)
12406 renderableEvents = expandEvent(abstractEvent);
12407
12408 for (j = 0; j < renderableEvents.length; j++) {
12409 event = renderableEvents[j];
12410
12411 if (!event.source) {
12412 if (stick) {
12413 stickySource.events.push(event);
12414 event.source = stickySource;
12415 }
12416 cache.push(event);
12417 }
12418 }
12419
12420 renderedEvents = renderedEvents.concat(renderableEvents);
12421 }
12422 }
12423
12424 if (renderedEvents.length) { // any new events rendered?
12425 reportEventChange();
12426 }
12427
12428 return renderedEvents;
12429 }
12430
12431
12432 function removeEvents(filter) {
12433 var eventID;
12434 var i;
12435
12436 if (filter == null) { // null or undefined. remove all events
12437 filter = function() { return true; }; // will always match
12438 }
12439 else if (!$.isFunction(filter)) { // an event ID
12440 eventID = filter + '';
12441 filter = function(event) {
12442 return event._id == eventID;
12443 };
12444 }
12445
12446 // Purge event(s) from our local cache
12447 cache = $.grep(cache, filter, true); // inverse=true
12448
12449 // Remove events from array sources.
12450 // This works because they have been converted to official Event Objects up front.
12451 // (and as a result, event._id has been calculated).
12452 for (i=0; i<sources.length; i++) {
12453 if ($.isArray(sources[i].events)) {
12454 sources[i].events = $.grep(sources[i].events, filter, true);
12455 }
12456 }
12457
12458 reportEventChange();
12459 }
12460
12461
12462 function clientEvents(filter) {
12463 if ($.isFunction(filter)) {
12464 return $.grep(cache, filter);
12465 }
12466 else if (filter != null) { // not null, not undefined. an event ID
12467 filter += '';
12468 return $.grep(cache, function(e) {
12469 return e._id == filter;
12470 });
12471 }
12472 return cache; // else, return all
12473 }
12474
12475
12476 // Makes sure all array event sources have their internal event objects
12477 // converted over to the Calendar's current timezone.
12478 t.rezoneArrayEventSources = function() {
12479 var i;
12480 var events;
12481 var j;
12482
12483 for (i = 0; i < sources.length; i++) {
12484 events = sources[i].events;
12485 if ($.isArray(events)) {
12486
12487 for (j = 0; j < events.length; j++) {
12488 rezoneEventDates(events[j]);
12489 }
12490 }
12491 }
12492 };
12493
12494 function rezoneEventDates(event) {
12495 event.start = t.moment(event.start);
12496 if (event.end) {
12497 event.end = t.moment(event.end);
12498 }
12499 backupEventDates(event);
12500 }
12501
12502
12503 /* Event Normalization
12504 -----------------------------------------------------------------------------*/
12505
12506
12507 // Given a raw object with key/value properties, returns an "abstract" Event object.
12508 // An "abstract" event is an event that, if recurring, will not have been expanded yet.
12509 // Will return `false` when input is invalid.
12510 // `source` is optional
12511 function buildEventFromInput(input, source) {
12512 var out = {};
12513 var start, end;
12514 var allDay;
12515
12516 if (t.options.eventDataTransform) {
12517 input = t.options.eventDataTransform(input);
12518 }
12519 if (source && source.eventDataTransform) {
12520 input = source.eventDataTransform(input);
12521 }
12522
12523 // Copy all properties over to the resulting object.
12524 // The special-case properties will be copied over afterwards.
12525 $.extend(out, input);
12526
12527 if (source) {
12528 out.source = source;
12529 }
12530
12531 out._id = input._id || (input.id === undefined ? '_fc' + eventGUID++ : input.id + '');
12532
12533 if (input.className) {
12534 if (typeof input.className == 'string') {
12535 out.className = input.className.split(/\s+/);
12536 }
12537 else { // assumed to be an array
12538 out.className = input.className;
12539 }
12540 }
12541 else {
12542 out.className = [];
12543 }
12544
12545 start = input.start || input.date; // "date" is an alias for "start"
12546 end = input.end;
12547
12548 // parse as a time (Duration) if applicable
12549 if (isTimeString(start)) {
12550 start = moment.duration(start);
12551 }
12552 if (isTimeString(end)) {
12553 end = moment.duration(end);
12554 }
12555
12556 if (input.dow || moment.isDuration(start) || moment.isDuration(end)) {
12557
12558 // the event is "abstract" (recurring) so don't calculate exact start/end dates just yet
12559 out.start = start ? moment.duration(start) : null; // will be a Duration or null
12560 out.end = end ? moment.duration(end) : null; // will be a Duration or null
12561 out._recurring = true; // our internal marker
12562 }
12563 else {
12564
12565 if (start) {
12566 start = t.moment(start);
12567 if (!start.isValid()) {
12568 return false;
12569 }
12570 }
12571
12572 if (end) {
12573 end = t.moment(end);
12574 if (!end.isValid()) {
12575 end = null; // let defaults take over
12576 }
12577 }
12578
12579 allDay = input.allDay;
12580 if (allDay === undefined) { // still undefined? fallback to default
12581 allDay = firstDefined(
12582 source ? source.allDayDefault : undefined,
12583 t.options.allDayDefault
12584 );
12585 // still undefined? normalizeEventDates will calculate it
12586 }
12587
12588 assignDatesToEvent(start, end, allDay, out);
12589 }
12590
12591 t.normalizeEvent(out); // hook for external use. a prototype method
12592
12593 return out;
12594 }
12595 t.buildEventFromInput = buildEventFromInput;
12596
12597
12598 // Normalizes and assigns the given dates to the given partially-formed event object.
12599 // NOTE: mutates the given start/end moments. does not make a copy.
12600 function assignDatesToEvent(start, end, allDay, event) {
12601 event.start = start;
12602 event.end = end;
12603 event.allDay = allDay;
12604 normalizeEventDates(event);
12605 backupEventDates(event);
12606 }
12607
12608
12609 // Ensures proper values for allDay/start/end. Accepts an Event object, or a plain object with event-ish properties.
12610 // NOTE: Will modify the given object.
12611 function normalizeEventDates(eventProps) {
12612
12613 normalizeEventTimes(eventProps);
12614
12615 if (eventProps.end && !eventProps.end.isAfter(eventProps.start)) {
12616 eventProps.end = null;
12617 }
12618
12619 if (!eventProps.end) {
12620 if (t.options.forceEventDuration) {
12621 eventProps.end = t.getDefaultEventEnd(eventProps.allDay, eventProps.start);
12622 }
12623 else {
12624 eventProps.end = null;
12625 }
12626 }
12627 }
12628
12629
12630 // Ensures the allDay property exists and the timeliness of the start/end dates are consistent
12631 function normalizeEventTimes(eventProps) {
12632 if (eventProps.allDay == null) {
12633 eventProps.allDay = !(eventProps.start.hasTime() || (eventProps.end && eventProps.end.hasTime()));
12634 }
12635
12636 if (eventProps.allDay) {
12637 eventProps.start.stripTime();
12638 if (eventProps.end) {
12639 // TODO: consider nextDayThreshold here? If so, will require a lot of testing and adjustment
12640 eventProps.end.stripTime();
12641 }
12642 }
12643 else {
12644 if (!eventProps.start.hasTime()) {
12645 eventProps.start = t.applyTimezone(eventProps.start.time(0)); // will assign a 00:00 time
12646 }
12647 if (eventProps.end && !eventProps.end.hasTime()) {
12648 eventProps.end = t.applyTimezone(eventProps.end.time(0)); // will assign a 00:00 time
12649 }
12650 }
12651 }
12652
12653
12654 // If the given event is a recurring event, break it down into an array of individual instances.
12655 // If not a recurring event, return an array with the single original event.
12656 // If given a falsy input (probably because of a failed buildEventFromInput call), returns an empty array.
12657 // HACK: can override the recurring window by providing custom rangeStart/rangeEnd (for businessHours).
12658 function expandEvent(abstractEvent, _rangeStart, _rangeEnd) {
12659 var events = [];
12660 var dowHash;
12661 var dow;
12662 var i;
12663 var date;
12664 var startTime, endTime;
12665 var start, end;
12666 var event;
12667
12668 _rangeStart = _rangeStart || rangeStart;
12669 _rangeEnd = _rangeEnd || rangeEnd;
12670
12671 if (abstractEvent) {
12672 if (abstractEvent._recurring) {
12673
12674 // make a boolean hash as to whether the event occurs on each day-of-week
12675 if ((dow = abstractEvent.dow)) {
12676 dowHash = {};
12677 for (i = 0; i < dow.length; i++) {
12678 dowHash[dow[i]] = true;
12679 }
12680 }
12681
12682 // iterate through every day in the current range
12683 date = _rangeStart.clone().stripTime(); // holds the date of the current day
12684 while (date.isBefore(_rangeEnd)) {
12685
12686 if (!dowHash || dowHash[date.day()]) { // if everyday, or this particular day-of-week
12687
12688 startTime = abstractEvent.start; // the stored start and end properties are times (Durations)
12689 endTime = abstractEvent.end; // "
12690 start = date.clone();
12691 end = null;
12692
12693 if (startTime) {
12694 start = start.time(startTime);
12695 }
12696 if (endTime) {
12697 end = date.clone().time(endTime);
12698 }
12699
12700 event = $.extend({}, abstractEvent); // make a copy of the original
12701 assignDatesToEvent(
12702 start, end,
12703 !startTime && !endTime, // allDay?
12704 event
12705 );
12706 events.push(event);
12707 }
12708
12709 date.add(1, 'days');
12710 }
12711 }
12712 else {
12713 events.push(abstractEvent); // return the original event. will be a one-item array
12714 }
12715 }
12716
12717 return events;
12718 }
12719 t.expandEvent = expandEvent;
12720
12721
12722
12723 /* Event Modification Math
12724 -----------------------------------------------------------------------------------------*/
12725
12726
12727 // Modifies an event and all related events by applying the given properties.
12728 // Special date-diffing logic is used for manipulation of dates.
12729 // If `props` does not contain start/end dates, the updated values are assumed to be the event's current start/end.
12730 // All date comparisons are done against the event's pristine _start and _end dates.
12731 // Returns an object with delta information and a function to undo all operations.
12732 // For making computations in a granularity greater than day/time, specify largeUnit.
12733 // NOTE: The given `newProps` might be mutated for normalization purposes.
12734 function mutateEvent(event, newProps, largeUnit) {
12735 var miscProps = {};
12736 var oldProps;
12737 var clearEnd;
12738 var startDelta;
12739 var endDelta;
12740 var durationDelta;
12741 var undoFunc;
12742
12743 // diffs the dates in the appropriate way, returning a duration
12744 function diffDates(date1, date0) { // date1 - date0
12745 if (largeUnit) {
12746 return diffByUnit(date1, date0, largeUnit);
12747 }
12748 else if (newProps.allDay) {
12749 return diffDay(date1, date0);
12750 }
12751 else {
12752 return diffDayTime(date1, date0);
12753 }
12754 }
12755
12756 newProps = newProps || {};
12757
12758 // normalize new date-related properties
12759 if (!newProps.start) {
12760 newProps.start = event.start.clone();
12761 }
12762 if (newProps.end === undefined) {
12763 newProps.end = event.end ? event.end.clone() : null;
12764 }
12765 if (newProps.allDay == null) { // is null or undefined?
12766 newProps.allDay = event.allDay;
12767 }
12768 normalizeEventDates(newProps);
12769
12770 // create normalized versions of the original props to compare against
12771 // need a real end value, for diffing
12772 oldProps = {
12773 start: event._start.clone(),
12774 end: event._end ? event._end.clone() : t.getDefaultEventEnd(event._allDay, event._start),
12775 allDay: newProps.allDay // normalize the dates in the same regard as the new properties
12776 };
12777 normalizeEventDates(oldProps);
12778
12779 // need to clear the end date if explicitly changed to null
12780 clearEnd = event._end !== null && newProps.end === null;
12781
12782 // compute the delta for moving the start date
12783 startDelta = diffDates(newProps.start, oldProps.start);
12784
12785 // compute the delta for moving the end date
12786 if (newProps.end) {
12787 endDelta = diffDates(newProps.end, oldProps.end);
12788 durationDelta = endDelta.subtract(startDelta);
12789 }
12790 else {
12791 durationDelta = null;
12792 }
12793
12794 // gather all non-date-related properties
12795 $.each(newProps, function(name, val) {
12796 if (isMiscEventPropName(name)) {
12797 if (val !== undefined) {
12798 miscProps[name] = val;
12799 }
12800 }
12801 });
12802
12803 // apply the operations to the event and all related events
12804 undoFunc = mutateEvents(
12805 clientEvents(event._id), // get events with this ID
12806 clearEnd,
12807 newProps.allDay,
12808 startDelta,
12809 durationDelta,
12810 miscProps
12811 );
12812
12813 return {
12814 dateDelta: startDelta,
12815 durationDelta: durationDelta,
12816 undo: undoFunc
12817 };
12818 }
12819
12820
12821 // Modifies an array of events in the following ways (operations are in order):
12822 // - clear the event's `end`
12823 // - convert the event to allDay
12824 // - add `dateDelta` to the start and end
12825 // - add `durationDelta` to the event's duration
12826 // - assign `miscProps` to the event
12827 //
12828 // Returns a function that can be called to undo all the operations.
12829 //
12830 // TODO: don't use so many closures. possible memory issues when lots of events with same ID.
12831 //
12832 function mutateEvents(events, clearEnd, allDay, dateDelta, durationDelta, miscProps) {
12833 var isAmbigTimezone = t.getIsAmbigTimezone();
12834 var undoFunctions = [];
12835
12836 // normalize zero-length deltas to be null
12837 if (dateDelta && !dateDelta.valueOf()) { dateDelta = null; }
12838 if (durationDelta && !durationDelta.valueOf()) { durationDelta = null; }
12839
12840 $.each(events, function(i, event) {
12841 var oldProps;
12842 var newProps;
12843
12844 // build an object holding all the old values, both date-related and misc.
12845 // for the undo function.
12846 oldProps = {
12847 start: event.start.clone(),
12848 end: event.end ? event.end.clone() : null,
12849 allDay: event.allDay
12850 };
12851 $.each(miscProps, function(name) {
12852 oldProps[name] = event[name];
12853 });
12854
12855 // new date-related properties. work off the original date snapshot.
12856 // ok to use references because they will be thrown away when backupEventDates is called.
12857 newProps = {
12858 start: event._start,
12859 end: event._end,
12860 allDay: allDay // normalize the dates in the same regard as the new properties
12861 };
12862 normalizeEventDates(newProps); // massages start/end/allDay
12863
12864 // strip or ensure the end date
12865 if (clearEnd) {
12866 newProps.end = null;
12867 }
12868 else if (durationDelta && !newProps.end) { // the duration translation requires an end date
12869 newProps.end = t.getDefaultEventEnd(newProps.allDay, newProps.start);
12870 }
12871
12872 if (dateDelta) {
12873 newProps.start.add(dateDelta);
12874 if (newProps.end) {
12875 newProps.end.add(dateDelta);
12876 }
12877 }
12878
12879 if (durationDelta) {
12880 newProps.end.add(durationDelta); // end already ensured above
12881 }
12882
12883 // if the dates have changed, and we know it is impossible to recompute the
12884 // timezone offsets, strip the zone.
12885 if (
12886 isAmbigTimezone &&
12887 !newProps.allDay &&
12888 (dateDelta || durationDelta)
12889 ) {
12890 newProps.start.stripZone();
12891 if (newProps.end) {
12892 newProps.end.stripZone();
12893 }
12894 }
12895
12896 $.extend(event, miscProps, newProps); // copy over misc props, then date-related props
12897 backupEventDates(event); // regenerate internal _start/_end/_allDay
12898
12899 undoFunctions.push(function() {
12900 $.extend(event, oldProps);
12901 backupEventDates(event); // regenerate internal _start/_end/_allDay
12902 });
12903 });
12904
12905 return function() {
12906 for (var i = 0; i < undoFunctions.length; i++) {
12907 undoFunctions[i]();
12908 }
12909 };
12910 }
12911
12912 }
12913
12914
12915 // returns an undo function
12916 Calendar.prototype.mutateSeg = function(seg, newProps) {
12917 return this.mutateEvent(seg.event, newProps);
12918 };
12919
12920
12921 // hook for external libs to manipulate event properties upon creation.
12922 // should manipulate the event in-place.
12923 Calendar.prototype.normalizeEvent = function(event) {
12924 };
12925
12926
12927 // Does the given span (start, end, and other location information)
12928 // fully contain the other?
12929 Calendar.prototype.spanContainsSpan = function(outerSpan, innerSpan) {
12930 var eventStart = outerSpan.start.clone().stripZone();
12931 var eventEnd = this.getEventEnd(outerSpan).stripZone();
12932
12933 return innerSpan.start >= eventStart && innerSpan.end <= eventEnd;
12934 };
12935
12936
12937 // Returns a list of events that the given event should be compared against when being considered for a move to
12938 // the specified span. Attached to the Calendar's prototype because EventManager is a mixin for a Calendar.
12939 Calendar.prototype.getPeerEvents = function(span, event) {
12940 var cache = this.getEventCache();
12941 var peerEvents = [];
12942 var i, otherEvent;
12943
12944 for (i = 0; i < cache.length; i++) {
12945 otherEvent = cache[i];
12946 if (
12947 !event ||
12948 event._id !== otherEvent._id // don't compare the event to itself or other related [repeating] events
12949 ) {
12950 peerEvents.push(otherEvent);
12951 }
12952 }
12953
12954 return peerEvents;
12955 };
12956
12957
12958 // updates the "backup" properties, which are preserved in order to compute diffs later on.
12959 function backupEventDates(event) {
12960 event._allDay = event.allDay;
12961 event._start = event.start.clone();
12962 event._end = event.end ? event.end.clone() : null;
12963 }
12964
12965
12966 /* Overlapping / Constraining
12967 -----------------------------------------------------------------------------------------*/
12968
12969
12970 // Determines if the given event can be relocated to the given span (unzoned start/end with other misc data)
12971 Calendar.prototype.isEventSpanAllowed = function(span, event) {
12972 var source = event.source || {};
12973
12974 var constraint = firstDefined(
12975 event.constraint,
12976 source.constraint,
12977 this.options.eventConstraint
12978 );
12979
12980 var overlap = firstDefined(
12981 event.overlap,
12982 source.overlap,
12983 this.options.eventOverlap
12984 );
12985
12986 return this.isSpanAllowed(span, constraint, overlap, event) &&
12987 (!this.options.eventAllow || this.options.eventAllow(span, event) !== false);
12988 };
12989
12990
12991 // Determines if an external event can be relocated to the given span (unzoned start/end with other misc data)
12992 Calendar.prototype.isExternalSpanAllowed = function(eventSpan, eventLocation, eventProps) {
12993 var eventInput;
12994 var event;
12995
12996 // note: very similar logic is in View's reportExternalDrop
12997 if (eventProps) {
12998 eventInput = $.extend({}, eventProps, eventLocation);
12999 event = this.expandEvent(
13000 this.buildEventFromInput(eventInput)
13001 )[0];
13002 }
13003
13004 if (event) {
13005 return this.isEventSpanAllowed(eventSpan, event);
13006 }
13007 else { // treat it as a selection
13008
13009 return this.isSelectionSpanAllowed(eventSpan);
13010 }
13011 };
13012
13013
13014 // Determines the given span (unzoned start/end with other misc data) can be selected.
13015 Calendar.prototype.isSelectionSpanAllowed = function(span) {
13016 return this.isSpanAllowed(span, this.options.selectConstraint, this.options.selectOverlap) &&
13017 (!this.options.selectAllow || this.options.selectAllow(span) !== false);
13018 };
13019
13020
13021 // Returns true if the given span (caused by an event drop/resize or a selection) is allowed to exist
13022 // according to the constraint/overlap settings.
13023 // `event` is not required if checking a selection.
13024 Calendar.prototype.isSpanAllowed = function(span, constraint, overlap, event) {
13025 var constraintEvents;
13026 var anyContainment;
13027 var peerEvents;
13028 var i, peerEvent;
13029 var peerOverlap;
13030
13031 // the range must be fully contained by at least one of produced constraint events
13032 if (constraint != null) {
13033
13034 // not treated as an event! intermediate data structure
13035 // TODO: use ranges in the future
13036 constraintEvents = this.constraintToEvents(constraint);
13037 if (constraintEvents) { // not invalid
13038
13039 anyContainment = false;
13040 for (i = 0; i < constraintEvents.length; i++) {
13041 if (this.spanContainsSpan(constraintEvents[i], span)) {
13042 anyContainment = true;
13043 break;
13044 }
13045 }
13046
13047 if (!anyContainment) {
13048 return false;
13049 }
13050 }
13051 }
13052
13053 peerEvents = this.getPeerEvents(span, event);
13054
13055 for (i = 0; i < peerEvents.length; i++) {
13056 peerEvent = peerEvents[i];
13057
13058 // there needs to be an actual intersection before disallowing anything
13059 if (this.eventIntersectsRange(peerEvent, span)) {
13060
13061 // evaluate overlap for the given range and short-circuit if necessary
13062 if (overlap === false) {
13063 return false;
13064 }
13065 // if the event's overlap is a test function, pass the peer event in question as the first param
13066 else if (typeof overlap === 'function' && !overlap(peerEvent, event)) {
13067 return false;
13068 }
13069
13070 // if we are computing if the given range is allowable for an event, consider the other event's
13071 // EventObject-specific or Source-specific `overlap` property
13072 if (event) {
13073 peerOverlap = firstDefined(
13074 peerEvent.overlap,
13075 (peerEvent.source || {}).overlap
13076 // we already considered the global `eventOverlap`
13077 );
13078 if (peerOverlap === false) {
13079 return false;
13080 }
13081 // if the peer event's overlap is a test function, pass the subject event as the first param
13082 if (typeof peerOverlap === 'function' && !peerOverlap(event, peerEvent)) {
13083 return false;
13084 }
13085 }
13086 }
13087 }
13088
13089 return true;
13090 };
13091
13092
13093 // Given an event input from the API, produces an array of event objects. Possible event inputs:
13094 // 'businessHours'
13095 // An event ID (number or string)
13096 // An object with specific start/end dates or a recurring event (like what businessHours accepts)
13097 Calendar.prototype.constraintToEvents = function(constraintInput) {
13098
13099 if (constraintInput === 'businessHours') {
13100 return this.getCurrentBusinessHourEvents();
13101 }
13102
13103 if (typeof constraintInput === 'object') {
13104 if (constraintInput.start != null) { // needs to be event-like input
13105 return this.expandEvent(this.buildEventFromInput(constraintInput));
13106 }
13107 else {
13108 return null; // invalid
13109 }
13110 }
13111
13112 return this.clientEvents(constraintInput); // probably an ID
13113 };
13114
13115
13116 // Does the event's date range intersect with the given range?
13117 // start/end already assumed to have stripped zones :(
13118 Calendar.prototype.eventIntersectsRange = function(event, range) {
13119 var eventStart = event.start.clone().stripZone();
13120 var eventEnd = this.getEventEnd(event).stripZone();
13121
13122 return range.start < eventEnd && range.end > eventStart;
13123 };
13124
13125
13126 /* Business Hours
13127 -----------------------------------------------------------------------------------------*/
13128
13129 var BUSINESS_HOUR_EVENT_DEFAULTS = {
13130 id: '_fcBusinessHours', // will relate events from different calls to expandEvent
13131 start: '09:00',
13132 end: '17:00',
13133 dow: [ 1, 2, 3, 4, 5 ], // monday - friday
13134 rendering: 'inverse-background'
13135 // classNames are defined in businessHoursSegClasses
13136 };
13137
13138 // Return events objects for business hours within the current view.
13139 // Abuse of our event system :(
13140 Calendar.prototype.getCurrentBusinessHourEvents = function(wholeDay) {
13141 return this.computeBusinessHourEvents(wholeDay, this.options.businessHours);
13142 };
13143
13144 // Given a raw input value from options, return events objects for business hours within the current view.
13145 Calendar.prototype.computeBusinessHourEvents = function(wholeDay, input) {
13146 if (input === true) {
13147 return this.expandBusinessHourEvents(wholeDay, [ {} ]);
13148 }
13149 else if ($.isPlainObject(input)) {
13150 return this.expandBusinessHourEvents(wholeDay, [ input ]);
13151 }
13152 else if ($.isArray(input)) {
13153 return this.expandBusinessHourEvents(wholeDay, input, true);
13154 }
13155 else {
13156 return [];
13157 }
13158 };
13159
13160 // inputs expected to be an array of objects.
13161 // if ignoreNoDow is true, will ignore entries that don't specify a day-of-week (dow) key.
13162 Calendar.prototype.expandBusinessHourEvents = function(wholeDay, inputs, ignoreNoDow) {
13163 var view = this.getView();
13164 var events = [];
13165 var i, input;
13166
13167 for (i = 0; i < inputs.length; i++) {
13168 input = inputs[i];
13169
13170 if (ignoreNoDow && !input.dow) {
13171 continue;
13172 }
13173
13174 // give defaults. will make a copy
13175 input = $.extend({}, BUSINESS_HOUR_EVENT_DEFAULTS, input);
13176
13177 // if a whole-day series is requested, clear the start/end times
13178 if (wholeDay) {
13179 input.start = null;
13180 input.end = null;
13181 }
13182
13183 events.push.apply(events, // append
13184 this.expandEvent(
13185 this.buildEventFromInput(input),
13186 view.start,
13187 view.end
13188 )
13189 );
13190 }
13191
13192 return events;
13193 };
13194
13195 ;;
13196
13197 /* An abstract class for the "basic" views, as well as month view. Renders one or more rows of day cells.
13198 ----------------------------------------------------------------------------------------------------------------------*/
13199 // It is a manager for a DayGrid subcomponent, which does most of the heavy lifting.
13200 // It is responsible for managing width/height.
13201
13202 var BasicView = FC.BasicView = View.extend({
13203
13204 scroller: null,
13205
13206 dayGridClass: DayGrid, // class the dayGrid will be instantiated from (overridable by subclasses)
13207 dayGrid: null, // the main subcomponent that does most of the heavy lifting
13208
13209 dayNumbersVisible: false, // display day numbers on each day cell?
13210 colWeekNumbersVisible: false, // display week numbers along the side?
13211 cellWeekNumbersVisible: false, // display week numbers in day cell?
13212
13213 weekNumberWidth: null, // width of all the week-number cells running down the side
13214
13215 headContainerEl: null, // div that hold's the dayGrid's rendered date header
13216 headRowEl: null, // the fake row element of the day-of-week header
13217
13218
13219 initialize: function() {
13220 this.dayGrid = this.instantiateDayGrid();
13221
13222 this.scroller = new Scroller({
13223 overflowX: 'hidden',
13224 overflowY: 'auto'
13225 });
13226 },
13227
13228
13229 // Generates the DayGrid object this view needs. Draws from this.dayGridClass
13230 instantiateDayGrid: function() {
13231 // generate a subclass on the fly with BasicView-specific behavior
13232 // TODO: cache this subclass
13233 var subclass = this.dayGridClass.extend(basicDayGridMethods);
13234
13235 return new subclass(this);
13236 },
13237
13238
13239 // Sets the display range and computes all necessary dates
13240 setRange: function(range) {
13241 View.prototype.setRange.call(this, range); // call the super-method
13242
13243 this.dayGrid.breakOnWeeks = /year|month|week/.test(this.intervalUnit); // do before setRange
13244 this.dayGrid.setRange(range);
13245 },
13246
13247
13248 // Compute the value to feed into setRange. Overrides superclass.
13249 computeRange: function(date) {
13250 var range = View.prototype.computeRange.call(this, date); // get value from the super-method
13251
13252 // year and month views should be aligned with weeks. this is already done for week
13253 if (/year|month/.test(range.intervalUnit)) {
13254 range.start.startOf('week');
13255 range.start = this.skipHiddenDays(range.start);
13256
13257 // make end-of-week if not already
13258 if (range.end.weekday()) {
13259 range.end.add(1, 'week').startOf('week');
13260 range.end = this.skipHiddenDays(range.end, -1, true); // exclusively move backwards
13261 }
13262 }
13263
13264 return range;
13265 },
13266
13267
13268 // Renders the view into `this.el`, which should already be assigned
13269 renderDates: function() {
13270
13271 this.dayNumbersVisible = this.dayGrid.rowCnt > 1; // TODO: make grid responsible
13272 if (this.opt('weekNumbers')) {
13273 if (this.opt('weekNumbersWithinDays')) {
13274 this.cellWeekNumbersVisible = true;
13275 this.colWeekNumbersVisible = false;
13276 }
13277 else {
13278 this.cellWeekNumbersVisible = false;
13279 this.colWeekNumbersVisible = true;
13280 };
13281 }
13282 this.dayGrid.numbersVisible = this.dayNumbersVisible ||
13283 this.cellWeekNumbersVisible || this.colWeekNumbersVisible;
13284
13285 this.el.addClass('fc-basic-view').html(this.renderSkeletonHtml());
13286 this.renderHead();
13287
13288 this.scroller.render();
13289 var dayGridContainerEl = this.scroller.el.addClass('fc-day-grid-container');
13290 var dayGridEl = $('<div class="fc-day-grid" />').appendTo(dayGridContainerEl);
13291 this.el.find('.fc-body > tr > td').append(dayGridContainerEl);
13292
13293 this.dayGrid.setElement(dayGridEl);
13294 this.dayGrid.renderDates(this.hasRigidRows());
13295 },
13296
13297
13298 // render the day-of-week headers
13299 renderHead: function() {
13300 this.headContainerEl =
13301 this.el.find('.fc-head-container')
13302 .html(this.dayGrid.renderHeadHtml());
13303 this.headRowEl = this.headContainerEl.find('.fc-row');
13304 },
13305
13306
13307 // Unrenders the content of the view. Since we haven't separated skeleton rendering from date rendering,
13308 // always completely kill the dayGrid's rendering.
13309 unrenderDates: function() {
13310 this.dayGrid.unrenderDates();
13311 this.dayGrid.removeElement();
13312 this.scroller.destroy();
13313 },
13314
13315
13316 renderBusinessHours: function() {
13317 this.dayGrid.renderBusinessHours();
13318 },
13319
13320
13321 unrenderBusinessHours: function() {
13322 this.dayGrid.unrenderBusinessHours();
13323 },
13324
13325
13326 // Builds the HTML skeleton for the view.
13327 // The day-grid component will render inside of a container defined by this HTML.
13328 renderSkeletonHtml: function() {
13329 return '' +
13330 '<table>' +
13331 '<thead class="fc-head">' +
13332 '<tr>' +
13333 '<td class="fc-head-container ' + this.widgetHeaderClass + '"></td>' +
13334 '</tr>' +
13335 '</thead>' +
13336 '<tbody class="fc-body">' +
13337 '<tr>' +
13338 '<td class="' + this.widgetContentClass + '"></td>' +
13339 '</tr>' +
13340 '</tbody>' +
13341 '</table>';
13342 },
13343
13344
13345 // Generates an HTML attribute string for setting the width of the week number column, if it is known
13346 weekNumberStyleAttr: function() {
13347 if (this.weekNumberWidth !== null) {
13348 return 'style="width:' + this.weekNumberWidth + 'px"';
13349 }
13350 return '';
13351 },
13352
13353
13354 // Determines whether each row should have a constant height
13355 hasRigidRows: function() {
13356 var eventLimit = this.opt('eventLimit');
13357 return eventLimit && typeof eventLimit !== 'number';
13358 },
13359
13360
13361 /* Dimensions
13362 ------------------------------------------------------------------------------------------------------------------*/
13363
13364
13365 // Refreshes the horizontal dimensions of the view
13366 updateWidth: function() {
13367 if (this.colWeekNumbersVisible) {
13368 // Make sure all week number cells running down the side have the same width.
13369 // Record the width for cells created later.
13370 this.weekNumberWidth = matchCellWidths(
13371 this.el.find('.fc-week-number')
13372 );
13373 }
13374 },
13375
13376
13377 // Adjusts the vertical dimensions of the view to the specified values
13378 setHeight: function(totalHeight, isAuto) {
13379 var eventLimit = this.opt('eventLimit');
13380 var scrollerHeight;
13381 var scrollbarWidths;
13382
13383 // reset all heights to be natural
13384 this.scroller.clear();
13385 uncompensateScroll(this.headRowEl);
13386
13387 this.dayGrid.removeSegPopover(); // kill the "more" popover if displayed
13388
13389 // is the event limit a constant level number?
13390 if (eventLimit && typeof eventLimit === 'number') {
13391 this.dayGrid.limitRows(eventLimit); // limit the levels first so the height can redistribute after
13392 }
13393
13394 // distribute the height to the rows
13395 // (totalHeight is a "recommended" value if isAuto)
13396 scrollerHeight = this.computeScrollerHeight(totalHeight);
13397 this.setGridHeight(scrollerHeight, isAuto);
13398
13399 // is the event limit dynamically calculated?
13400 if (eventLimit && typeof eventLimit !== 'number') {
13401 this.dayGrid.limitRows(eventLimit); // limit the levels after the grid's row heights have been set
13402 }
13403
13404 if (!isAuto) { // should we force dimensions of the scroll container?
13405
13406 this.scroller.setHeight(scrollerHeight);
13407 scrollbarWidths = this.scroller.getScrollbarWidths();
13408
13409 if (scrollbarWidths.left || scrollbarWidths.right) { // using scrollbars?
13410
13411 compensateScroll(this.headRowEl, scrollbarWidths);
13412
13413 // doing the scrollbar compensation might have created text overflow which created more height. redo
13414 scrollerHeight = this.computeScrollerHeight(totalHeight);
13415 this.scroller.setHeight(scrollerHeight);
13416 }
13417
13418 // guarantees the same scrollbar widths
13419 this.scroller.lockOverflow(scrollbarWidths);
13420 }
13421 },
13422
13423
13424 // given a desired total height of the view, returns what the height of the scroller should be
13425 computeScrollerHeight: function(totalHeight) {
13426 return totalHeight -
13427 subtractInnerElHeight(this.el, this.scroller.el); // everything that's NOT the scroller
13428 },
13429
13430
13431 // Sets the height of just the DayGrid component in this view
13432 setGridHeight: function(height, isAuto) {
13433 if (isAuto) {
13434 undistributeHeight(this.dayGrid.rowEls); // let the rows be their natural height with no expanding
13435 }
13436 else {
13437 distributeHeight(this.dayGrid.rowEls, height, true); // true = compensate for height-hogging rows
13438 }
13439 },
13440
13441
13442 /* Scroll
13443 ------------------------------------------------------------------------------------------------------------------*/
13444
13445
13446 computeInitialScroll: function() {
13447 return { top: 0 };
13448 },
13449
13450
13451 queryScroll: function() {
13452 return { top: this.scroller.getScrollTop() };
13453 },
13454
13455
13456 setScroll: function(scroll) {
13457 this.scroller.setScrollTop(scroll.top);
13458 },
13459
13460
13461 /* Hit Areas
13462 ------------------------------------------------------------------------------------------------------------------*/
13463 // forward all hit-related method calls to dayGrid
13464
13465
13466 hitsNeeded: function() {
13467 this.dayGrid.hitsNeeded();
13468 },
13469
13470
13471 hitsNotNeeded: function() {
13472 this.dayGrid.hitsNotNeeded();
13473 },
13474
13475
13476 prepareHits: function() {
13477 this.dayGrid.prepareHits();
13478 },
13479
13480
13481 releaseHits: function() {
13482 this.dayGrid.releaseHits();
13483 },
13484
13485
13486 queryHit: function(left, top) {
13487 return this.dayGrid.queryHit(left, top);
13488 },
13489
13490
13491 getHitSpan: function(hit) {
13492 return this.dayGrid.getHitSpan(hit);
13493 },
13494
13495
13496 getHitEl: function(hit) {
13497 return this.dayGrid.getHitEl(hit);
13498 },
13499
13500
13501 /* Events
13502 ------------------------------------------------------------------------------------------------------------------*/
13503
13504
13505 // Renders the given events onto the view and populates the segments array
13506 renderEvents: function(events) {
13507 this.dayGrid.renderEvents(events);
13508
13509 this.updateHeight(); // must compensate for events that overflow the row
13510 },
13511
13512
13513 // Retrieves all segment objects that are rendered in the view
13514 getEventSegs: function() {
13515 return this.dayGrid.getEventSegs();
13516 },
13517
13518
13519 // Unrenders all event elements and clears internal segment data
13520 unrenderEvents: function() {
13521 this.dayGrid.unrenderEvents();
13522
13523 // we DON'T need to call updateHeight() because
13524 // a renderEvents() call always happens after this, which will eventually call updateHeight()
13525 },
13526
13527
13528 /* Dragging (for both events and external elements)
13529 ------------------------------------------------------------------------------------------------------------------*/
13530
13531
13532 // A returned value of `true` signals that a mock "helper" event has been rendered.
13533 renderDrag: function(dropLocation, seg) {
13534 return this.dayGrid.renderDrag(dropLocation, seg);
13535 },
13536
13537
13538 unrenderDrag: function() {
13539 this.dayGrid.unrenderDrag();
13540 },
13541
13542
13543 /* Selection
13544 ------------------------------------------------------------------------------------------------------------------*/
13545
13546
13547 // Renders a visual indication of a selection
13548 renderSelection: function(span) {
13549 this.dayGrid.renderSelection(span);
13550 },
13551
13552
13553 // Unrenders a visual indications of a selection
13554 unrenderSelection: function() {
13555 this.dayGrid.unrenderSelection();
13556 }
13557
13558 });
13559
13560
13561 // Methods that will customize the rendering behavior of the BasicView's dayGrid
13562 var basicDayGridMethods = {
13563
13564
13565 // Generates the HTML that will go before the day-of week header cells
13566 renderHeadIntroHtml: function() {
13567 var view = this.view;
13568
13569 if (view.colWeekNumbersVisible) {
13570 return '' +
13571 '<th class="fc-week-number ' + view.widgetHeaderClass + '" ' + view.weekNumberStyleAttr() + '>' +
13572 '<span>' + // needed for matchCellWidths
13573 htmlEscape(view.opt('weekNumberTitle')) +
13574 '</span>' +
13575 '</th>';
13576 }
13577
13578 return '';
13579 },
13580
13581
13582 // Generates the HTML that will go before content-skeleton cells that display the day/week numbers
13583 renderNumberIntroHtml: function(row) {
13584 var view = this.view;
13585 var weekStart = this.getCellDate(row, 0);
13586
13587 if (view.colWeekNumbersVisible) {
13588 return '' +
13589 '<td class="fc-week-number" ' + view.weekNumberStyleAttr() + '>' +
13590 view.buildGotoAnchorHtml( // aside from link, important for matchCellWidths
13591 { date: weekStart, type: 'week', forceOff: this.colCnt === 1 },
13592 weekStart.format('w') // inner HTML
13593 ) +
13594 '</td>';
13595 }
13596
13597 return '';
13598 },
13599
13600
13601 // Generates the HTML that goes before the day bg cells for each day-row
13602 renderBgIntroHtml: function() {
13603 var view = this.view;
13604
13605 if (view.colWeekNumbersVisible) {
13606 return '<td class="fc-week-number ' + view.widgetContentClass + '" ' +
13607 view.weekNumberStyleAttr() + '></td>';
13608 }
13609
13610 return '';
13611 },
13612
13613
13614 // Generates the HTML that goes before every other type of row generated by DayGrid.
13615 // Affects helper-skeleton and highlight-skeleton rows.
13616 renderIntroHtml: function() {
13617 var view = this.view;
13618
13619 if (view.colWeekNumbersVisible) {
13620 return '<td class="fc-week-number" ' + view.weekNumberStyleAttr() + '></td>';
13621 }
13622
13623 return '';
13624 }
13625
13626 };
13627
13628 ;;
13629
13630 /* A month view with day cells running in rows (one-per-week) and columns
13631 ----------------------------------------------------------------------------------------------------------------------*/
13632
13633 var MonthView = FC.MonthView = BasicView.extend({
13634
13635 // Produces information about what range to display
13636 computeRange: function(date) {
13637 var range = BasicView.prototype.computeRange.call(this, date); // get value from super-method
13638 var rowCnt;
13639
13640 // ensure 6 weeks
13641 if (this.isFixedWeeks()) {
13642 rowCnt = Math.ceil(range.end.diff(range.start, 'weeks', true)); // could be partial weeks due to hiddenDays
13643 range.end.add(6 - rowCnt, 'weeks');
13644 }
13645
13646 return range;
13647 },
13648
13649
13650 // Overrides the default BasicView behavior to have special multi-week auto-height logic
13651 setGridHeight: function(height, isAuto) {
13652
13653 // if auto, make the height of each row the height that it would be if there were 6 weeks
13654 if (isAuto) {
13655 height *= this.rowCnt / 6;
13656 }
13657
13658 distributeHeight(this.dayGrid.rowEls, height, !isAuto); // if auto, don't compensate for height-hogging rows
13659 },
13660
13661
13662 isFixedWeeks: function() {
13663 return this.opt('fixedWeekCount');
13664 }
13665
13666 });
13667
13668 ;;
13669
13670 fcViews.basic = {
13671 'class': BasicView
13672 };
13673
13674 fcViews.basicDay = {
13675 type: 'basic',
13676 duration: { days: 1 }
13677 };
13678
13679 fcViews.basicWeek = {
13680 type: 'basic',
13681 duration: { weeks: 1 }
13682 };
13683
13684 fcViews.month = {
13685 'class': MonthView,
13686 duration: { months: 1 }, // important for prev/next
13687 defaults: {
13688 fixedWeekCount: true
13689 }
13690 };
13691 ;;
13692
13693 /* An abstract class for all agenda-related views. Displays one more columns with time slots running vertically.
13694 ----------------------------------------------------------------------------------------------------------------------*/
13695 // Is a manager for the TimeGrid subcomponent and possibly the DayGrid subcomponent (if allDaySlot is on).
13696 // Responsible for managing width/height.
13697
13698 var AgendaView = FC.AgendaView = View.extend({
13699
13700 scroller: null,
13701
13702 timeGridClass: TimeGrid, // class used to instantiate the timeGrid. subclasses can override
13703 timeGrid: null, // the main time-grid subcomponent of this view
13704
13705 dayGridClass: DayGrid, // class used to instantiate the dayGrid. subclasses can override
13706 dayGrid: null, // the "all-day" subcomponent. if all-day is turned off, this will be null
13707
13708 axisWidth: null, // the width of the time axis running down the side
13709
13710 headContainerEl: null, // div that hold's the timeGrid's rendered date header
13711 noScrollRowEls: null, // set of fake row elements that must compensate when scroller has scrollbars
13712
13713 // when the time-grid isn't tall enough to occupy the given height, we render an <hr> underneath
13714 bottomRuleEl: null,
13715
13716
13717 initialize: function() {
13718 this.timeGrid = this.instantiateTimeGrid();
13719
13720 if (this.opt('allDaySlot')) { // should we display the "all-day" area?
13721 this.dayGrid = this.instantiateDayGrid(); // the all-day subcomponent of this view
13722 }
13723
13724 this.scroller = new Scroller({
13725 overflowX: 'hidden',
13726 overflowY: 'auto'
13727 });
13728 },
13729
13730
13731 // Instantiates the TimeGrid object this view needs. Draws from this.timeGridClass
13732 instantiateTimeGrid: function() {
13733 var subclass = this.timeGridClass.extend(agendaTimeGridMethods);
13734
13735 return new subclass(this);
13736 },
13737
13738
13739 // Instantiates the DayGrid object this view might need. Draws from this.dayGridClass
13740 instantiateDayGrid: function() {
13741 var subclass = this.dayGridClass.extend(agendaDayGridMethods);
13742
13743 return new subclass(this);
13744 },
13745
13746
13747 /* Rendering
13748 ------------------------------------------------------------------------------------------------------------------*/
13749
13750
13751 // Sets the display range and computes all necessary dates
13752 setRange: function(range) {
13753 View.prototype.setRange.call(this, range); // call the super-method
13754
13755 this.timeGrid.setRange(range);
13756 if (this.dayGrid) {
13757 this.dayGrid.setRange(range);
13758 }
13759 },
13760
13761
13762 // Renders the view into `this.el`, which has already been assigned
13763 renderDates: function() {
13764
13765 this.el.addClass('fc-agenda-view').html(this.renderSkeletonHtml());
13766 this.renderHead();
13767
13768 this.scroller.render();
13769 var timeGridWrapEl = this.scroller.el.addClass('fc-time-grid-container');
13770 var timeGridEl = $('<div class="fc-time-grid" />').appendTo(timeGridWrapEl);
13771 this.el.find('.fc-body > tr > td').append(timeGridWrapEl);
13772
13773 this.timeGrid.setElement(timeGridEl);
13774 this.timeGrid.renderDates();
13775
13776 // the <hr> that sometimes displays under the time-grid
13777 this.bottomRuleEl = $('<hr class="fc-divider ' + this.widgetHeaderClass + '"/>')
13778 .appendTo(this.timeGrid.el); // inject it into the time-grid
13779
13780 if (this.dayGrid) {
13781 this.dayGrid.setElement(this.el.find('.fc-day-grid'));
13782 this.dayGrid.renderDates();
13783
13784 // have the day-grid extend it's coordinate area over the <hr> dividing the two grids
13785 this.dayGrid.bottomCoordPadding = this.dayGrid.el.next('hr').outerHeight();
13786 }
13787
13788 this.noScrollRowEls = this.el.find('.fc-row:not(.fc-scroller *)'); // fake rows not within the scroller
13789 },
13790
13791
13792 // render the day-of-week headers
13793 renderHead: function() {
13794 this.headContainerEl =
13795 this.el.find('.fc-head-container')
13796 .html(this.timeGrid.renderHeadHtml());
13797 },
13798
13799
13800 // Unrenders the content of the view. Since we haven't separated skeleton rendering from date rendering,
13801 // always completely kill each grid's rendering.
13802 unrenderDates: function() {
13803 this.timeGrid.unrenderDates();
13804 this.timeGrid.removeElement();
13805
13806 if (this.dayGrid) {
13807 this.dayGrid.unrenderDates();
13808 this.dayGrid.removeElement();
13809 }
13810
13811 this.scroller.destroy();
13812 },
13813
13814
13815 // Builds the HTML skeleton for the view.
13816 // The day-grid and time-grid components will render inside containers defined by this HTML.
13817 renderSkeletonHtml: function() {
13818 return '' +
13819 '<table>' +
13820 '<thead class="fc-head">' +
13821 '<tr>' +
13822 '<td class="fc-head-container ' + this.widgetHeaderClass + '"></td>' +
13823 '</tr>' +
13824 '</thead>' +
13825 '<tbody class="fc-body">' +
13826 '<tr>' +
13827 '<td class="' + this.widgetContentClass + '">' +
13828 (this.dayGrid ?
13829 '<div class="fc-day-grid"/>' +
13830 '<hr class="fc-divider ' + this.widgetHeaderClass + '"/>' :
13831 ''
13832 ) +
13833 '</td>' +
13834 '</tr>' +
13835 '</tbody>' +
13836 '</table>';
13837 },
13838
13839
13840 // Generates an HTML attribute string for setting the width of the axis, if it is known
13841 axisStyleAttr: function() {
13842 if (this.axisWidth !== null) {
13843 return 'style="width:' + this.axisWidth + 'px"';
13844 }
13845 return '';
13846 },
13847
13848
13849 /* Business Hours
13850 ------------------------------------------------------------------------------------------------------------------*/
13851
13852
13853 renderBusinessHours: function() {
13854 this.timeGrid.renderBusinessHours();
13855
13856 if (this.dayGrid) {
13857 this.dayGrid.renderBusinessHours();
13858 }
13859 },
13860
13861
13862 unrenderBusinessHours: function() {
13863 this.timeGrid.unrenderBusinessHours();
13864
13865 if (this.dayGrid) {
13866 this.dayGrid.unrenderBusinessHours();
13867 }
13868 },
13869
13870
13871 /* Now Indicator
13872 ------------------------------------------------------------------------------------------------------------------*/
13873
13874
13875 getNowIndicatorUnit: function() {
13876 return this.timeGrid.getNowIndicatorUnit();
13877 },
13878
13879
13880 renderNowIndicator: function(date) {
13881 this.timeGrid.renderNowIndicator(date);
13882 },
13883
13884
13885 unrenderNowIndicator: function() {
13886 this.timeGrid.unrenderNowIndicator();
13887 },
13888
13889
13890 /* Dimensions
13891 ------------------------------------------------------------------------------------------------------------------*/
13892
13893
13894 updateSize: function(isResize) {
13895 this.timeGrid.updateSize(isResize);
13896
13897 View.prototype.updateSize.call(this, isResize); // call the super-method
13898 },
13899
13900
13901 // Refreshes the horizontal dimensions of the view
13902 updateWidth: function() {
13903 // make all axis cells line up, and record the width so newly created axis cells will have it
13904 this.axisWidth = matchCellWidths(this.el.find('.fc-axis'));
13905 },
13906
13907
13908 // Adjusts the vertical dimensions of the view to the specified values
13909 setHeight: function(totalHeight, isAuto) {
13910 var eventLimit;
13911 var scrollerHeight;
13912 var scrollbarWidths;
13913
13914 // reset all dimensions back to the original state
13915 this.bottomRuleEl.hide(); // .show() will be called later if this <hr> is necessary
13916 this.scroller.clear(); // sets height to 'auto' and clears overflow
13917 uncompensateScroll(this.noScrollRowEls);
13918
13919 // limit number of events in the all-day area
13920 if (this.dayGrid) {
13921 this.dayGrid.removeSegPopover(); // kill the "more" popover if displayed
13922
13923 eventLimit = this.opt('eventLimit');
13924 if (eventLimit && typeof eventLimit !== 'number') {
13925 eventLimit = AGENDA_ALL_DAY_EVENT_LIMIT; // make sure "auto" goes to a real number
13926 }
13927 if (eventLimit) {
13928 this.dayGrid.limitRows(eventLimit);
13929 }
13930 }
13931
13932 if (!isAuto) { // should we force dimensions of the scroll container?
13933
13934 scrollerHeight = this.computeScrollerHeight(totalHeight);
13935 this.scroller.setHeight(scrollerHeight);
13936 scrollbarWidths = this.scroller.getScrollbarWidths();
13937
13938 if (scrollbarWidths.left || scrollbarWidths.right) { // using scrollbars?
13939
13940 // make the all-day and header rows lines up
13941 compensateScroll(this.noScrollRowEls, scrollbarWidths);
13942
13943 // the scrollbar compensation might have changed text flow, which might affect height, so recalculate
13944 // and reapply the desired height to the scroller.
13945 scrollerHeight = this.computeScrollerHeight(totalHeight);
13946 this.scroller.setHeight(scrollerHeight);
13947 }
13948
13949 // guarantees the same scrollbar widths
13950 this.scroller.lockOverflow(scrollbarWidths);
13951
13952 // if there's any space below the slats, show the horizontal rule.
13953 // this won't cause any new overflow, because lockOverflow already called.
13954 if (this.timeGrid.getTotalSlatHeight() < scrollerHeight) {
13955 this.bottomRuleEl.show();
13956 }
13957 }
13958 },
13959
13960
13961 // given a desired total height of the view, returns what the height of the scroller should be
13962 computeScrollerHeight: function(totalHeight) {
13963 return totalHeight -
13964 subtractInnerElHeight(this.el, this.scroller.el); // everything that's NOT the scroller
13965 },
13966
13967
13968 /* Scroll
13969 ------------------------------------------------------------------------------------------------------------------*/
13970
13971
13972 // Computes the initial pre-configured scroll state prior to allowing the user to change it
13973 computeInitialScroll: function() {
13974 var scrollTime = moment.duration(this.opt('scrollTime'));
13975 var top = this.timeGrid.computeTimeTop(scrollTime);
13976
13977 // zoom can give weird floating-point values. rather scroll a little bit further
13978 top = Math.ceil(top);
13979
13980 if (top) {
13981 top++; // to overcome top border that slots beyond the first have. looks better
13982 }
13983
13984 return { top: top };
13985 },
13986
13987
13988 queryScroll: function() {
13989 return { top: this.scroller.getScrollTop() };
13990 },
13991
13992
13993 setScroll: function(scroll) {
13994 this.scroller.setScrollTop(scroll.top);
13995 },
13996
13997
13998 /* Hit Areas
13999 ------------------------------------------------------------------------------------------------------------------*/
14000 // forward all hit-related method calls to the grids (dayGrid might not be defined)
14001
14002
14003 hitsNeeded: function() {
14004 this.timeGrid.hitsNeeded();
14005 if (this.dayGrid) {
14006 this.dayGrid.hitsNeeded();
14007 }
14008 },
14009
14010
14011 hitsNotNeeded: function() {
14012 this.timeGrid.hitsNotNeeded();
14013 if (this.dayGrid) {
14014 this.dayGrid.hitsNotNeeded();
14015 }
14016 },
14017
14018
14019 prepareHits: function() {
14020 this.timeGrid.prepareHits();
14021 if (this.dayGrid) {
14022 this.dayGrid.prepareHits();
14023 }
14024 },
14025
14026
14027 releaseHits: function() {
14028 this.timeGrid.releaseHits();
14029 if (this.dayGrid) {
14030 this.dayGrid.releaseHits();
14031 }
14032 },
14033
14034
14035 queryHit: function(left, top) {
14036 var hit = this.timeGrid.queryHit(left, top);
14037
14038 if (!hit && this.dayGrid) {
14039 hit = this.dayGrid.queryHit(left, top);
14040 }
14041
14042 return hit;
14043 },
14044
14045
14046 getHitSpan: function(hit) {
14047 // TODO: hit.component is set as a hack to identify where the hit came from
14048 return hit.component.getHitSpan(hit);
14049 },
14050
14051
14052 getHitEl: function(hit) {
14053 // TODO: hit.component is set as a hack to identify where the hit came from
14054 return hit.component.getHitEl(hit);
14055 },
14056
14057
14058 /* Events
14059 ------------------------------------------------------------------------------------------------------------------*/
14060
14061
14062 // Renders events onto the view and populates the View's segment array
14063 renderEvents: function(events) {
14064 var dayEvents = [];
14065 var timedEvents = [];
14066 var daySegs = [];
14067 var timedSegs;
14068 var i;
14069
14070 // separate the events into all-day and timed
14071 for (i = 0; i < events.length; i++) {
14072 if (events[i].allDay) {
14073 dayEvents.push(events[i]);
14074 }
14075 else {
14076 timedEvents.push(events[i]);
14077 }
14078 }
14079
14080 // render the events in the subcomponents
14081 timedSegs = this.timeGrid.renderEvents(timedEvents);
14082 if (this.dayGrid) {
14083 daySegs = this.dayGrid.renderEvents(dayEvents);
14084 }
14085
14086 // the all-day area is flexible and might have a lot of events, so shift the height
14087 this.updateHeight();
14088 },
14089
14090
14091 // Retrieves all segment objects that are rendered in the view
14092 getEventSegs: function() {
14093 return this.timeGrid.getEventSegs().concat(
14094 this.dayGrid ? this.dayGrid.getEventSegs() : []
14095 );
14096 },
14097
14098
14099 // Unrenders all event elements and clears internal segment data
14100 unrenderEvents: function() {
14101
14102 // unrender the events in the subcomponents
14103 this.timeGrid.unrenderEvents();
14104 if (this.dayGrid) {
14105 this.dayGrid.unrenderEvents();
14106 }
14107
14108 // we DON'T need to call updateHeight() because
14109 // a renderEvents() call always happens after this, which will eventually call updateHeight()
14110 },
14111
14112
14113 /* Dragging (for events and external elements)
14114 ------------------------------------------------------------------------------------------------------------------*/
14115
14116
14117 // A returned value of `true` signals that a mock "helper" event has been rendered.
14118 renderDrag: function(dropLocation, seg) {
14119 if (dropLocation.start.hasTime()) {
14120 return this.timeGrid.renderDrag(dropLocation, seg);
14121 }
14122 else if (this.dayGrid) {
14123 return this.dayGrid.renderDrag(dropLocation, seg);
14124 }
14125 },
14126
14127
14128 unrenderDrag: function() {
14129 this.timeGrid.unrenderDrag();
14130 if (this.dayGrid) {
14131 this.dayGrid.unrenderDrag();
14132 }
14133 },
14134
14135
14136 /* Selection
14137 ------------------------------------------------------------------------------------------------------------------*/
14138
14139
14140 // Renders a visual indication of a selection
14141 renderSelection: function(span) {
14142 if (span.start.hasTime() || span.end.hasTime()) {
14143 this.timeGrid.renderSelection(span);
14144 }
14145 else if (this.dayGrid) {
14146 this.dayGrid.renderSelection(span);
14147 }
14148 },
14149
14150
14151 // Unrenders a visual indications of a selection
14152 unrenderSelection: function() {
14153 this.timeGrid.unrenderSelection();
14154 if (this.dayGrid) {
14155 this.dayGrid.unrenderSelection();
14156 }
14157 }
14158
14159 });
14160
14161
14162 // Methods that will customize the rendering behavior of the AgendaView's timeGrid
14163 // TODO: move into TimeGrid
14164 var agendaTimeGridMethods = {
14165
14166
14167 // Generates the HTML that will go before the day-of week header cells
14168 renderHeadIntroHtml: function() {
14169 var view = this.view;
14170 var weekText;
14171
14172 if (view.opt('weekNumbers')) {
14173 weekText = this.start.format(view.opt('smallWeekFormat'));
14174
14175 return '' +
14176 '<th class="fc-axis fc-week-number ' + view.widgetHeaderClass + '" ' + view.axisStyleAttr() + '>' +
14177 view.buildGotoAnchorHtml( // aside from link, important for matchCellWidths
14178 { date: this.start, type: 'week', forceOff: this.colCnt > 1 },
14179 htmlEscape(weekText) // inner HTML
14180 ) +
14181 '</th>';
14182 }
14183 else {
14184 return '<th class="fc-axis ' + view.widgetHeaderClass + '" ' + view.axisStyleAttr() + '></th>';
14185 }
14186 },
14187
14188
14189 // Generates the HTML that goes before the bg of the TimeGrid slot area. Long vertical column.
14190 renderBgIntroHtml: function() {
14191 var view = this.view;
14192
14193 return '<td class="fc-axis ' + view.widgetContentClass + '" ' + view.axisStyleAttr() + '></td>';
14194 },
14195
14196
14197 // Generates the HTML that goes before all other types of cells.
14198 // Affects content-skeleton, helper-skeleton, highlight-skeleton for both the time-grid and day-grid.
14199 renderIntroHtml: function() {
14200 var view = this.view;
14201
14202 return '<td class="fc-axis" ' + view.axisStyleAttr() + '></td>';
14203 }
14204
14205 };
14206
14207
14208 // Methods that will customize the rendering behavior of the AgendaView's dayGrid
14209 var agendaDayGridMethods = {
14210
14211
14212 // Generates the HTML that goes before the all-day cells
14213 renderBgIntroHtml: function() {
14214 var view = this.view;
14215
14216 return '' +
14217 '<td class="fc-axis ' + view.widgetContentClass + '" ' + view.axisStyleAttr() + '>' +
14218 '<span>' + // needed for matchCellWidths
14219 view.getAllDayHtml() +
14220 '</span>' +
14221 '</td>';
14222 },
14223
14224
14225 // Generates the HTML that goes before all other types of cells.
14226 // Affects content-skeleton, helper-skeleton, highlight-skeleton for both the time-grid and day-grid.
14227 renderIntroHtml: function() {
14228 var view = this.view;
14229
14230 return '<td class="fc-axis" ' + view.axisStyleAttr() + '></td>';
14231 }
14232
14233 };
14234
14235 ;;
14236
14237 var AGENDA_ALL_DAY_EVENT_LIMIT = 5;
14238
14239 // potential nice values for the slot-duration and interval-duration
14240 // from largest to smallest
14241 var AGENDA_STOCK_SUB_DURATIONS = [
14242 { hours: 1 },
14243 { minutes: 30 },
14244 { minutes: 15 },
14245 { seconds: 30 },
14246 { seconds: 15 }
14247 ];
14248
14249 fcViews.agenda = {
14250 'class': AgendaView,
14251 defaults: {
14252 allDaySlot: true,
14253 slotDuration: '00:30:00',
14254 minTime: '00:00:00',
14255 maxTime: '24:00:00',
14256 slotEventOverlap: true // a bad name. confused with overlap/constraint system
14257 }
14258 };
14259
14260 fcViews.agendaDay = {
14261 type: 'agenda',
14262 duration: { days: 1 }
14263 };
14264
14265 fcViews.agendaWeek = {
14266 type: 'agenda',
14267 duration: { weeks: 1 }
14268 };
14269 ;;
14270
14271 /*
14272 Responsible for the scroller, and forwarding event-related actions into the "grid"
14273 */
14274 var ListView = View.extend({
14275
14276 grid: null,
14277 scroller: null,
14278
14279 initialize: function() {
14280 this.grid = new ListViewGrid(this);
14281 this.scroller = new Scroller({
14282 overflowX: 'hidden',
14283 overflowY: 'auto'
14284 });
14285 },
14286
14287 setRange: function(range) {
14288 View.prototype.setRange.call(this, range); // super
14289
14290 this.grid.setRange(range); // needs to process range-related options
14291 },
14292
14293 renderSkeleton: function() {
14294 this.el.addClass(
14295 'fc-list-view ' +
14296 this.widgetContentClass
14297 );
14298
14299 this.scroller.render();
14300 this.scroller.el.appendTo(this.el);
14301
14302 this.grid.setElement(this.scroller.scrollEl);
14303 },
14304
14305 unrenderSkeleton: function() {
14306 this.scroller.destroy(); // will remove the Grid too
14307 },
14308
14309 setHeight: function(totalHeight, isAuto) {
14310 this.scroller.setHeight(this.computeScrollerHeight(totalHeight));
14311 },
14312
14313 computeScrollerHeight: function(totalHeight) {
14314 return totalHeight -
14315 subtractInnerElHeight(this.el, this.scroller.el); // everything that's NOT the scroller
14316 },
14317
14318 renderEvents: function(events) {
14319 this.grid.renderEvents(events);
14320 },
14321
14322 unrenderEvents: function() {
14323 this.grid.unrenderEvents();
14324 },
14325
14326 isEventResizable: function(event) {
14327 return false;
14328 },
14329
14330 isEventDraggable: function(event) {
14331 return false;
14332 }
14333
14334 });
14335
14336 /*
14337 Responsible for event rendering and user-interaction.
14338 Its "el" is the inner-content of the above view's scroller.
14339 */
14340 var ListViewGrid = Grid.extend({
14341
14342 segSelector: '.fc-list-item', // which elements accept event actions
14343 hasDayInteractions: false, // no day selection or day clicking
14344
14345 // slices by day
14346 spanToSegs: function(span) {
14347 var view = this.view;
14348 var dayStart = view.start.clone().time(0); // timed, so segs get times!
14349 var dayIndex = 0;
14350 var seg;
14351 var segs = [];
14352
14353 while (dayStart < view.end) {
14354
14355 seg = intersectRanges(span, {
14356 start: dayStart,
14357 end: dayStart.clone().add(1, 'day')
14358 });
14359
14360 if (seg) {
14361 seg.dayIndex = dayIndex;
14362 segs.push(seg);
14363 }
14364
14365 dayStart.add(1, 'day');
14366 dayIndex++;
14367
14368 // detect when span won't go fully into the next day,
14369 // and mutate the latest seg to the be the end.
14370 if (
14371 seg && !seg.isEnd && span.end.hasTime() &&
14372 span.end < dayStart.clone().add(this.view.nextDayThreshold)
14373 ) {
14374 seg.end = span.end.clone();
14375 seg.isEnd = true;
14376 break;
14377 }
14378 }
14379
14380 return segs;
14381 },
14382
14383 // like "4:00am"
14384 computeEventTimeFormat: function() {
14385 return this.view.opt('mediumTimeFormat');
14386 },
14387
14388 // for events with a url, the whole <tr> should be clickable,
14389 // but it's impossible to wrap with an <a> tag. simulate this.
14390 handleSegClick: function(seg, ev) {
14391 var url;
14392
14393 Grid.prototype.handleSegClick.apply(this, arguments); // super. might prevent the default action
14394
14395 // not clicking on or within an <a> with an href
14396 if (!$(ev.target).closest('a[href]').length) {
14397 url = seg.event.url;
14398 if (url && !ev.isDefaultPrevented()) { // jsEvent not cancelled in handler
14399 window.location.href = url; // simulate link click
14400 }
14401 }
14402 },
14403
14404 // returns list of foreground segs that were actually rendered
14405 renderFgSegs: function(segs) {
14406 segs = this.renderFgSegEls(segs); // might filter away hidden events
14407
14408 if (!segs.length) {
14409 this.renderEmptyMessage();
14410 }
14411 else {
14412 this.renderSegList(segs);
14413 }
14414
14415 return segs;
14416 },
14417
14418 renderEmptyMessage: function() {
14419 this.el.html(
14420 '<div class="fc-list-empty-wrap2">' + // TODO: try less wraps
14421 '<div class="fc-list-empty-wrap1">' +
14422 '<div class="fc-list-empty">' +
14423 htmlEscape(this.view.opt('noEventsMessage')) +
14424 '</div>' +
14425 '</div>' +
14426 '</div>'
14427 );
14428 },
14429
14430 // render the event segments in the view
14431 renderSegList: function(allSegs) {
14432 var segsByDay = this.groupSegsByDay(allSegs); // sparse array
14433 var dayIndex;
14434 var daySegs;
14435 var i;
14436 var tableEl = $('<table class="fc-list-table"><tbody/></table>');
14437 var tbodyEl = tableEl.find('tbody');
14438
14439 for (dayIndex = 0; dayIndex < segsByDay.length; dayIndex++) {
14440 daySegs = segsByDay[dayIndex];
14441 if (daySegs) { // sparse array, so might be undefined
14442
14443 // append a day header
14444 tbodyEl.append(this.dayHeaderHtml(
14445 this.view.start.clone().add(dayIndex, 'days')
14446 ));
14447
14448 this.sortEventSegs(daySegs);
14449
14450 for (i = 0; i < daySegs.length; i++) {
14451 tbodyEl.append(daySegs[i].el); // append event row
14452 }
14453 }
14454 }
14455
14456 this.el.empty().append(tableEl);
14457 },
14458
14459 // Returns a sparse array of arrays, segs grouped by their dayIndex
14460 groupSegsByDay: function(segs) {
14461 var segsByDay = []; // sparse array
14462 var i, seg;
14463
14464 for (i = 0; i < segs.length; i++) {
14465 seg = segs[i];
14466 (segsByDay[seg.dayIndex] || (segsByDay[seg.dayIndex] = []))
14467 .push(seg);
14468 }
14469
14470 return segsByDay;
14471 },
14472
14473 // generates the HTML for the day headers that live amongst the event rows
14474 dayHeaderHtml: function(dayDate) {
14475 var view = this.view;
14476 var mainFormat = view.opt('listDayFormat');
14477 var altFormat = view.opt('listDayAltFormat');
14478
14479 return '<tr class="fc-list-heading" data-date="' + dayDate.format('YYYY-MM-DD') + '">' +
14480 '<td class="' + view.widgetHeaderClass + '" colspan="3">' +
14481 (mainFormat ?
14482 view.buildGotoAnchorHtml(
14483 dayDate,
14484 { 'class': 'fc-list-heading-main' },
14485 htmlEscape(dayDate.format(mainFormat)) // inner HTML
14486 ) :
14487 '') +
14488 (altFormat ?
14489 view.buildGotoAnchorHtml(
14490 dayDate,
14491 { 'class': 'fc-list-heading-alt' },
14492 htmlEscape(dayDate.format(altFormat)) // inner HTML
14493 ) :
14494 '') +
14495 '</td>' +
14496 '</tr>';
14497 },
14498
14499 // generates the HTML for a single event row
14500 fgSegHtml: function(seg) {
14501 var view = this.view;
14502 var classes = [ 'fc-list-item' ].concat(this.getSegCustomClasses(seg));
14503 var bgColor = this.getSegBackgroundColor(seg);
14504 var event = seg.event;
14505 var url = event.url;
14506 var timeHtml;
14507
14508 if (event.allDay) {
14509 timeHtml = view.getAllDayHtml();
14510 }
14511 else if (view.isMultiDayEvent(event)) { // if the event appears to span more than one day
14512 if (seg.isStart || seg.isEnd) { // outer segment that probably lasts part of the day
14513 timeHtml = htmlEscape(this.getEventTimeText(seg));
14514 }
14515 else { // inner segment that lasts the whole day
14516 timeHtml = view.getAllDayHtml();
14517 }
14518 }
14519 else {
14520 // Display the normal time text for the *event's* times
14521 timeHtml = htmlEscape(this.getEventTimeText(event));
14522 }
14523
14524 if (url) {
14525 classes.push('fc-has-url');
14526 }
14527
14528 return '<tr class="' + classes.join(' ') + '">' +
14529 (this.displayEventTime ?
14530 '<td class="fc-list-item-time ' + view.widgetContentClass + '">' +
14531 (timeHtml || '') +
14532 '</td>' :
14533 '') +
14534 '<td class="fc-list-item-marker ' + view.widgetContentClass + '">' +
14535 '<span class="fc-event-dot"' +
14536 (bgColor ?
14537 ' style="background-color:' + bgColor + '"' :
14538 '') +
14539 '></span>' +
14540 '</td>' +
14541 '<td class="fc-list-item-title ' + view.widgetContentClass + '">' +
14542 '<a' + (url ? ' href="' + htmlEscape(url) + '"' : '') + '>' +
14543 htmlEscape(seg.event.title || '') +
14544 '</a>' +
14545 '</td>' +
14546 '</tr>';
14547 }
14548
14549 });
14550
14551 ;;
14552
14553 fcViews.list = {
14554 'class': ListView,
14555 buttonTextKey: 'list', // what to lookup in locale files
14556 defaults: {
14557 buttonText: 'list', // text to display for English
14558 listDayFormat: 'LL', // like "January 1, 2016"
14559 noEventsMessage: 'No events to display'
14560 }
14561 };
14562
14563 fcViews.listDay = {
14564 type: 'list',
14565 duration: { days: 1 },
14566 defaults: {
14567 listDayFormat: 'dddd' // day-of-week is all we need. full date is probably in header
14568 }
14569 };
14570
14571 fcViews.listWeek = {
14572 type: 'list',
14573 duration: { weeks: 1 },
14574 defaults: {
14575 listDayFormat: 'dddd', // day-of-week is more important
14576 listDayAltFormat: 'LL'
14577 }
14578 };
14579
14580 fcViews.listMonth = {
14581 type: 'list',
14582 duration: { month: 1 },
14583 defaults: {
14584 listDayAltFormat: 'dddd' // day-of-week is nice-to-have
14585 }
14586 };
14587
14588 fcViews.listYear = {
14589 type: 'list',
14590 duration: { year: 1 },
14591 defaults: {
14592 listDayAltFormat: 'dddd' // day-of-week is nice-to-have
14593 }
14594 };
14595
14596 ;;
14597
14598 return FC; // export for Node/CommonJS
14599 });